diff --git a/pyproject.toml b/pyproject.toml index 721e8bee..a06cd7c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,7 @@ dependencies = [ [project.optional-dependencies] server = [ - # Cap <0.137: fastapi 0.137 drops auth on serve --ui protected endpoints (GHSA-ffpq-prmh-3gx2). - "fastapi>=0.115,<0.137", + "fastapi>=0.115", "uvicorn[standard]>=0.30", "sse-starlette>=2.1", "python-multipart>=0.0.31", diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index ba43dcb4..f77c06f6 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -46,6 +46,13 @@ "`/version`, `/changelog`, `/agents` become reachable unauthenticated, reopening " "GHSA-ffpq-prmh-3gx2 (fixed in an earlier release). Held until the `serve --ui` " "auth check is updated for the newer fastapi.", + "Security: `serve --ui` now decides which paths need auth by asking the router's " + "match protocol whether a GET resolves to a real endpoint, instead of scanning " + "`app.routes` as a flat list -- and it fails closed (any error -> path treated as " + "protected). fastapi 0.137 nests included routers into a lazy tree, so the old flat " + "scan missed nested endpoints and served `/doctor`, `/version`, `/changelog`, " + "`/agents` unauthenticated (GHSA-ffpq-prmh-3gx2). With the predicate fixed, the " + "temporary `fastapi<0.137` cap above is lifted -- fastapi is back on the latest release.", ], "0.63.3": [ "Fix: `kbagent context` no longer renders API path templates as " diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index eba968cb..f1d3e53d 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -723,38 +723,40 @@ def _allow_static_through_auth(app: FastAPI) -> None: favicons, and the SPA's client-side routes (which resolve to index.html via the StaticFiles ``html=True`` fallback) to load without a token. - Route-aware (GHSA-ffpq-prmh-3gx2): instead of a hand-maintained prefix - deny-list -- which silently went stale and let ``GET /doctor`` / ``/stream`` - / ``/version`` / ``/changelog`` bypass auth in ``--ui`` mode -- we derive the - protected set from the app's *actually-registered* routes. All routers are - included before this runs (and before the StaticFiles mount is appended), so - any GET that resolves to a real endpoint must authenticate, and only genuine - client-side SPA routes fall through to the public index.html shell. New API - routes are protected automatically, with no list to keep in sync. - - Implemented by stashing a predicate on ``app.state`` that the auth - middleware consults; we don't import-cycle by editing the auth module here. + Route-aware (GHSA-ffpq-prmh-3gx2): a real endpoint must authenticate; only + genuine client-side SPA routes fall through to the public index.html shell. + We ask the router whether a GET resolves to a registered route via the + routing match protocol, NOT a flat scan of ``app.routes``: fastapi >=0.137 + nests included routers into a lazy tree (``_IncludedRouter``), so a flat scan + misses nested endpoints and would serve them unauthenticated. ``matches()`` + is the same resolution a real request uses, so it cannot miss a live + endpoint. Fails CLOSED -- any error treats the path as protected, never + silently public. + + Stashed on ``app.state`` for the auth middleware to consult (avoids an + import cycle with the auth module). """ - from starlette.routing import Route + from starlette.routing import Match - # Snapshot the real endpoints once. The StaticFiles mount (added by the - # caller AFTER this function) is a ``Mount``, not a ``Route``, so it is - # correctly excluded -- otherwise it would match every path and break the - # SPA fallback. - api_route_patterns = [r.path_regex for r in app.routes if isinstance(r, Route)] static_paths = frozenset({"/", "/index.html", "/favicon.svg", "/favicon.ico", "/manifest.json"}) def _is_ui_public(method: str, path: str) -> bool: if method != "GET": return False - # SPA shell + built assets are always public (they carry no secrets) so - # the browser can bootstrap and pick up the session cookie. + # SPA shell + built assets are always public so the browser can bootstrap. if path in static_paths or path.startswith("/assets/"): return True - # A path that resolves to a registered route is a real endpoint and - # MUST go through auth. Anything else is a client-side SPA route served - # by the public StaticFiles shell, so skipping auth there leaks nothing. - return not any(pattern.match(path) for pattern in api_route_patterns) + try: + scope = {"type": "http", "method": "GET", "path": path, "headers": []} + for route in app.router.routes: + if getattr(route, "name", None) == "ui": # the SPA StaticFiles catch-all + continue + match, _ = route.matches(scope) + if match is not Match.NONE: + return False # resolves to a real endpoint -> require auth + return True # no endpoint matched -> genuine SPA client route + except Exception: + return False # fail closed app.state.is_ui_public = _is_ui_public diff --git a/uv.lock b/uv.lock index 2396d826..afd1a133 100644 --- a/uv.lock +++ b/uv.lock @@ -423,7 +423,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.136.3" +version = "0.137.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -432,9 +432,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/29/cc5819dc24d3daa80cdaa1aec023bf8652a70dd7fd1c96b0b225c99a7690/fastapi-0.137.2.tar.gz", hash = "sha256:b9d893bebc97dcfbdcb1917e88a292d062844ea19445a5fa4f7eb28c4baea9e3", size = 410332, upload-time = "2026-06-18T06:58:24.434Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/0c6b644e99fb5697d8bdcd36cdb47c52e77a63fc7a1514b1f03a6ecab955/fastapi-0.137.2-py3-none-any.whl", hash = "sha256:791d36261e916a98b25ac85ee591bc3db159394070f6d3d096d94fb378f60ce2", size = 122252, upload-time = "2026-06-18T06:58:26.074Z" }, ] [[package]] @@ -632,7 +632,7 @@ dev = [ requires-dist = [ { name = "croniter", specifier = ">=2.0" }, { name = "cryptography", specifier = ">=48.0.1" }, - { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115,<0.137" }, + { name = "fastapi", marker = "extra == 'server'", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.27" }, { name = "jsonschema", specifier = ">=4.20" }, { name = "kai-client", specifier = ">=0.11.0" },