fix(gateway): expose the run metadata header to split-origin clients (#4535)

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.
This commit is contained in:
Aari 2026-07-29 00:09:02 +08:00 committed by GitHub
parent 9c7cd4cad3
commit c24bf383e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 30 additions and 3 deletions

View File

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

View File

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

View File

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

View File

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