mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
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:
parent
483f789b8e
commit
13850cda1f
@ -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...
|
126
docs/docs/changelog/v1.0.0beta-4.md
Normal file
126
docs/docs/changelog/v1.0.0beta-4.md
Normal 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 -->
|
@ -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
|
||||
|
@ -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
@ -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"
|
||||
|
@ -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[],
|
||||
|
@ -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: {
|
||||
|
@ -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[],
|
||||
|
@ -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: {
|
||||
|
@ -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,
|
||||
|
42
frontend/components/global/SafeMarkdown.vue
Normal file
42
frontend/components/global/SafeMarkdown.vue
Normal 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>
|
@ -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);
|
||||
|
@ -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() {
|
||||
|
2
frontend/types/components.d.ts
vendored
2
frontend/types/components.d.ts
vendored
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
7
poetry.lock
generated
@ -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"},
|
||||
|
@ -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"
|
||||
|
29
tests/fixtures/fixture_recipe.py
vendored
29
tests/fixtures/fixture_recipe.py
vendored
@ -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
|
||||
|
@ -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()}
|
||||
|
Loading…
x
Reference in New Issue
Block a user