security: multiple reported CVE fixes (#1515)

* update out of date license

* update typing / refactor

* fix arbitrarty path injection

* use markdown sanatizer to prevent XSS CWE-79

* fix CWE-918 SSRF by validating url and mime type

* add security docs

* update recipe-scrapers

* resolve DOS from arbitrary url

* update changelog

* bump version

* add ref to #1506

* add #1511 to changelog

* use requests decoder

* actually fix encoding issue
This commit is contained in:
Hayden 2022-07-31 13:10:20 -08:00 committed by GitHub
parent 483f789b8e
commit 13850cda1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 401 additions and 118 deletions

View File

@ -1,30 +0,0 @@
# vx.x.x COOL TITLE GOES HERE
**App Version: vx.x.x**
**Database Version: vx.x.x**
## Breaking Changes
!!! error "Breaking Changes"
#### Database
#### ENV Variables
## Bug Fixes
- Fixed ...
## Features and Improvements
### General
- New Thing 1
### UI Improvements
-
### Behind the Scenes
- Refactoring...

View File

@ -0,0 +1,126 @@
### Security
#### v1.0.0beta-3 and Under - Recipe Scraper: Server Side Request Forgery Lead To Denial Of Service
!!! error "CWE-918: Server-Side Request Forgery (SSRF)"
In this case if a attacker try to load a huge file then server will try to load the file and eventually server use its all memory which will dos the server
##### Mitigation
HTML is now scraped via a Stream and canceled after a 15 second timeout to prevent arbitrary data from being loaded into the server.
#### v1.0.0beta-3 and Under - Recipe Assets: Remote Code Execution
!!! error "CWE-1336: Improper Neutralization of Special Elements Used in a Template Engine"
As a low privileged user, Create a new recipe and click on the "+" to add a New Asset.
Select a file, then proxy the request that will create the asset.
Since mealie/routes/recipe/recipe_crud_routes.py:306 is calling slugify on the name POST parameter, we use $ which slugify() will remove completely.
Since mealie/routes/recipe/recipe_crud_routes.py:306 is concatenating raw user input from the extension POST parameter into the variable file_name, which ultimately gets used when writing to disk, we can use a directory traversal attack in the extension (e.g. ./../../../tmp/pwn.txt) to write the file to arbitrary location on the server.
As an attacker, now that we have a strong attack primitive, we can start getting creative to get RCE. Since the files were being created by root, we could add an entry to /etc/passwd, create a crontab, etc. but since there was templating functionality in the application that peaked my interest. The PoC in the HTTP request above creates a Jinja2 template at /app/data/template/pwn.html. Since Jinja2 templates execute Python code when rendered, all we have to do now to get code execution is render the malicious template. This was easy enough.
##### Mitigation
We've added proper path sanitization to ensure that the user is not allowed to write to arbitrary locations on the server.
!!! warning "Breaking Change Incoming"
As this has shown a significant area of exposure in the templates that Mealie was provided for exporting recipes, we'll be removing this feature in the next Beta release and will instead rely on the community to provide tooling around transforming recipes using templates. This will significantly limit the possible exposure of users injecting malicious templates into the application. The template functionality will be completely removed in the next beta release v1.0.0beta-5
#### All version Markdown Editor: Cross Site Scripting
!!! error "CWE-79: Cross-site Scripting (XSS) - Stored"
A low privilege user can insert malicious JavaScript code into the Recipe Instructions which will execute in another person's browser that visits the recipe.
`<img src=x onerror=alert(document.domain)>`
##### Mitigation
This issues is present on all pages that allow markdown input. This error has been mitigated by wrapping the 3rd Party Markdown component and using the `domPurify` library to strip out the dangerous HTML.
#### v1.0.0beta-3 and Under - Image Scraper: Server-Side Request Forgery
!!! error "CWE-918: Server-Side Request Forgery (SSRF)"
In the recipe edit page, is possible to upload an image directly or via an URL provided by the user. The function that handles the fetching and saving of the image via the URL doesn't have any URL verification, which allows to fetch internal services.
Furthermore, after the resource is fetch, there is no MIME type validation, which would ensure that the resource is indeed an image. After this, because there is no extension in the provided URL, the application will fallback to jpg, and original for the image name.
Then the result is saved to disk with the original.jpg name, that can be retrieved from the following URL: http://<domain>/api/media/recipes/<recipe-uid>/images/original.jpg. This file will contain the full response of the provided URL.
**Impact**
An attacker can get sensitive information of any internal-only services running. For example, if the application is hosted on Amazon Web Services (AWS) platform, its possible to fetch the AWS API endpoint, https://169.254.169.254, which returns API keys and other sensitive metadata.
##### Mitigation
Two actions were taken to reduce exposure to SSRF in this case.
1. The application will not prevent requests being made to local resources by checking for localhost or 127.0.0.1 domain names.
2. The mime-type of the response is now checked prior to writing to disk.
If either of the above actions prevent the user from uploading images, the application will alert the user of what error occurred.
### Bug Fixes
- For erroneously-translated datetime config ([#1362](https://github.com/hay-kot/mealie/issues/1362))
- Fixed text color on RecipeCard in RecipePrintView and implemented ingredient sections ([#1351](https://github.com/hay-kot/mealie/issues/1351))
- Ingredient sections lost after parsing ([#1368](https://github.com/hay-kot/mealie/issues/1368))
- Increased float rounding precision for CRF parser ([#1369](https://github.com/hay-kot/mealie/issues/1369))
- Infinite scroll bug on all recipes page ([#1393](https://github.com/hay-kot/mealie/issues/1393))
- Fast fail of bulk importer ([#1394](https://github.com/hay-kot/mealie/issues/1394))
- Bump @mdi/js from 5.9.55 to 6.7.96 in /frontend ([#1279](https://github.com/hay-kot/mealie/issues/1279))
- Bump @nuxtjs/i18n from 7.0.3 to 7.2.2 in /frontend ([#1288](https://github.com/hay-kot/mealie/issues/1288))
- Bump date-fns from 2.23.0 to 2.28.0 in /frontend ([#1293](https://github.com/hay-kot/mealie/issues/1293))
- Bump fuse.js from 6.5.3 to 6.6.2 in /frontend ([#1325](https://github.com/hay-kot/mealie/issues/1325))
- Bump core-js from 3.17.2 to 3.23.1 in /frontend ([#1383](https://github.com/hay-kot/mealie/issues/1383))
- All-recipes page now sorts alphabetically ([#1405](https://github.com/hay-kot/mealie/issues/1405))
- Sort recent recipes by created_at instead of date_added ([#1417](https://github.com/hay-kot/mealie/issues/1417))
- Only show scaler when ingredients amounts enabled ([#1426](https://github.com/hay-kot/mealie/issues/1426))
- Add missing types for API token deletion ([#1428](https://github.com/hay-kot/mealie/issues/1428))
- Entry nutrition checker ([#1448](https://github.com/hay-kot/mealie/issues/1448))
- Use == operator instead of is_ for sql queries ([#1453](https://github.com/hay-kot/mealie/issues/1453))
- Use `mtime` instead of `ctime` for backup dates ([#1461](https://github.com/hay-kot/mealie/issues/1461))
- Mealplan pagination ([#1464](https://github.com/hay-kot/mealie/issues/1464))
- Properly use pagination for group event notifies ([#1512](https://github.com/hay-kot/mealie/pull/1512))
### Documentation
- Add go bulk import example ([#1388](https://github.com/hay-kot/mealie/issues/1388))
- Fix old link
- Pagination and filtering, and fixed a few broken links ([#1488](https://github.com/hay-kot/mealie/issues/1488))
### Features
- Toggle display of ingredient references in recipe instructions ([#1268](https://github.com/hay-kot/mealie/issues/1268))
- Add custom scaling option ([#1345](https://github.com/hay-kot/mealie/issues/1345))
- Implemented "order by" API parameters for recipe, food, and unit queries ([#1356](https://github.com/hay-kot/mealie/issues/1356))
- Implement user favorites page ([#1376](https://github.com/hay-kot/mealie/issues/1376))
- Extend Apprise JSON notification functionality with programmatic data ([#1355](https://github.com/hay-kot/mealie/issues/1355))
- Mealplan-webhooks ([#1403](https://github.com/hay-kot/mealie/issues/1403))
- Added "last-modified" header to supported record types ([#1379](https://github.com/hay-kot/mealie/issues/1379))
- Re-write get all routes to use pagination ([#1424](https://github.com/hay-kot/mealie/issues/1424))
- Advanced filtering API ([#1468](https://github.com/hay-kot/mealie/issues/1468))
- Restore frontend sorting for all recipes ([#1497](https://github.com/hay-kot/mealie/issues/1497))
- Implemented local storage for sorting and dynamic sort icons on the new recipe sort card ([1506](https://github.com/hay-kot/mealie/pull/1506))
- create new foods and units from their Data Management pages ([#1511](https://github.com/hay-kot/mealie/pull/1511))
### Miscellaneous Tasks
- Bump dev deps ([#1418](https://github.com/hay-kot/mealie/issues/1418))
- Bump @vue/runtime-dom in /frontend ([#1423](https://github.com/hay-kot/mealie/issues/1423))
- Backend page_all route cleanup ([#1483](https://github.com/hay-kot/mealie/issues/1483))
### Refactor
- Remove depreciated repo call ([#1370](https://github.com/hay-kot/mealie/issues/1370))
### Hotfix
- Tame typescript beast
### UI
- Improve parser ui text display ([#1437](https://github.com/hay-kot/mealie/issues/1437))
<!-- generated by git-cliff -->

View File

@ -10,7 +10,7 @@
version: "3.7"
services:
mealie-frontend:
image: hkotel/mealie:frontend-v1.0.0beta-3
image: hkotel/mealie:frontend-v1.0.0beta-4
container_name: mealie-frontend
depends_on:
- mealie-api
@ -23,7 +23,7 @@ services:
volumes:
- mealie-data:/app/data/ # (3)
mealie-api:
image: hkotel/mealie:api-v1.0.0beta-3
image: hkotel/mealie:api-v1.0.0beta-4
container_name: mealie-api
depends_on:
- postgres

View File

@ -12,7 +12,7 @@ SQLite is a popular, open source, self-contained, zero-configuration database th
version: "3.7"
services:
mealie-frontend:
image: hkotel/mealie:frontend-v1.0.0beta-3
image: hkotel/mealie:frontend-v1.0.0beta-4
container_name: mealie-frontend
environment:
# Set Frontend ENV Variables Here
@ -23,7 +23,7 @@ services:
volumes:
- mealie-data:/app/data/ # (3)
mealie-api:
image: hkotel/mealie:api-v1.0.0beta-3
image: hkotel/mealie:api-v1.0.0beta-4
container_name: mealie-api
volumes:
- mealie-data:/app/data/

File diff suppressed because one or more lines are too long

View File

@ -88,6 +88,7 @@ nav:
- Improving Ingredient Parser: "contributors/guides/ingredient-parser.md"
- Change Log:
- v1.0.0beta-4: "changelog/v1.0.0beta-4.md"
- v1.0.0beta-3: "changelog/v1.0.0beta-3.md"
- v1.0.0beta-2: "changelog/v1.0.0beta-2.md"
- v1.0.0 Beta: "changelog/v1.0.0.md"

View File

@ -11,7 +11,7 @@
<v-list-item dense @click="toggleChecked(index)">
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
<v-list-item-content :key="ingredient.quantity">
<VueMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" />
<SafeMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredientDisplay[index]" />
</v-list-item-content>
</v-list-item>
</div>
@ -22,14 +22,11 @@
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import { parseIngredientText } from "~/composables/recipes";
import { RecipeIngredient } from "~/types/api-types/recipe";
export default defineComponent({
components: {
VueMarkdown,
},
components: {},
props: {
value: {
type: Array as () => RecipeIngredient[],

View File

@ -197,7 +197,7 @@
<v-expand-transition>
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
<v-card-text class="markdown">
<VueMarkdown class="markdown" :source="step.text"> </VueMarkdown>
<SafeMarkdown class="markdown" :source="step.text" />
<div v-if="cookMode && step.ingredientReferences && step.ingredientReferences.length > 0">
<v-divider class="mb-2"></v-divider>
<div
@ -219,8 +219,6 @@
<script lang="ts">
import draggable from "vuedraggable";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import {
ref,
toRefs,
@ -245,7 +243,6 @@ interface MergerHistory {
export default defineComponent({
components: {
VueMarkdown,
draggable,
},
props: {

View File

@ -18,7 +18,7 @@
{{ note.title }}
</v-card-title>
<v-card-text>
<VueMarkdown :source="note.text"> </VueMarkdown>
<SafeMarkdown :source="note.text" />
</v-card-text>
</div>
</div>
@ -30,15 +30,10 @@
</template>
<script lang="ts">
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import { defineComponent } from "@nuxtjs/composition-api";
import { RecipeNote } from "~/types/api-types/recipe";
export default defineComponent({
components: {
VueMarkdown,
},
props: {
value: {
type: Array as () => RecipeNote[],

View File

@ -11,7 +11,7 @@
</section>
<v-card-text class="px-0">
<VueMarkdown :source="recipe.description" />
<SafeMarkdown :source="recipe.description" />
</v-card-text>
<!-- Ingredients -->
@ -47,7 +47,7 @@
{{ step.title }}
</h4>
<h5>{{ $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5>
<VueMarkdown :source="step.text" class="recipe-step-body" />
<SafeMarkdown :source="step.text" class="recipe-step-body" />
</div>
</div>
</div>
@ -60,7 +60,7 @@
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<div class="print-section">
<h4>{{ note.title }}</h4>
<VueMarkdown :source="note.text" class="note-body" />
<SafeMarkdown :source="note.text" class="note-body" />
</div>
</div>
</section>
@ -69,8 +69,6 @@
<script lang="ts">
import { defineComponent, computed } from "@nuxtjs/composition-api";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { Recipe, RecipeIngredient, RecipeStep } from "~/types/api-types/recipe";
import { parseIngredientText } from "~/composables/recipes";
@ -89,7 +87,6 @@ type InstructionSection = {
export default defineComponent({
components: {
RecipeTimeCard,
VueMarkdown,
},
props: {
recipe: {

View File

@ -22,21 +22,15 @@
dense
rows="4"
/>
<VueMarkdown v-else :source="value" />
<SafeMarkdown v-else :source="value" />
</div>
</template>
<script lang="ts">
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import { defineComponent, computed, ref } from "@nuxtjs/composition-api";
export default defineComponent({
name: "MarkdownEditor",
components: {
VueMarkdown,
},
props: {
value: {
type: String,

View File

@ -0,0 +1,42 @@
<template>
<VueMarkdown :source="sanitizeMarkdown(source)"></VueMarkdown>
</template>
<script lang="ts">
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import { defineComponent } from "@nuxtjs/composition-api";
import DOMPurify from "isomorphic-dompurify";
export default defineComponent({
components: {
VueMarkdown,
},
props: {
source: {
type: String,
default: "",
},
},
setup() {
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
if (!rawHtml) {
return "";
}
const sanitized = DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
// TODO: some more thought could be put into what is allowed and what isn't
ALLOWED_TAGS: ["img", "div", "p"],
ADD_ATTR: ["src", "alt", "height", "width", "class"],
});
return sanitized;
}
return {
sanitizeMarkdown,
};
},
});
</script>

View File

@ -17,7 +17,7 @@
<RecipeRating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<SafeMarkdown :source="recipe.description" />
<v-divider></v-divider>
<div class="d-flex justify-center mt-5">
<RecipeTimeCard
@ -81,7 +81,7 @@
<v-card-title class="px-0 py-2 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<SafeMarkdown :source="recipe.description" />
<div class="pb-2 d-flex justify-center flex-wrap">
<RecipeTimeCard
@ -465,8 +465,6 @@ import {
useRouter,
onMounted,
} from "@nuxtjs/composition-api";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
import draggable from "vuedraggable";
import { invoke, until, useWakeLock } from "@vueuse/core";
import { onUnmounted } from "vue-demi";
@ -494,7 +492,6 @@ import { Recipe } from "~/types/api-types/recipe";
import { uuid4, deepCopy } from "~/composables/use-utils";
import { useRouteQuery } from "~/composables/use-router";
import { useToolStore } from "~/composables/store";
export default defineComponent({
components: {
draggable,
@ -520,7 +517,6 @@ export default defineComponent({
RecipeTimeCard,
RecipeTools,
RecipeScaleEditButton,
VueMarkdown,
},
async beforeRouteLeave(_to, _from, next) {
const isSame = JSON.stringify(this.recipe) === JSON.stringify(this.originalRecipe);

View File

@ -17,7 +17,7 @@
<RecipeRating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
</v-card-title>
<v-divider class="my-2"></v-divider>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<SafeMarkdown :source="recipe.description"> </SafeMarkdown>
<v-divider></v-divider>
<div class="d-flex justify-center mt-5">
<RecipeTimeCard
@ -61,7 +61,7 @@
<v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }}
</v-card-title>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<SafeMarkdown :source="recipe.description"> </SafeMarkdown>
</template>
<template v-else-if="form">
@ -273,8 +273,6 @@ import {
useMeta,
useRoute,
} from "@nuxtjs/composition-api";
// @ts-ignore vue-markdown has no types
import VueMarkdown from "@adapttive/vue-markdown";
// import { useRecipeMeta } from "~/composables/recipes";
import { useStaticRoutes, useUserApi } from "~/composables/api";
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
@ -296,7 +294,6 @@ export default defineComponent({
RecipePrintView,
RecipeRating,
RecipeTimeCard,
VueMarkdown,
},
layout: "basic",
setup() {

View File

@ -21,6 +21,7 @@ import ToggleState from "@/components/global/ToggleState.vue";
import ContextMenu from "@/components/global/ContextMenu.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import SafeMarkdown from "@/components/global/SafeMarkdown.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
@ -59,6 +60,7 @@ declare module "vue" {
ContextMenu: typeof ContextMenu;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
SafeMarkdown: typeof SafeMarkdown;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;

View File

@ -1,6 +1,6 @@
from pathlib import Path
APP_VERSION = "v1.0.0beta-3"
APP_VERSION = "v1.0.0beta-4"
CWD = Path(__file__).parent
BASE_DIR = CWD.parent.parent.parent

View File

@ -29,13 +29,13 @@ from mealie.schema.response.responses import ErrorResponse
from mealie.services import urls
from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource
from mealie.services.event_bus_service.message_types import EventTypes
from mealie.services.recipe.recipe_data_service import RecipeDataService
from mealie.services.recipe.recipe_data_service import InvalidDomainError, NotAnImageError, RecipeDataService
from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService
from mealie.services.scraper.recipe_bulk_scraper import RecipeBulkScraperService
from mealie.services.scraper.scraped_extras import ScraperContext
from mealie.services.scraper.scraper import create_from_url
from mealie.services.scraper.scraper_strategies import RecipeScraperPackage
from mealie.services.scraper.scraper_strategies import ForceTimeoutException, RecipeScraperPackage
class BaseRecipeController(BaseUserController):
@ -139,7 +139,12 @@ class RecipeController(BaseRecipeController):
@router.post("/create-url", status_code=201, response_model=str)
def parse_recipe_url(self, req: ScrapeRecipe):
"""Takes in a URL and attempts to scrape data and load it into the database"""
recipe, extras = create_from_url(req.url)
try:
recipe, extras = create_from_url(req.url)
except ForceTimeoutException as e:
raise HTTPException(
status_code=408, detail=ErrorResponse.respond(message="Recipe Scraping Timed Out")
) from e
if req.include_tags:
ctx = ScraperContext(self.user.id, self.group_id, self.repos)
@ -176,8 +181,13 @@ class RecipeController(BaseRecipeController):
@router.post("/test-scrape-url")
def test_parse_recipe_url(self, url: ScrapeRecipeTest):
# Debugger should produce the same result as the scraper sees before cleaning
if scraped_data := RecipeScraperPackage(url.url).scrape_url():
return scraped_data.schema.data
try:
if scraped_data := RecipeScraperPackage(url.url).scrape_url():
return scraped_data.schema.data
except ForceTimeoutException as e:
raise HTTPException(
status_code=408, detail=ErrorResponse.respond(message="Recipe Scraping Timed Out")
) from e
return "recipe_scrapers was unable to scrape this URL"
@ -314,7 +324,19 @@ class RecipeController(BaseRecipeController):
def scrape_image_url(self, slug: str, url: ScrapeRecipe):
recipe = self.mixins.get_one(slug)
data_service = RecipeDataService(recipe.id)
data_service.scrape_image(url.url)
try:
data_service.scrape_image(url.url)
except NotAnImageError as e:
raise HTTPException(
status_code=400,
detail=ErrorResponse.respond("Url is not an image"),
) from e
except InvalidDomainError as e:
raise HTTPException(
status_code=400,
detail=ErrorResponse.respond("Url is not from an allowed domain"),
) from e
recipe.image = cache.cache_key.new_key()
self.service.update_one(recipe.slug, recipe)
@ -338,13 +360,27 @@ class RecipeController(BaseRecipeController):
file: UploadFile = File(...),
):
"""Upload a file to store as a recipe asset"""
file_name = f"{slugify(name)}.{extension}"
if "." in extension:
extension = extension.split(".")[-1]
file_slug = slugify(name)
if not extension or not file_slug:
raise HTTPException(status_code=400, detail="Missing required fields")
file_name = f"{file_slug}.{extension}"
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
recipe = self.mixins.get_one(slug)
dest = recipe.asset_dir / file_name
# Ensure path is relative to the recipe's asset directory
if dest.absolute().parent != recipe.asset_dir:
raise HTTPException(
status_code=400,
detail=f"File name {file_name} or extension {extension} not valid",
)
with dest.open("wb") as buffer:
copyfileobj(file.file, buffer)

View File

@ -11,6 +11,14 @@ from mealie.services._base_service import BaseService
_FIREFOX_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:86.0) Gecko/20100101 Firefox/86.0"
class NotAnImageError(Exception):
pass
class InvalidDomainError(Exception):
pass
class RecipeDataService(BaseService):
minifier: img.ABCMinifier
@ -56,8 +64,26 @@ class RecipeDataService(BaseService):
return image_path
@staticmethod
def _validate_image_url(url: str) -> bool:
# sourcery skip: invert-any-all, use-any
"""
Validates that the URL is of an allowed source and restricts certain sources to prevent
malicious images from being downloaded.
"""
invalid_domains = {"127.0.0.1", "localhost"}
for domain in invalid_domains:
if domain in url:
return False
return True
def scrape_image(self, image_url) -> None:
self.logger.info(f"Image URL: {image_url}")
self.logger.debug(f"Image URL: {image_url}")
if not self._validate_image_url(image_url):
self.logger.error(f"Invalid image URL: {image_url}")
raise InvalidDomainError(f"Invalid domain: {image_url}")
if isinstance(image_url, str): # Handles String Types
pass
@ -74,7 +100,7 @@ class RecipeDataService(BaseService):
try:
r = requests.get(url, stream=True, headers={"User-Agent": _FIREFOX_UA})
except Exception:
self.logger.exception("Image {url} could not be requested")
self.logger.exception(f"Image {url} could not be requested")
continue
if r.status_code == 200:
all_image_requests.append((url, r))
@ -100,9 +126,19 @@ class RecipeDataService(BaseService):
self.logger.exception("Fatal Image Request Exception")
return None
if r.status_code == 200:
r.raw.decode_content = True
self.logger.info(f"File Name Suffix {file_path.suffix}")
self.write_image(r.raw, file_path.suffix)
if r.status_code != 200:
# TODO: Probably should throw an exception in this case as well, but before these changes
# we were returning None if it failed anyways.
return None
file_path.unlink(missing_ok=True)
content_type = r.headers.get("content-type", "")
if "image" not in content_type:
self.logger.error(f"Content-Type: {content_type} is not an image")
raise NotAnImageError(f"Content-Type {content_type} is not an image")
r.raw.decode_content = True
self.logger.info(f"File Name Suffix {file_path.suffix}")
self.write_image(r.raw, file_path.suffix)
file_path.unlink(missing_ok=True)

View File

@ -1,10 +1,11 @@
import time
from abc import ABC, abstractmethod
from typing import Any, Callable
import extruct
import requests
from fastapi import HTTPException, status
from recipe_scrapers import NoSchemaFoundInWildMode, SchemaScraperFactory, WebsiteNotImplementedError, scrape_me
from recipe_scrapers import NoSchemaFoundInWildMode, SchemaScraperFactory, scrape_html
from slugify import slugify
from w3lib.html import get_base_url
@ -14,6 +15,59 @@ from mealie.services.scraper.scraped_extras import ScrapedExtras
from . import cleaner
SCRAPER_TIMEOUT = 15
class ForceTimeoutException(Exception):
pass
def safe_scrape_html(url: str) -> str:
"""
Scrapes the html from a url but will cancel the request
if the request takes longer than 15 seconds. This is used to mitigate
DDOS attacks from users providing a url with arbitrary large content.
"""
resp = requests.get(url, timeout=SCRAPER_TIMEOUT, stream=True)
html_bytes = b""
start_time = time.time()
for chunk in resp.iter_content(chunk_size=1024):
html_bytes += chunk
if time.time() - start_time > SCRAPER_TIMEOUT:
raise ForceTimeoutException()
# =====================================
# Coppied from requests text property
# Try charset from content-type
content = None
encoding = resp.encoding
if not html_bytes:
return ""
# Fallback to auto-detected encoding.
if encoding is None:
encoding = resp.apparent_encoding
# Decode unicode from given encoding.
try:
content = str(html_bytes, encoding, errors="replace")
except (LookupError, TypeError):
# A LookupError is raised if the encoding was not found which could
# indicate a misspelling or similar mistake.
#
# A TypeError can be raised if encoding is None
#
# So we try blindly encoding.
content = str(html_bytes, errors="replace")
return content
class ABCScraperStrategy(ABC):
"""
@ -103,14 +157,13 @@ class RecipeScraperPackage(ABCScraperStrategy):
return recipe, extras
def scrape_url(self) -> SchemaScraperFactory.SchemaScraper | Any | None:
recipe_html = safe_scrape_html(self.url)
try:
scraped_schema = scrape_me(self.url)
except (WebsiteNotImplementedError, AttributeError):
try:
scraped_schema = scrape_me(self.url, wild_mode=True)
except (NoSchemaFoundInWildMode, AttributeError):
self.logger.error("Recipe Scraper was unable to extract a recipe.")
return None
scraped_schema = scrape_html(recipe_html, org_url=self.url)
except (NoSchemaFoundInWildMode, AttributeError):
self.logger.error("Recipe Scraper was unable to extract a recipe.")
return None
except ConnectionError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, {"details": "CONNECTION_ERROR"}) from e
@ -150,7 +203,7 @@ class RecipeScraperOpenGraph(ABCScraperStrategy):
"""
def get_html(self) -> str:
return requests.get(self.url).text
return safe_scrape_html(self.url)
def get_recipe_fields(self, html) -> dict | None:
"""

7
poetry.lock generated
View File

@ -1190,7 +1190,7 @@ rdflib = ">=5.0.0"
[[package]]
name = "recipe-scrapers"
version = "14.7.0"
version = "14.11.0"
description = "Python package, scraping recipes from all over the internet"
category = "main"
optional = false
@ -2288,7 +2288,10 @@ rdflib-jsonld = [
{file = "rdflib-jsonld-0.6.2.tar.gz", hash = "sha256:107cd3019d41354c31687e64af5e3fd3c3e3fa5052ce635f5ce595fd31853a63"},
{file = "rdflib_jsonld-0.6.2-py2.py3-none-any.whl", hash = "sha256:011afe67672353ca9978ab9a4bee964dff91f14042f2d8a28c22a573779d2f8b"},
]
recipe-scrapers = []
recipe-scrapers = [
{file = "recipe_scrapers-14.11.0-py3-none-any.whl", hash = "sha256:992b37ef2c29d66caaec82b2c5a1f9d901a74d2e267e60e505370c59ceadaeef"},
{file = "recipe_scrapers-14.11.0.tar.gz", hash = "sha256:85192e976388eeba9bb314c5cf75ac087ec1cfaf4b4aa1ffe580dae4099e2be9"},
]
requests = []
requests-oauthlib = [
{file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"},

View File

@ -3,7 +3,7 @@ name = "mealie"
version = "1.0.0b"
description = "A Recipe Manager"
authors = ["Hayden <hay-kot@pm.me>"]
license = "MIT"
license = "AGPL"
[tool.poetry.scripts]
start = "mealie.app:main"

View File

@ -1,9 +1,12 @@
import contextlib
from collections.abc import Generator
import sqlalchemy
from pytest import fixture
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe, RecipeCategory
from mealie.schema.recipe.recipe_category import CategorySave
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import CategoryOut, CategorySave
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient
from mealie.schema.recipe.recipe_step import RecipeStep
from tests.utils.factories import random_string
@ -47,15 +50,13 @@ def recipe_ingredient_only(database: AllRepositories, unique_user: TestUser):
yield model
try:
with contextlib.suppress(sqlalchemy.exc.NoResultFound):
database.recipes.delete(model.slug)
except sqlalchemy.exc.NoResultFound: # Entry Deleted in Test
pass
@fixture(scope="function")
def recipe_categories(database: AllRepositories, unique_user: TestUser) -> list[RecipeCategory]:
models: list[RecipeCategory] = []
def recipe_categories(database: AllRepositories, unique_user: TestUser) -> Generator[list[CategoryOut], None, None]:
models: list[CategoryOut] = []
for _ in range(3):
category = CategorySave(
group_id=unique_user.group_id,
@ -66,15 +67,13 @@ def recipe_categories(database: AllRepositories, unique_user: TestUser) -> list[
yield models
for model in models:
try:
database.categories.delete(model.id)
except sqlalchemy.exc.NoResultFound:
pass
for m in models:
with contextlib.suppress(sqlalchemy.exc.NoResultFound):
database.categories.delete(m.id)
@fixture(scope="function")
def random_recipe(database: AllRepositories, unique_user: TestUser) -> Recipe:
def random_recipe(database: AllRepositories, unique_user: TestUser) -> Generator[Recipe, None, None]:
recipe = Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
@ -95,7 +94,5 @@ def random_recipe(database: AllRepositories, unique_user: TestUser) -> Recipe:
yield model
try:
with contextlib.suppress(sqlalchemy.exc.NoResultFound):
database.recipes.delete(model.slug)
except sqlalchemy.exc.NoResultFound:
pass

View File

@ -12,7 +12,6 @@ from tests.utils.fixture_schemas import TestUser
def test_recipe_assets_create(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
recipe = recipe_ingredient_only
payload = {
"slug": recipe.slug,
"name": random_string(10),
"icon": random_string(10),
"extension": "jpg",
@ -43,6 +42,51 @@ def test_recipe_assets_create(api_client: TestClient, unique_user: TestUser, rec
assert recipe_respons["assets"][0]["name"] == payload["name"]
def test_recipe_asset_exploit(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
"""
Test to ensure that users are unable to circumvent the destination directory when uploading a file
as an asset to the recipe. This was reported via huntr and was confirmed to be a sevre security issue.
mitigration is implemented by ensuring that the destination file is checked to ensure that the parent directory
is the recipe's asset directory. otherwise an exception is raised and a 400 error is returned.
Report Details:
-------------------
Arbitrary template creation leading to Authenticated Remote Code Execution in hay-kot/mealie
An attacker who is able to execute such a flaw is able to execute commands with the privileges
of the programming language or the web server. In this case, since the attacker is root in a
Docker container they can execute system commands, read/modify databases, attack adjacent
systems. This flaw leads to a complete compromise of the system.
https://huntr.dev/bounties/3ecd4a78-523e-4f84-a3fd-31a01a68f142/
"""
recipe = recipe_ingredient_only
payload = {
"name": "$",
"icon": random_string(10),
"extension": "./test.txt",
}
file_payload = {
"file": data.images_test_image_1.read_bytes(),
}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 400
# Ensure File was not created
assert not (recipe.asset_dir.parent / "test.txt").exists()
assert not (recipe.asset_dir / "test.txt").exists()
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()}