Harden release console token bootstrap
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 14s
Security Audit / security-audit (push) Failing after 13s

This commit is contained in:
2026-07-21 03:16:57 +02:00
parent fcc5885dcf
commit 17f8036bdc
4 changed files with 51 additions and 4 deletions

View File

@@ -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()

View File

@@ -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}")

View File

@@ -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)

View File

@@ -768,11 +768,15 @@
</div>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get("token") || sessionStorage.getItem("releaseConsoleToken") || "";
const fragmentParams = new URLSearchParams(window.location.hash.slice(1));
const fragmentToken = fragmentParams.get("token") || "";
const token = fragmentToken || sessionStorage.getItem("releaseConsoleToken") || "";
if (token) {
sessionStorage.setItem("releaseConsoleToken", token);
}
if (fragmentToken) {
history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}`);
}
const elements = {
channel: document.getElementById("channel"),