Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ 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;
# 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"]
Expand Down
52 changes: 52 additions & 0 deletions enlace/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,59 @@ 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

@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"))
Expand Down
30 changes: 23 additions & 7 deletions enlace/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/``.

Expand Down
102 changes: 102 additions & 0 deletions enlace/tests/test_cors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""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"]


@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"]
Loading