1
0
Fork 0
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:
Michael Genson 2025-07-19 20:45:33 -05:00 committed by GitHub
parent 6274a3dd39
commit 7d1c5ad14b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 16 deletions

View file

@ -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,
} }

View file

@ -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