From 7406a98c27afbcd9983874741ae3f6103b7a5e80 Mon Sep 17 00:00:00 2001 From: Don Beckham Date: Sun, 26 Jul 2026 14:12:54 -0500 Subject: [PATCH] Move to Starlette 1.3.1 and pin it explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starlette is not listed in requirements.txt, so the version that actually ships is whatever FastAPI's range resolves to — 0.41.3 in the current image. Seven advisories apply to it, and two are reachable from unauthenticated requests: - CVE-2025-62727: quadratic-time Range-header merging in FileResponse. Every share download is served by FileResponse, so anyone holding a link can drive it. - CVE-2026-54283: form-body limits silently ignored for application/x-www-form-urlencoded. FastAPI parses the body before the handler runs, so the form endpoints are reachable before their own authorization checks are reached. The rest (Host-header handling that can poison request.url, arbitrary methods dispatched to HTTPEndpoint attributes, UNC paths in StaticFiles on Windows) are lower risk here but land in the same upgrade. Clearing all of them requires 1.3.1, which needs FastAPI 0.140.0 to allow the 1.x range. Starlette 1.0 removed the deprecated TemplateResponse(name, context) signature, so the seven call sites move to the current TemplateResponse(request, name, context) form. Rather than repeat that at every site, they now go through a render() helper that merges the shared base context, plus an error_page() wrapper for the three error renders. Context no longer carries "request" explicitly; Starlette injects it. Added a regression test asserting that a request arriving with an unexpected Host still lands on the canonical origin, since Host parsing is one of the things that changed underneath. Verified with the backend suite (26 tests) and the browser end-to-end suite against a staging container built from this branch, plus the same live Authentik authorization-request check as the previous dependency update. --- CHANGELOG.md | 11 ++++++++++ app/main.py | 41 +++++++++++++++++------------------- requirements.txt | 5 ++++- tests/backend/test_public.py | 10 +++++++++ 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f850faf..018922f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ pre-1.0 scheme; dates are when the change reached `main`. path), python-multipart 0.0.20 → 0.0.32 (denial-of-service and parameter smuggling in multipart parsing, reachable from the public upload form), and Jinja2 3.1.5 → 3.1.6 (CVE-2025-27516). +- Moved to Starlette 1.3.1 (FastAPI 0.140.0), clearing seven advisories that + applied to the previously resolved 0.41.3 — most notably CVE-2025-62727, a + quadratic-time denial of service reachable through the `Range` header on any + share download, and CVE-2026-54283, unenforced form-body limits on + URL-encoded posts. Starlette is now pinned explicitly rather than left to + dependency resolution. + +### Changed +- Template rendering goes through a small `render()`/`error_page()` helper, + which also moves the app onto Starlette's current `TemplateResponse` + signature. ## 2026-06-23 diff --git a/app/main.py b/app/main.py index 2b1b97c..4354959 100644 --- a/app/main.py +++ b/app/main.py @@ -94,7 +94,6 @@ def expiry_from_hours(hours: int) -> "object | None": def base_context(request: Request) -> dict: user = get_current_user(request) return { - "request": request, "settings": settings, "user": user, "expiry_options": settings.expiry_options, @@ -102,6 +101,17 @@ def base_context(request: Request) -> dict: } +def render(request: Request, template: str, extra: dict | None = None, status_code: int = 200): + """Render a template with the shared base context merged in.""" + return templates.TemplateResponse( + request, template, {**base_context(request), **(extra or {})}, status_code=status_code, + ) + + +def error_page(request: Request, code: int, message: str): + return render(request, "error.html", {"code": code, "message": message}, status_code=code) + + def record_event(db: Session, request: Request, file: FileModel, *, anonymous: bool, user=None, client_fp: str | None = None, fp_data=None) -> UploadEvent: info = fingerprint.collect(request, client_fp, fp_data) @@ -136,7 +146,7 @@ def landing(request: Request): user = get_current_user(request) if user: return RedirectResponse(url="/app", status_code=302) - return templates.TemplateResponse("landing.html", base_context(request)) + return render(request, "landing.html") @app.post("/api/anon-upload") @@ -211,11 +221,7 @@ async def auth_callback(request: Request): token = await oauth.authentik.authorize_access_token(request) except Exception as exc: # noqa: BLE001 - surface a friendly error page log.warning("OIDC callback failed: %s", exc) - return templates.TemplateResponse( - "error.html", - {**base_context(request), "code": 400, "message": "Sign-in failed. Please try again."}, - status_code=400, - ) + return error_page(request, 400, "Sign-in failed. Please try again.") userinfo = token.get("userinfo") if not userinfo: userinfo = await oauth.authentik.userinfo(token=token) @@ -247,17 +253,14 @@ def app_home(request: Request, db: Session = Depends(get_db)): if not user: return RedirectResponse(url="/login", status_code=302) if not user.in_required_group: - return templates.TemplateResponse( - "not_authorized.html", base_context(request), status_code=403, - ) + return render(request, "not_authorized.html", status_code=403) files = db.execute( select(FileModel) .where(FileModel.owner_sub == user.sub, FileModel.deleted.is_(False)) .order_by(FileModel.created_at.desc()) ).scalars().all() rows = [_file_row(f) for f in files] - ctx = {**base_context(request), "files": rows, "usage": humanize_size(storage.disk_usage_bytes())} - return templates.TemplateResponse("app.html", ctx) + return render(request, "app.html", {"files": rows, "usage": humanize_size(storage.disk_usage_bytes())}) @app.post("/api/files") @@ -362,23 +365,17 @@ def email_share(token: str, request: Request, to: str = Form(...), db: Session = def share_page(token: str, request: Request, db: Session = Depends(get_db)): link = db.get(ShareLink, token) if not link or link.file.deleted: - return templates.TemplateResponse( - "error.html", {**base_context(request), "code": 404, "message": "This link doesn't exist."}, - status_code=404) + return error_page(request, 404, "This link doesn't exist.") if link.is_expired: - return templates.TemplateResponse( - "error.html", {**base_context(request), "code": 410, "message": "This link has expired."}, - status_code=410) - ctx = { - **base_context(request), + return error_page(request, 410, "This link has expired.") + return render(request, "share.html", { "file": link.file, "size_h": humanize_size(link.file.size_bytes), "token": token, "download_url": f"{settings.base_url.rstrip('/')}/d/{token}", "this_url": share_url(token), "expires_at": link.expires_at, - } - return templates.TemplateResponse("share.html", ctx) + }) @app.get("/d/{token}") diff --git a/requirements.txt b/requirements.txt index 9546c53..66d7460 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,8 @@ # Beckham Share — Python dependencies (pinned for reproducible builds) -fastapi==0.115.6 +fastapi==0.140.0 +# FastAPI's ASGI core. Pinned explicitly rather than left to resolution because +# it terminates every request and has its own advisory stream. +starlette==1.3.1 uvicorn[standard]==0.34.0 sqlalchemy==2.0.36 psycopg2-binary==2.9.10 diff --git a/tests/backend/test_public.py b/tests/backend/test_public.py index f5f497e..ee60714 100644 --- a/tests/backend/test_public.py +++ b/tests/backend/test_public.py @@ -20,3 +20,13 @@ def test_landing_redirects_logged_in_member(client, member): r = client.get("/", follow_redirects=False) assert r.status_code == 302 assert r.headers["location"] == "/app" + + +def test_unrecognized_host_lands_on_the_canonical_origin(client): + # The app answers on several hostnames, but the authenticated flow is pinned + # to BASE_URL so the session cookie and the OIDC redirect URI stay on one + # origin. Whatever Host arrives, the redirect target is rebuilt from the + # canonical host rather than echoed back. + r = client.get("/app", headers={"host": "elsewhere.example.com"}, follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"].startswith("https://share.beckham.ai/app")