mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-05 13:35:23 +02:00
fix: Patch XSS Vulnerability (#5754)
This commit is contained in:
parent
6274a3dd39
commit
7d1c5ad14b
2 changed files with 61 additions and 16 deletions
|
@ -1,6 +1,8 @@
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from fastapi import Depends, FastAPI, Response
|
from fastapi import Depends, FastAPI, Response
|
||||||
|
@ -24,6 +26,9 @@ class MetaTag:
|
||||||
property_name: str
|
property_name: str
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
self.content = escape(self.content) # escape HTML to prevent XSS attacks
|
||||||
|
|
||||||
|
|
||||||
class SPAStaticFiles(StaticFiles):
|
class SPAStaticFiles(StaticFiles):
|
||||||
async def get_response(self, path: str, scope):
|
async def get_response(self, path: str, scope):
|
||||||
|
@ -42,6 +47,17 @@ __app_settings = get_app_settings()
|
||||||
__contents = ""
|
__contents = ""
|
||||||
|
|
||||||
|
|
||||||
|
def escape(content: Any) -> Any:
|
||||||
|
if isinstance(content, str):
|
||||||
|
return html.escape(content)
|
||||||
|
elif isinstance(content, list | tuple | set):
|
||||||
|
return [escape(item) for item in content]
|
||||||
|
elif isinstance(content, dict):
|
||||||
|
return {escape(k): escape(v) for k, v in content.items()}
|
||||||
|
else:
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def inject_meta(contents: str, tags: list[MetaTag]) -> str:
|
def inject_meta(contents: str, tags: list[MetaTag]) -> str:
|
||||||
soup = BeautifulSoup(contents, "lxml")
|
soup = BeautifulSoup(contents, "lxml")
|
||||||
scraped_meta_tags = soup.find_all("meta")
|
scraped_meta_tags = soup.find_all("meta")
|
||||||
|
@ -80,15 +96,13 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
|
||||||
# Inject meta tags
|
# Inject meta tags
|
||||||
recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}"
|
recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}"
|
||||||
if recipe.image:
|
if recipe.image:
|
||||||
image_url = (
|
image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={escape(recipe.image)}"
|
||||||
f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
image_url = "https://raw.githubusercontent.com/mealie-recipes/mealie/9571816ac4eed5beacfc0abf6c03eff1427fd0eb/frontend/static/icons/android-chrome-512x512.png"
|
image_url = "https://raw.githubusercontent.com/mealie-recipes/mealie/9571816ac4eed5beacfc0abf6c03eff1427fd0eb/frontend/static/icons/android-chrome-512x512.png"
|
||||||
|
|
||||||
ingredients: list[str] = []
|
ingredients: list[str] = []
|
||||||
if recipe.settings.disable_amount: # type: ignore
|
if recipe.settings.disable_amount: # type: ignore
|
||||||
ingredients = [i.note for i in recipe.recipe_ingredient if i.note]
|
ingredients = [escape(i.note) for i in recipe.recipe_ingredient if i.note]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
for ing in recipe.recipe_ingredient:
|
for ing in recipe.recipe_ingredient:
|
||||||
|
@ -102,25 +116,30 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
|
||||||
if ing.note:
|
if ing.note:
|
||||||
s += f"{ing.note}"
|
s += f"{ing.note}"
|
||||||
|
|
||||||
ingredients.append(s)
|
ingredients.append(escape(s))
|
||||||
|
|
||||||
nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {}
|
nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {}
|
||||||
|
for k, v in nutrition.items():
|
||||||
|
if v:
|
||||||
|
nutrition[k] = escape(v)
|
||||||
|
|
||||||
as_schema_org = {
|
as_schema_org: dict[str, Any] = {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Recipe",
|
"@type": "Recipe",
|
||||||
"name": recipe.name,
|
"name": escape(recipe.name),
|
||||||
"description": recipe.description,
|
"description": escape(recipe.description),
|
||||||
"image": [image_url],
|
"image": [image_url],
|
||||||
"datePublished": recipe.created_at,
|
"datePublished": recipe.created_at,
|
||||||
"prepTime": recipe.prep_time,
|
"prepTime": escape(recipe.prep_time),
|
||||||
"cookTime": recipe.cook_time,
|
"cookTime": escape(recipe.cook_time),
|
||||||
"totalTime": recipe.total_time,
|
"totalTime": escape(recipe.total_time),
|
||||||
"recipeYield": recipe.recipe_yield_display,
|
"recipeYield": escape(recipe.recipe_yield_display),
|
||||||
"recipeIngredient": ingredients,
|
"recipeIngredient": ingredients,
|
||||||
"recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [],
|
"recipeInstructions": [escape(i.text) for i in recipe.recipe_instructions]
|
||||||
"recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [],
|
if recipe.recipe_instructions
|
||||||
"keywords": [t.name for t in recipe.tags] if recipe.tags else [],
|
else [],
|
||||||
|
"recipeCategory": [escape(c.name) for c in recipe.recipe_category] if recipe.recipe_category else [],
|
||||||
|
"keywords": [escape(t.name) for t in recipe.tags] if recipe.tags else [],
|
||||||
"nutrition": nutrition,
|
"nutrition": nutrition,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,8 @@ import pytest
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
from mealie.routes import spa
|
from mealie.routes import spa
|
||||||
from mealie.schema.recipe.recipe import Recipe
|
from mealie.schema.recipe.recipe import Recipe, RecipeSettings
|
||||||
|
from mealie.schema.recipe.recipe_notes import RecipeNote
|
||||||
from mealie.schema.recipe.recipe_share_token import RecipeShareTokenSave
|
from mealie.schema.recipe.recipe_share_token import RecipeShareTokenSave
|
||||||
from tests import data as test_data
|
from tests import data as test_data
|
||||||
from tests.utils.factories import random_string
|
from tests.utils.factories import random_string
|
||||||
|
@ -189,3 +190,28 @@ async def test_spa_service_shared_recipe_with_meta_invalid_data(unique_user: Tes
|
||||||
|
|
||||||
response = await spa.serve_shared_recipe_with_meta(group.slug, random_string(), session=unique_user.repos.session)
|
response = await spa.serve_shared_recipe_with_meta(group.slug, random_string(), session=unique_user.repos.session)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"malicious_content, malicious_strings",
|
||||||
|
[
|
||||||
|
("<script>alert('XSS');</script>", ["<script>", "alert('XSS')"]),
|
||||||
|
("<img src=x onerror=alert('XSS')>", ["<img", "onerror=alert('XSS')"]),
|
||||||
|
("<div onmouseover=alert('XSS')>Hover me</div>", ["<div", "onmouseover=alert('XSS')"]),
|
||||||
|
("<a href='javascript:alert(\"XSS\")'>Click me</a>", ["<a", 'javascript:alert("XSS")']),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_spa_escapes_malicious_recipe_data(unique_user: TestUser, malicious_content: str, malicious_strings: list[str]):
|
||||||
|
recipe = Recipe(
|
||||||
|
user_id=unique_user.user_id,
|
||||||
|
group_id=unique_user.group_id,
|
||||||
|
name=malicious_content,
|
||||||
|
description=malicious_content,
|
||||||
|
image=malicious_content,
|
||||||
|
notes=[RecipeNote(title=malicious_content, text=malicious_content)],
|
||||||
|
settings=RecipeSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = spa.content_with_meta(unique_user.group_id, recipe)
|
||||||
|
for string in malicious_strings:
|
||||||
|
assert string not in response
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue