fix(web): gate /docs, /redoc, /openapi.json behind login

FastAPI auto-registers these with no auth of its own -- every other data
route is individually wrapped in Depends(require_user), but these three
are wired up internally by the FastAPI() constructor and were never
covered by that. Confirmed live: all three returned 200 with zero session,
handing anyone who could reach the port a full map of every route --
VM remove, cache purge, template delete, service control included --
without logging in.

Disabled FastAPI's built-in docs_url/redoc_url/openapi_url and re-added all
three as ordinary routes behind the same require_user dependency every
other route uses. Verified against the live deployment: all three now 303
to /login instead of 200.
This commit is contained in:
2026-07-25 20:04:28 +02:00
parent a3dbf6ad45
commit 8a34e663c7
4 changed files with 75 additions and 3 deletions
+44 -2
View File
@@ -32,9 +32,10 @@ from __future__ import annotations
import secrets
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from fastapi import Depends, FastAPI, Request
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -391,6 +392,29 @@ def healthz() -> dict[str, str]:
return {"status": "ok"}
def openapi_json(request: Request) -> dict[str, Any]:
"""``GET /openapi.json`` -- the raw schema, gated behind ``require_user``.
FastAPI's own ``openapi_url`` is disabled in :func:`create_app`
(it ships with no auth of its own -- every mutating route's path,
parameters and docstring would otherwise be readable by anyone who can
reach the port, logged in or not). This route re-adds it behind the same
dependency every other data route uses.
"""
app: FastAPI = request.app
return app.openapi()
def swagger_docs() -> HTMLResponse:
"""``GET /docs`` -- Swagger UI, gated the same way as :func:`openapi_json`."""
return get_swagger_ui_html(openapi_url="/openapi.json", title="ci-orchestrator web - Swagger UI")
def redoc_docs() -> HTMLResponse:
"""``GET /redoc`` -- ReDoc, gated the same way as :func:`openapi_json`."""
return get_redoc_html(openapi_url="/openapi.json", title="ci-orchestrator web - ReDoc")
async def dashboard(
request: Request, backend: VmBackend | None = Depends(get_backend)
) -> HTMLResponse:
@@ -568,7 +592,10 @@ def create_app(config: Config) -> FastAPI:
register_action_routes,
)
app = FastAPI(title="ci-orchestrator web")
# docs_url/redoc_url/openapi_url are FastAPI's own auto-registered routes
# with no auth of their own -- disabled here and re-added below behind
# require_user, same as every other data route.
app = FastAPI(title="ci-orchestrator web", docs_url=None, redoc_url=None, openapi_url=None)
app.state.config = config
app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates"))
filters.register(app.state.templates)
@@ -597,6 +624,21 @@ def create_app(config: Config) -> FastAPI:
app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static")
app.get("/healthz")(healthz)
app.get("/openapi.json", dependencies=[Depends(require_user)], include_in_schema=False)(
openapi_json
)
app.get(
"/docs",
response_class=HTMLResponse,
dependencies=[Depends(require_user)],
include_in_schema=False,
)(swagger_docs)
app.get(
"/redoc",
response_class=HTMLResponse,
dependencies=[Depends(require_user)],
include_in_schema=False,
)(redoc_docs)
app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard)
app.get(
"/executions", response_class=HTMLResponse, dependencies=[Depends(require_user)]