diff --git a/tests/test_release_console_security.py b/tests/test_release_console_security.py new file mode 100644 index 0000000..451e61b --- /dev/null +++ b/tests/test_release_console_security.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +from fastapi.testclient import TestClient + + +META_ROOT = Path(__file__).resolve().parents[1] +RELEASE_ROOT = META_ROOT / "tools" / "release" +if str(RELEASE_ROOT) not in sys.path: + sys.path.insert(0, str(RELEASE_ROOT)) + +from server.app import create_app # noqa: E402 + + +class ReleaseConsoleSecurityTests(unittest.TestCase): + def test_api_token_is_accepted_only_from_header(self) -> None: + with TestClient(create_app(workspace_root=META_ROOT, token="test-console-token")) as client: + query_response = client.get("/api/health?token=test-console-token") + header_response = client.get( + "/api/health", + headers={"X-Release-Console-Token": "test-console-token"}, + ) + + self.assertEqual(query_response.status_code, 401) + self.assertEqual(header_response.status_code, 200) + + def test_bootstrap_token_uses_and_clears_url_fragment(self) -> None: + launcher = (RELEASE_ROOT / "release-console.py").read_text(encoding="utf-8") + webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8") + + self.assertIn("#token={token}", launcher) + self.assertNotIn("?token={token}", launcher) + self.assertIn("window.location.hash.slice(1)", webui) + self.assertIn("history.replaceState", webui) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/release-console.py b/tools/release/release-console.py index 5d6eeb9..afc93fc 100644 --- a/tools/release/release-console.py +++ b/tools/release/release-console.py @@ -32,7 +32,9 @@ def main() -> int: app = create_app(workspace_root=workspace_root, token=token) url = f"http://{args.host}:{args.port}/" if token: - url = f"{url}?token={token}" + # URL fragments are never sent in HTTP requests. The WebUI transfers + # this one-time bootstrap token to sessionStorage and clears the hash. + url = f"{url}#token={token}" print("GovOPlaN release console") print(f" workspace: {workspace_root}") print(f" url: {url}") diff --git a/tools/release/server/app.py b/tools/release/server/app.py index bd34fd1..2f17154 100644 --- a/tools/release/server/app.py +++ b/tools/release/server/app.py @@ -85,7 +85,7 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No @app.middleware("http") async def require_token(request: Request, call_next): # type: ignore[no-untyped-def] if token and request.url.path.startswith("/api/"): - provided = request.headers.get("x-release-console-token") or request.query_params.get("token") + provided = request.headers.get("x-release-console-token") if provided != token: return JSONResponse({"detail": "release console token required"}, status_code=401) return await call_next(request) diff --git a/tools/release/webui/index.html b/tools/release/webui/index.html index ca9dd08..7ada4dd 100644 --- a/tools/release/webui/index.html +++ b/tools/release/webui/index.html @@ -768,11 +768,15 @@