From 11ba46505b8dc1965fa881effddb6c00add1c704 Mon Sep 17 00:00:00 2001 From: Vykos Date: Fri, 5 Jun 2026 10:33:47 +0200 Subject: [PATCH] Constrain generated-image paths to image root (#2837) --- app.py | 11 +--- src/generated_images.py | 30 ++++++++++ tests/test_generated_image_confinement.py | 72 +++++++++++++++++++++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 src/generated_images.py create mode 100644 tests/test_generated_image_confinement.py diff --git a/app.py b/app.py index b34b818..4f3dff0 100644 --- a/app.py +++ b/app.py @@ -64,6 +64,7 @@ from core.exceptions import ( import bcrypt as _bcrypt from src.app_helpers import abs_join +from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path from starlette.responses import RedirectResponse # ========= LOGGING ========= @@ -387,13 +388,7 @@ app.mount("/static", _RevalidatingStatic(directory="static"), name="static") @app.get("/api/generated-image/{filename}") async def serve_generated_image(filename: str, request: Request): """Serve generated images from the data directory.""" - from pathlib import Path - import re - if not re.match(r'^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$', filename): - raise HTTPException(status_code=400, detail="Invalid filename") - img_path = Path("data/generated_images") / filename - if not img_path.exists(): - raise HTTPException(status_code=404, detail="Image not found") + img_path = resolve_generated_image_path(filename) # SECURITY: filename is the only key, so anyone who knows / guesses a # 12-hex content hash could pull another user's image bytes. Require # auth and verify ownership via the gallery row (when one exists). @@ -429,7 +424,7 @@ async def serve_generated_image(filename: str, request: Request): return FileResponse( str(img_path), media_type=mime, - headers={"Cache-Control": "public, max-age=31536000, immutable"}, + headers=GENERATED_IMAGE_HEADERS, ) # ========= YOUTUBE INIT ========= diff --git a/src/generated_images.py b/src/generated_images.py new file mode 100644 index 0000000..2e79941 --- /dev/null +++ b/src/generated_images.py @@ -0,0 +1,30 @@ +import os +import re +from pathlib import Path + +from fastapi import HTTPException + + +GENERATED_IMAGE_DIR = Path("data/generated_images") +GENERATED_IMAGE_RE = re.compile( + r"^[a-f0-9]{8,64}\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv|m4v)$" +) +GENERATED_IMAGE_HEADERS = { + "Cache-Control": "public, max-age=31536000, immutable", + "X-Content-Type-Options": "nosniff", +} + + +def resolve_generated_image_path(filename: str) -> Path: + if not isinstance(filename, str) or not GENERATED_IMAGE_RE.fullmatch(filename): + raise HTTPException(status_code=400, detail="Invalid filename") + root = GENERATED_IMAGE_DIR.resolve() + path = (GENERATED_IMAGE_DIR / filename).resolve() + try: + if os.path.commonpath([str(root), str(path)]) != str(root): + raise ValueError + except Exception: + raise HTTPException(status_code=400, detail="Invalid filename") + if not path.exists(): + raise HTTPException(status_code=404, detail="Image not found") + return path diff --git a/tests/test_generated_image_confinement.py b/tests/test_generated_image_confinement.py new file mode 100644 index 0000000..5628706 --- /dev/null +++ b/tests/test_generated_image_confinement.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path + +import pytest +from fastapi import HTTPException + + +def _generated_images_module(): + from src import generated_images + return generated_images + + +def test_generated_image_path_allows_safe_existing_file(tmp_path, monkeypatch): + generated_images = _generated_images_module() + image_dir = tmp_path / "generated_images" + image_dir.mkdir() + filename = "a" * 12 + ".png" + image_path = image_dir / filename + image_path.write_bytes(b"png") + monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir) + + assert generated_images.resolve_generated_image_path(filename) == image_path + + +@pytest.mark.parametrize("filename", ["../../secret.png", "zzzzzzzz.png", "aaaaaaa.png", None, 12345]) +def test_generated_image_path_rejects_invalid_filenames(tmp_path, monkeypatch, filename): + generated_images = _generated_images_module() + image_dir = tmp_path / "generated_images" + image_dir.mkdir() + monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir) + + with pytest.raises(HTTPException) as exc: + generated_images.resolve_generated_image_path(filename) + + assert exc.value.status_code == 400 + + +def test_generated_image_path_rejects_symlink_escape(tmp_path, monkeypatch): + generated_images = _generated_images_module() + image_dir = tmp_path / "generated_images" + image_dir.mkdir() + filename = "b" * 12 + ".png" + outside = tmp_path / "outside.png" + outside.write_bytes(b"outside image root") + try: + os.symlink(outside, image_dir / filename) + except (AttributeError, NotImplementedError, OSError) as exc: + pytest.skip(f"symlinks unavailable: {exc}") + monkeypatch.setattr(generated_images, "GENERATED_IMAGE_DIR", image_dir) + + with pytest.raises(HTTPException) as exc: + generated_images.resolve_generated_image_path(filename) + + assert exc.value.status_code == 400 + + +def test_generated_image_headers_include_nosniff(): + generated_images = _generated_images_module() + + assert generated_images.GENERATED_IMAGE_HEADERS["X-Content-Type-Options"] == "nosniff" + assert ( + generated_images.GENERATED_IMAGE_HEADERS["Cache-Control"] + == "public, max-age=31536000, immutable" + ) + + +def test_generated_image_route_uses_confining_resolver(): + source = Path("app.py").read_text(encoding="utf-8") + + assert 'Path("data/generated_images") / filename' not in source + assert "resolve_generated_image_path(filename)" in source + assert "headers=GENERATED_IMAGE_HEADERS" in source