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",
+ [
+ ("", ["