fix: harden media path handling against path injection (CodeQL py/path-injection)

Resolve media filenames through a shared _resolve_media_path() barrier that
confirms the resolved path sits directly under the resolved media dir, so
CodeQL can trace the sanitization the regex already guaranteed.

ha-relevant: no
This commit is contained in:
Pouzor
2026-07-07 11:50:12 +02:00
parent 738595be5a
commit 2d376c2bed
2 changed files with 26 additions and 6 deletions
+19 -6
View File
@@ -11,6 +11,7 @@ raw-image uploads reuse the same endpoint.
import re import re
import uuid import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
@@ -40,6 +41,22 @@ MAX_BYTES = 10 * 1024 * 1024 # 10 MB
_NAME_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$") _NAME_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$")
def _resolve_media_path(filename: str) -> Path:
"""Resolve `filename` inside the media dir, rejecting anything that escapes it.
`filename` is validated against `_NAME_RE` (no separators, no `..`) and the
resolved path is confirmed to sit directly under the resolved media dir.
Raises 404 on any mismatch so we never leak whether a path exists elsewhere.
"""
if not _NAME_RE.match(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
base = settings.media_dir().resolve()
path = (base / filename).resolve()
if path.parent != base:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return path
@router.post("/upload") @router.post("/upload")
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]: async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
ext = ALLOWED_TYPES.get(file.content_type or "") ext = ALLOWED_TYPES.get(file.content_type or "")
@@ -72,9 +89,7 @@ async def upload_media(file: UploadFile, _user: str = Depends(get_current_user))
@router.get("/{filename}") @router.get("/{filename}")
async def get_media(filename: str) -> FileResponse: async def get_media(filename: str) -> FileResponse:
if not _NAME_RE.match(filename): path = _resolve_media_path(filename)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if not path.is_file(): if not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return FileResponse(path) return FileResponse(path)
@@ -82,8 +97,6 @@ async def get_media(filename: str) -> FileResponse:
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None: async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
if not _NAME_RE.match(filename): path = _resolve_media_path(filename)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if path.is_file(): if path.is_file():
path.unlink() path.unlink()
+7
View File
@@ -79,6 +79,13 @@ async def test_get_rejects_bad_filename(client, media_dir):
assert res.status_code == 404 assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_rejects_bad_filename(client, auth_headers, media_dir):
headers = await auth_headers()
res = await client.delete("/api/v1/media/..%2f..%2fetc%2fpasswd", headers=headers)
assert res.status_code == 404
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_delete_requires_auth_and_removes_file(client, auth_headers, media_dir): async def test_delete_requires_auth_and_removes_file(client, auth_headers, media_dir):
headers = await auth_headers() headers = await auth_headers()