Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions DEPLOYMENT_PROFILES.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ next one starts.
origin (`app/auth_middleware.py` gates writes on the same list).
Verify: `curl -sD - -o /dev/null -H "Origin: https://<new-domain>" https://<new-domain>/api/healthz`
must echo the origin back in `access-control-allow-origin`.
(On Railway the service now also trusts its own `RAILWAY_PUBLIC_DOMAIN`
origin *in addition to* this list, so a stale configured value can no
longer lock the service out of its own domain — but explicit origins for
any other dashboard host still need this step.)
2. **Replace secret reference variables with concrete values.**
`REGENGINE_BASIC_AUTH_USERNAME`, `REGENGINE_BASIC_AUTH_PASSWORD`, and
`REGENGINE_WEBHOOK_HMAC_SECRET` may reference the old service. Deleting
Expand Down
44 changes: 36 additions & 8 deletions app/cors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,42 @@
def cors_origins_from_env() -> list[str]:
raw_origins = os.getenv("REGENGINE_CORS_ORIGINS")
if not raw_origins or not raw_origins.strip():
return list(DEFAULT_CORS_ORIGINS)

origins: list[str] = []
for raw_origin in raw_origins.split(","):
origin = _normalize_cors_origin(raw_origin)
if origin and origin not in origins:
origins.append(origin)
return origins or list(DEFAULT_CORS_ORIGINS)
origins = list(DEFAULT_CORS_ORIGINS)
else:
origins = []
for raw_origin in raw_origins.split(","):
origin = _normalize_cors_origin(raw_origin)
if origin and origin not in origins:
origins.append(origin)
origins = origins or list(DEFAULT_CORS_ORIGINS)

# The service always trusts its own platform-issued domain, IN ADDITION to
# whatever is configured. A union, not a fallback, on purpose: in the
# August 2026 cutover REGENGINE_CORS_ORIGINS was set — to the previous
# service's URL, via a Railway reference variable — so a fallback would
# have changed nothing, and the service rejected every browser request
# from its own domain for three days (see DEPLOYMENT_PROFILES.md,
# "Moving the demo to a new service"). Trusting RAILWAY_PUBLIC_DOMAIN is
# safe because whoever controls that variable controls the deployment
# itself; it widens nothing beyond the service's own canonical origin.
platform_origin = _platform_origin()
if platform_origin and platform_origin not in origins:
origins.append(platform_origin)
return origins


def _platform_origin() -> str | None:
domain = os.getenv("RAILWAY_PUBLIC_DOMAIN", "").strip().rstrip("/")
if not domain:
return None
if "://" not in domain:
domain = f"https://{domain}"
try:
return _normalize_cors_origin(domain)
except ValueError:
# A malformed platform value must degrade to "no extra origin", never
# crash startup — this runs while the ASGI app is being constructed.
return None


def _normalize_cors_origin(raw_origin: str) -> str | None:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi.testclient import TestClient

from app.build_info import BRANCH_ENV_VARS, COMMIT_SHA_ENV_VARS, DEPLOYMENT_ID_ENV_VARS
from app.cors import DEFAULT_CORS_ORIGINS
from app.main import app, controller, cors_origins_from_env, scenario_saves
from app.schemas.simulation import SimulationConfig
from app.regengine_client import LiveIngestResult, LiveRegEngineDeliveryError
Expand Down Expand Up @@ -201,6 +202,42 @@ def test_cors_origins_can_be_configured_without_wildcard_credentials(monkeypatch
cors_origins_from_env()


def test_cors_allowlist_always_trusts_the_platform_domain(monkeypatch):
monkeypatch.setenv("RAILWAY_PUBLIC_DOMAIN", "demo.up.railway.app")

# With no explicit config, the platform origin joins the local defaults.
monkeypatch.delenv("REGENGINE_CORS_ORIGINS", raising=False)
assert cors_origins_from_env() == [
*DEFAULT_CORS_ORIGINS,
"https://demo.up.railway.app",
]

# An explicit-but-stale list can no longer lock the service out of its own
# domain — the regression that kept the nightly smokes red after the
# August 2026 cutover (issues #80/#81).
monkeypatch.setenv("REGENGINE_CORS_ORIGINS", "https://old.up.railway.app")
assert cors_origins_from_env() == [
"https://old.up.railway.app",
"https://demo.up.railway.app",
]

# No duplicate when the platform origin is already configured.
monkeypatch.setenv("REGENGINE_CORS_ORIGINS", "https://demo.up.railway.app")
assert cors_origins_from_env() == ["https://demo.up.railway.app"]


def test_cors_platform_domain_never_crashes_startup(monkeypatch):
monkeypatch.delenv("REGENGINE_CORS_ORIGINS", raising=False)

# A malformed platform value degrades to "no extra origin", not a raise —
# this path runs while the ASGI app is being constructed.
monkeypatch.setenv("RAILWAY_PUBLIC_DOMAIN", "demo.up.railway.app/?bad=1")
assert cors_origins_from_env() == list(DEFAULT_CORS_ORIGINS)

monkeypatch.setenv("RAILWAY_PUBLIC_DOMAIN", " ")
assert cors_origins_from_env() == list(DEFAULT_CORS_ORIGINS)


def test_basic_auth_is_optional_but_enforced_when_configured(monkeypatch):
health_response = client.get("/api/health")
assert health_response.status_code == 200
Expand Down
Loading