From 7d1c5ad14b90b8c709a5f56537aa98e5da4bbfdd Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Sat, 19 Jul 2025 20:45:33 -0500 Subject: [PATCH] fix: Patch XSS Vulnerability (#5754) --- mealie/routes/spa/__init__.py | 49 ++++++++++++++++++++--------- tests/integration_tests/test_spa.py | 28 ++++++++++++++++- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/mealie/routes/spa/__init__.py b/mealie/routes/spa/__init__.py index 824764b6e..6e4e24180 100644 --- a/mealie/routes/spa/__init__.py +++ b/mealie/routes/spa/__init__.py @@ -1,6 +1,8 @@ +import html import json import pathlib from dataclasses import dataclass +from typing import Any from bs4 import BeautifulSoup from fastapi import Depends, FastAPI, Response @@ -24,6 +26,9 @@ class MetaTag: property_name: str content: str + def __post_init__(self): + self.content = escape(self.content) # escape HTML to prevent XSS attacks + class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): @@ -42,6 +47,17 @@ __app_settings = get_app_settings() __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: soup = BeautifulSoup(contents, "lxml") scraped_meta_tags = soup.find_all("meta") @@ -80,15 +96,13 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: # Inject meta tags recipe_url = f"{__app_settings.BASE_URL}/g/{group_slug}/r/{recipe.slug}" if recipe.image: - image_url = ( - f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={recipe.image}" - ) + image_url = f"{__app_settings.BASE_URL}/api/media/recipes/{recipe.id}/images/original.webp?version={escape(recipe.image)}" else: image_url = "https://raw.githubusercontent.com/mealie-recipes/mealie/9571816ac4eed5beacfc0abf6c03eff1427fd0eb/frontend/static/icons/android-chrome-512x512.png" ingredients: list[str] = [] 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: for ing in recipe.recipe_ingredient: @@ -102,25 +116,30 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: if 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 {} + 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", "@type": "Recipe", - "name": recipe.name, - "description": recipe.description, + "name": escape(recipe.name), + "description": escape(recipe.description), "image": [image_url], "datePublished": recipe.created_at, - "prepTime": recipe.prep_time, - "cookTime": recipe.cook_time, - "totalTime": recipe.total_time, - "recipeYield": recipe.recipe_yield_display, + "prepTime": escape(recipe.prep_time), + "cookTime": escape(recipe.cook_time), + "totalTime": escape(recipe.total_time), + "recipeYield": escape(recipe.recipe_yield_display), "recipeIngredient": ingredients, - "recipeInstructions": [i.text for i in recipe.recipe_instructions] if recipe.recipe_instructions else [], - "recipeCategory": [c.name for c in recipe.recipe_category] if recipe.recipe_category else [], - "keywords": [t.name for t in recipe.tags] if recipe.tags else [], + "recipeInstructions": [escape(i.text) for i in recipe.recipe_instructions] + if recipe.recipe_instructions + 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, } diff --git a/tests/integration_tests/test_spa.py b/tests/integration_tests/test_spa.py index 68373f79a..9238d4200 100644 --- a/tests/integration_tests/test_spa.py +++ b/tests/integration_tests/test_spa.py @@ -2,7 +2,8 @@ import pytest from bs4 import BeautifulSoup 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 tests import data as test_data 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) assert response.status_code == 404 + + +@pytest.mark.parametrize( + "malicious_content, malicious_strings", + [ + ("", ["