Constrain generated-image paths to image root (#2837)

This commit is contained in:
Vykos
2026-06-05 10:33:47 +02:00
committed by GitHub
parent d4d168f972
commit 11ba46505b
3 changed files with 105 additions and 8 deletions

11
app.py
View File

@@ -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 =========

30
src/generated_images.py Normal file
View File

@@ -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

View File

@@ -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