From 9329d5a06949cb62cf1ab7fc4ff4412f84816ffc Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:32:59 +0000 Subject: [PATCH 1/2] CORS: never allow credentials for the wildcard origin The parent app used allow_origins=['*'] with allow_credentials=True, which makes Starlette reflect any request Origin and add Access-Control-Allow-Credentials: true, so any page that got the browser to send the session cookie could read a signed-in user's responses. New PlatformConfig.cors_origins (platform.toml [platform]): ['*'] (default) allows any origin without credentials; an explicit list allows those origins with credentials; [] disables CORS. Co-Authored-By: Claude Opus 5 --- README.md | 2 + enlace/base.py | 11 ++++++ enlace/compose.py | 30 +++++++++++---- enlace/tests/test_cors.py | 78 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 enlace/tests/test_cors.py diff --git a/README.md b/README.md index cd05016..53b6e1d 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,8 @@ parseable `FAIL` naming the app and its exception class, on apps_dirs = ["apps"] # Directories containing app subdirs app_dirs = ["/path/to/standalone"] # Individual app directories backend_port = 8000 +cors_origins = ["*"] # default: any origin, never with credentials; + # list origins to allow credentials; [] = no CORS [conventions] entry_points = ["server.py", "app.py", "main.py"] diff --git a/enlace/base.py b/enlace/base.py index c5d5d5b..a58c2ff 100644 --- a/enlace/base.py +++ b/enlace/base.py @@ -258,6 +258,17 @@ class PlatformConfig(BaseModel): ), ) domain: str = "localhost" + cors_origins: list[str] = Field( + default_factory=lambda: ["*"], + description=( + "Origins allowed to call the platform cross-origin (CORS). " + '``["*"]`` (default): any origin, WITHOUT credentials -- a page on ' + "another origin can read public responses but never a signed-in " + "user's. An explicit list (e.g. " + '``["https://app.example.com"]``): only those origins, WITH ' + "credentials. ``[]``: no CORS headers at all (same-origin only)." + ), + ) backend_port: int = 8000 frontend_port: int = 3000 process_port_start: int = 9100 diff --git a/enlace/compose.py b/enlace/compose.py index 427571e..6d74c53 100644 --- a/enlace/compose.py +++ b/enlace/compose.py @@ -117,13 +117,7 @@ async def cascade_lifespan(app: FastAPI): ) # CORS on the parent only — sub-apps must NOT add their own CORS - parent.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Dev default; restrict in production - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) + _add_cors(parent, config.cors_origins) # Compose-time plugins (pure ASGI middleware only — no BaseHTTPMiddleware # in plugins either, please). enlace_auth is the canonical plugin: it @@ -437,6 +431,28 @@ async def _redirect() -> RedirectResponse: ) +def _add_cors(parent: FastAPI, origins: list[str]) -> None: + """Install CORS per ``PlatformConfig.cors_origins``. + + Credentials are only ever allowed for an explicit origin list. With + ``["*"]`` plus credentials, Starlette reflects *any* request origin and + adds ``Access-Control-Allow-Credentials: true``, so a page on any origin + that gets the browser to send the session cookie could read a signed-in + user's responses. ``*`` therefore means "anyone, anonymously". + """ + origins = list(origins or []) + if not origins: + return + wildcard = "*" in origins + parent.add_middleware( + CORSMiddleware, + allow_origins=["*"] if wildcard else origins, + allow_credentials=not wildcard, + allow_methods=["*"], + allow_headers=["*"], + ) + + def _add_router_root_redirects(parent: FastAPI) -> None: """Add trailing-slash redirects for router roots like ``/_admin/``. diff --git a/enlace/tests/test_cors.py b/enlace/tests/test_cors.py new file mode 100644 index 0000000..4724621 --- /dev/null +++ b/enlace/tests/test_cors.py @@ -0,0 +1,78 @@ +"""CORS never lets another origin read a signed-in user's responses by default. + +``allow_origins=["*"]`` together with ``allow_credentials=True`` makes +Starlette reflect *any* request ``Origin`` and add +``Access-Control-Allow-Credentials: true`` -- so any page that got the +browser to attach the session cookie could read the response. The default is +now "any origin, anonymously"; credentials need an explicit origin list. +""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from enlace.base import PlatformConfig +from enlace.compose import build_backend +from enlace.discover import discover_apps + + +@pytest.fixture +def apps_dir(tmp_path): + d = tmp_path / "apps" / "p" + d.mkdir(parents=True) + (d / "server.py").write_text( + "from fastapi import FastAPI\napp = FastAPI()\n" + "@app.get('/ping')\ndef ping():\n return {'ok': True}\n" + ) + return tmp_path / "apps" + + +def _client(apps_dir, **kw): + cfg = discover_apps(PlatformConfig(apps_dir=apps_dir, **kw)) + return TestClient(build_backend(cfg)) + + +def _get(client, origin): + return client.get( + "/api/p/ping", headers={"Origin": origin, "Cookie": "enlace_session=x"} + ) + + +def test_default_allows_any_origin_but_never_credentials(apps_dir): + r = _get(_client(apps_dir), "https://evil.example") + assert r.headers.get("access-control-allow-origin") == "*" + assert "access-control-allow-credentials" not in r.headers + + +def test_default_preflight_does_not_grant_credentials(apps_dir): + r = _client(apps_dir).options( + "/api/p/ping", + headers={ + "Origin": "https://evil.example", + "Access-Control-Request-Method": "POST", + }, + ) + assert "access-control-allow-credentials" not in r.headers + + +def test_explicit_origins_get_credentials_others_nothing(apps_dir): + c = _client(apps_dir, cors_origins=["https://app.example.com"]) + ok = _get(c, "https://app.example.com") + assert ok.headers["access-control-allow-origin"] == "https://app.example.com" + assert ok.headers["access-control-allow-credentials"] == "true" + other = _get(c, "https://evil.example") + assert "access-control-allow-origin" not in other.headers + + +def test_empty_list_disables_cors(apps_dir): + r = _get(_client(apps_dir, cors_origins=[]), "https://evil.example") + assert "access-control-allow-origin" not in r.headers + + +def test_platform_toml_sets_it(tmp_path, apps_dir): + toml = tmp_path / "platform.toml" + toml.write_text( + f'[platform]\napps_dir = "{apps_dir}"\ncors_origins = ["https://a.example"]\n' + ) + assert PlatformConfig.from_toml(toml).cors_origins == ["https://a.example"] From 2428c83852e86af8b6a34272affb86ba1c1457e3 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:40:33 +0000 Subject: [PATCH 2/2] Address review: validate cors_origins Refuse "*" mixed with named origins (would silently drop credentials), "null" (any sandboxed page sends it), and values that are not scheme://host[:port] (never match, fail silently). Co-Authored-By: Claude Opus 5 --- README.md | 4 +++- enlace/base.py | 41 +++++++++++++++++++++++++++++++++++++++ enlace/tests/test_cors.py | 24 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 53b6e1d..50629c0 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,9 @@ apps_dirs = ["apps"] # Directories containing app subdirs app_dirs = ["/path/to/standalone"] # Individual app directories backend_port = 8000 cors_origins = ["*"] # default: any origin, never with credentials; - # list origins to allow credentials; [] = no CORS + # or list origins ("https://host[:port]") that may + # send credentials (then only those get CORS); + # [] = no CORS. "*" can't be mixed; "null" refused. [conventions] entry_points = ["server.py", "app.py", "main.py"] diff --git a/enlace/base.py b/enlace/base.py index a58c2ff..f8dda2c 100644 --- a/enlace/base.py +++ b/enlace/base.py @@ -270,6 +270,47 @@ class PlatformConfig(BaseModel): ), ) backend_port: int = 8000 + + @field_validator("cors_origins") + @classmethod + def _check_cors_origins(cls, origins: list[str]) -> list[str]: + """Refuse CORS lists that would silently misbehave or re-open exposure. + + - ``"*"`` must stand alone: mixed with named origins it would drop + credentials for them too. + - ``"null"`` is refused: any sandboxed iframe or ``data:`` page sends + ``Origin: null``, so allowing it with credentials lets any site in. + - Named origins must be ``scheme://host[:port]`` (no path, no trailing + slash) -- anything else never matches a browser's ``Origin``. + """ + from urllib.parse import urlsplit + + if "*" in origins: + if len(origins) > 1: + raise ValueError( + 'cors_origins: "*" cannot be combined with named origins ' + '(use ["*"] for anonymous access from anywhere, or list ' + "the origins that may send credentials)" + ) + return origins + for origin in origins: + if origin.strip().lower() == "null": + raise ValueError('cors_origins: "null" is not allowed') + parts = urlsplit(origin) + if ( + parts.scheme not in ("http", "https") + or not parts.netloc + or parts.path + or parts.query + or parts.fragment + or "@" in parts.netloc + ): + raise ValueError( + f"cors_origins: {origin!r} is not an origin " + "(expected scheme://host[:port], no path or trailing slash)" + ) + return origins + frontend_port: int = 3000 process_port_start: int = 9100 socket_dir: Path = Field(default=Path("/tmp/enlace")) diff --git a/enlace/tests/test_cors.py b/enlace/tests/test_cors.py index 4724621..139a160 100644 --- a/enlace/tests/test_cors.py +++ b/enlace/tests/test_cors.py @@ -76,3 +76,27 @@ def test_platform_toml_sets_it(tmp_path, apps_dir): f'[platform]\napps_dir = "{apps_dir}"\ncors_origins = ["https://a.example"]\n' ) assert PlatformConfig.from_toml(toml).cors_origins == ["https://a.example"] + + +@pytest.mark.parametrize( + "origins", + [ + ["*", "https://a.example"], + ["null"], + ["https://a.example/"], + ["https://a.example/app"], + ["a.example"], + ["ftp://a.example"], + ["https://u@a.example"], + ], +) +def test_misleading_origin_lists_are_refused(apps_dir, origins): + with pytest.raises(ValueError): + PlatformConfig(apps_dir=apps_dir, cors_origins=origins) + + +def test_valid_origin_list_with_port(apps_dir): + cfg = PlatformConfig( + apps_dir=apps_dir, cors_origins=["https://a.example", "http://localhost:5173"] + ) + assert cfg.cors_origins == ["https://a.example", "http://localhost:5173"]