mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-05-15 06:17:29 -04:00
fix: block scriptable asset extensions and force Content-Disposition: attachment (GHSA-gfwc-pjx4-mg9p) (#7626)
This commit is contained in:
@@ -58,6 +58,6 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
|||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if file.exists():
|
if file.exists():
|
||||||
return FileResponse(file)
|
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ from mealie.services.scraper.scraper_strategies import (
|
|||||||
|
|
||||||
from ._base import BaseRecipeController, JSONBytes
|
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)
|
router = UserAPIRouter(prefix="/recipes", route_class=MealieCrudRoute)
|
||||||
|
|
||||||
|
|
||||||
@@ -660,6 +662,10 @@ class RecipeController(BaseRecipeController):
|
|||||||
if "." in extension:
|
if "." in extension:
|
||||||
extension = extension.split(".")[-1]
|
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)
|
file_slug = slugify(name)
|
||||||
if not extension or not file_slug:
|
if not extension or not file_slug:
|
||||||
raise HTTPException(status_code=400, detail="Missing required fields")
|
raise HTTPException(status_code=400, detail="Missing required fields")
|
||||||
|
|||||||
@@ -87,6 +87,23 @@ def test_recipe_asset_exploit(api_client: TestClient, unique_user: TestUser, rec
|
|||||||
assert not (recipe.asset_dir / "test.txt").exists()
|
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"<script>alert(1)</script>"}
|
||||||
|
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):
|
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
|
||||||
data_payload = {"extension": "jpg"}
|
data_payload = {"extension": "jpg"}
|
||||||
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
||||||
|
|||||||
Reference in New Issue
Block a user