diff --git a/mealie/routes/media/media_recipe.py b/mealie/routes/media/media_recipe.py index b2224eab9..428fac95c 100644 --- a/mealie/routes/media/media_recipe.py +++ b/mealie/routes/media/media_recipe.py @@ -58,6 +58,6 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str): raise HTTPException(status.HTTP_400_BAD_REQUEST) if file.exists(): - return FileResponse(file) + return FileResponse(file, filename=file.name, content_disposition_type="attachment") else: raise HTTPException(status.HTTP_404_NOT_FOUND) diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py index 927352efb..ca8da8865 100644 --- a/mealie/routes/recipe/recipe_crud_routes.py +++ b/mealie/routes/recipe/recipe_crud_routes.py @@ -80,6 +80,8 @@ from mealie.services.scraper.scraper_strategies import ( from ._base import BaseRecipeController, JSONBytes +ASSET_ALLOWED_EXTENSIONS = {"pdf", "jpg", "jpeg", "png", "gif", "webp", "bmp", "avif", "txt", "md", "csv", "json"} + router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute) @@ -660,6 +662,10 @@ class RecipeController(BaseRecipeController): if "." in extension: extension = extension.split(".")[-1] + extension = extension.lower() + if extension not in ASSET_ALLOWED_EXTENSIONS: + raise HTTPException(status_code=400, detail="Unsupported file extension") + file_slug = slugify(name) if not extension or not file_slug: raise HTTPException(status_code=400, detail="Missing required fields") diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py b/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py index 22c2e03f6..599edc624 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_image_assets.py @@ -87,6 +87,23 @@ def test_recipe_asset_exploit(api_client: TestClient, unique_user: TestUser, rec assert not (recipe.asset_dir / "test.txt").exists() +def test_recipe_asset_dangerous_extension_blocked( + api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe +): + """Ensure scriptable extensions are rejected to prevent stored XSS (GHSA-gfwc-pjx4-mg9p).""" + recipe = recipe_ingredient_only + for ext in ("html", "svg", "js", "htm", "xhtml"): + payload = {"name": random_string(10), "icon": "mdi-file", "extension": ext} + file_payload = {"file": b""} + response = api_client.post( + f"/api/recipes/{recipe.slug}/assets", + data=payload, + files=file_payload, + headers=unique_user.token, + ) + assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}" + + 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()}