From c24bf383e53ac31fdcccf85123ee36cf9a46feeb Mon Sep 17 00:00:00 2001 From: Aari Date: Wed, 29 Jul 2026 00:09:02 +0800 Subject: [PATCH] fix(gateway): expose the run metadata header to split-origin clients (#4535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser client served from a different origin than the Gateway never learns the id of the run it just created, so a brand-new thread keeps its placeholder route for the whole session and every action gated on an established thread — edit and rerun, regenerate, branch — stays hidden until the page is reloaded. Run-creating routes return the run's id in `Content-Location`, and the LangGraph SDK resolves run metadata from that header alone. It is not CORS-safelisted, so a cross-origin response hides it from JS unless the server lists it in `Access-Control-Expose-Headers`. `useStream`'s `onCreated` therefore never fires and the app cannot rewrite its route. Expose it. `GATEWAY_CORS_ORIGINS` is a supported deployment mode, so the CORS middleware has to carry everything that mode needs to read. Same-origin nginx deployments are unaffected because CORS never applies to them. --- backend/AGENTS.md | 2 +- backend/app/gateway/app.py | 9 +++++++-- backend/app/gateway/csrf_middleware.py | 7 +++++++ backend/tests/test_gateway_docs_toggle.py | 15 +++++++++++++++ 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index a6185f9cd..c0afa4d72 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -422,7 +422,7 @@ Extensions are optional only in the fallback *search* mode (priority 3-4 above): FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWAY_ENABLE_DOCS=false` to disable `/docs`, `/redoc`, and `/openapi.json` in production (default: enabled). -CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned. +CORS is same-origin by default when requests enter through nginx on port 2026. Split-origin or port-forwarded browser clients must opt in with `GATEWAY_CORS_ORIGINS` (comma-separated exact origins); Gateway `CORSMiddleware` and `CSRFMiddleware` both read that variable so browser CORS and auth-origin checks stay aligned. Those clients also need `CORS_EXPOSED_HEADERS` (`csrf_middleware.py`): run-creating routes return the run's id in `Content-Location`, which is not CORS-safelisted, so JS cannot read it unless it is exposed. The LangGraph SDK resolves run metadata from that header alone — withhold it and `useStream`'s `onCreated` never fires, a new thread keeps its placeholder route, and every action gated on an established thread (edit, regenerate, branch) stays hidden until the page is reloaded. Same-origin nginx deployments never hit this because CORS does not apply. Browser auth sessions are owned by `app.gateway.auth.session_cookie`. Login accepts a `remember_me` form flag, but the Gateway never stores passwords. `SessionCookiePolicy` persists the `HttpOnly access_token` cookie only for HTTPS/trusted-forwarded HTTPS, direct-host localhost HTTP, or explicit operator opt-in for insecure persistence; public HTTP sandbox URLs degrade to session cookies. Session-creating handlers stamp the final `max_age` on `request.state`, and CSRF cookie creation mirrors that value so the double-submit cookie pair expires together, including explicit re-issue after password changes and OIDC callbacks. A small `HttpOnly` preference cookie preserves the user's remember choice across token re-issue paths. Logout clears all auth cookies and suppresses CSRF re-issue on the logout response. diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 637bdbbe6..1c97050a7 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -10,7 +10,7 @@ from app.gateway.auth_disabled import warn_if_auth_disabled_enabled from app.gateway.auth_middleware import AuthMiddleware from app.gateway.browser_capability import ensure_browser_runtime_available from app.gateway.config import get_gateway_config -from app.gateway.csrf_middleware import CSRFMiddleware, get_configured_cors_origins +from app.gateway.csrf_middleware import CORS_EXPOSED_HEADERS, CSRFMiddleware, get_configured_cors_origins from app.gateway.deps import langgraph_runtime from app.gateway.routers import ( agents, @@ -539,7 +539,11 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for # CORS: the unified nginx endpoint is same-origin by default. Split-origin # browser clients must opt in with this explicit Gateway allowlist so CORS - # and CSRF origin checks share the same source of truth. + # and CSRF origin checks share the same source of truth. They also need the + # run id the Gateway returns in a non-safelisted response header; without + # exposing it the SDK never reports a created run, so a new thread keeps its + # placeholder route and every action gated on an established thread stays + # hidden until the page is reloaded. cors_origins = sorted(get_configured_cors_origins()) if cors_origins: app.add_middleware( @@ -548,6 +552,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=list(CORS_EXPOSED_HEADERS), ) # Request trace correlation: when logging.enhance.enabled=true, bind one diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py index cb0181d68..fd9e3b2a1 100644 --- a/backend/app/gateway/csrf_middleware.py +++ b/backend/app/gateway/csrf_middleware.py @@ -122,6 +122,13 @@ def get_configured_cors_origins() -> set[str]: return _configured_cors_origins() +# Response headers a split-origin browser client must be able to read. Only the +# CORS-safelisted set is visible to JS by default, and the created run's id +# travels in `Content-Location` — the LangGraph SDK resolves run metadata from +# it, so withholding it leaves such a client unable to learn its own run id. +CORS_EXPOSED_HEADERS: tuple[str, ...] = ("Content-Location",) + + def _first_header_value(value: str | None) -> str | None: """Return the first value from a comma-separated proxy header.""" if not value: diff --git a/backend/tests/test_gateway_docs_toggle.py b/backend/tests/test_gateway_docs_toggle.py index 372f93e18..986370286 100644 --- a/backend/tests/test_gateway_docs_toggle.py +++ b/backend/tests/test_gateway_docs_toggle.py @@ -148,6 +148,21 @@ def test_gateway_cors_allows_configured_origin(): assert response.headers["access-control-allow-credentials"] == "true" +def test_gateway_cors_exposes_the_run_metadata_header(): + """`Content-Location` carries the created run id and is not CORS-safelisted. + + A split-origin browser client that cannot read it never learns its own run + id, so the SDK reports no created run and the thread keeps its placeholder + route for the whole session. + """ + client = _make_gateway_client("https://app.example") + + response = client.get("/health", headers={"Origin": "https://app.example"}) + + exposed = {value.strip().lower() for value in response.headers.get("access-control-expose-headers", "").split(",")} + assert "content-location" in exposed + + def test_gateway_cors_rejects_unconfigured_origin(): client = _make_gateway_client("https://app.example")