Constrain generated-image paths to image root (#2837)
This commit is contained in:
11
app.py
11
app.py
@@ -64,6 +64,7 @@ from core.exceptions import (
|
|||||||
import bcrypt as _bcrypt
|
import bcrypt as _bcrypt
|
||||||
|
|
||||||
from src.app_helpers import abs_join
|
from src.app_helpers import abs_join
|
||||||
|
from src.generated_images import GENERATED_IMAGE_HEADERS, resolve_generated_image_path
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
# ========= LOGGING =========
|
# ========= LOGGING =========
|
||||||
@@ -387,13 +388,7 @@ app.mount("/static", _RevalidatingStatic(directory="static"), name="static")
|
|||||||
@app.get("/api/generated-image/{filename}")
|
@app.get("/api/generated-image/{filename}")
|
||||||
async def serve_generated_image(filename: str, request: Request):
|
async def serve_generated_image(filename: str, request: Request):
|
||||||
"""Serve generated images from the data directory."""
|
"""Serve generated images from the data directory."""
|
||||||
from pathlib import Path
|
img_path = resolve_generated_image_path(filename)
|
||||||
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")
|
|
||||||
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
# SECURITY: filename is the only key, so anyone who knows / guesses a
|
||||||
# 12-hex content hash could pull another user's image bytes. Require
|
# 12-hex content hash could pull another user's image bytes. Require
|
||||||
# auth and verify ownership via the gallery row (when one exists).
|
# 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(
|
return FileResponse(
|
||||||
str(img_path),
|
str(img_path),
|
||||||
media_type=mime,
|
media_type=mime,
|
||||||
headers={"Cache-Control": "public, max-age=31536000, immutable"},
|
headers=GENERATED_IMAGE_HEADERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========= YOUTUBE INIT =========
|
# ========= YOUTUBE INIT =========
|
||||||
|
|||||||
30
src/generated_images.py
Normal file
30
src/generated_images.py
Normal 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
|
||||||
72
tests/test_generated_image_confinement.py
Normal file
72
tests/test_generated_image_confinement.py
Normal 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
|
||||||
Reference in New Issue
Block a user