From 39c60be9676fa96ff6fa967984d584e8c246ba6e Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 15 Jun 2026 00:31:47 +0200 Subject: [PATCH] fix(serve): reject unsafe CORS origins (wildcard + credentials) at startup (0.62.1) create_app sets allow_credentials=True. Combined with `--cors-origin '*'` (or a malformed origin) Starlette reflects the request Origin and returns Access-Control-Allow-Credentials: true, letting any website read authenticated cross-origin responses. Validate the origins in create_app: reject `*` and any non scheme://host[:port] value (raising ConfigError), surfaced by `kbagent serve` as a clean --cors-origin usage error. The default localhost dev set is unaffected. Private advisory GHSA-5mh2-6xgr-rf89. --- .claude-plugin/marketplace.json | 2 +- plugins/kbagent/.claude-plugin/plugin.json | 2 +- pyproject.toml | 2 +- src/keboola_agent_cli/changelog.py | 10 ++++ src/keboola_agent_cli/commands/serve.py | 20 +++++--- src/keboola_agent_cli/server/app.py | 53 +++++++++++++++++++--- tests/test_serve_ui.py | 46 +++++++++++++++++++ uv.lock | 2 +- 8 files changed, 119 insertions(+), 18 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8cee447c..e6c299ef 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.63.3", + "version": "0.63.4", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index c6816a70..6da24acb 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.63.3", + "version": "0.63.4", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/pyproject.toml b/pyproject.toml index 9280acbb..6e1d51a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.63.3" +version = "0.63.4" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 0184846a..b88f7856 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,16 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.63.4": [ + "Security: `kbagent serve` now refuses to start with an unsafe CORS configuration. The app " + "sets `allow_credentials=True`, which combined with a wildcard `--cors-origin '*'` (or a " + "malformed origin) makes Starlette reflect the request `Origin` back with " + "`Access-Control-Allow-Credentials: true` -- letting any website read authenticated " + "cross-origin responses. `create_app` now validates the origins and rejects `*` and any " + "non-`scheme://host[:port]` value, surfaced as a clean `--cors-origin` usage error rather " + "than silently shipping the unsafe combination. The default (no `--cors-origin`) localhost " + "dev set is unaffected. Private advisory GHSA-5mh2-6xgr-rf89.", + ], "0.63.3": [ "Fix: `kbagent context` no longer renders API path templates as " "`/apps//logs/tail`. `AGENT_CONTEXT` is an f-string (it " diff --git a/src/keboola_agent_cli/commands/serve.py b/src/keboola_agent_cli/commands/serve.py index 70d5c24c..d063da41 100644 --- a/src/keboola_agent_cli/commands/serve.py +++ b/src/keboola_agent_cli/commands/serve.py @@ -20,6 +20,7 @@ import typer from ..constants import ENV_CONVERSATION_ID +from ..errors import ConfigError logger = logging.getLogger(__name__) @@ -211,13 +212,18 @@ def serve_command( raise typer.Exit(code=1) from None resolved_ui_dist = str(candidate) - app = create_app( - config_dir=config_dir, - auth_token=auth_token, - cors_origins=cors, - serve_url=serve_url, - ui_dist=resolved_ui_dist, - ) + try: + app = create_app( + config_dir=config_dir, + auth_token=auth_token, + cors_origins=cors, + serve_url=serve_url, + ui_dist=resolved_ui_dist, + ) + except ConfigError as exc: + # e.g. an unsafe --cors-origin (wildcard with credentials, GHSA-5mh2). + # Render as a clean CLI usage error instead of a traceback. + raise typer.BadParameter(str(exc), param_hint="--cors-origin") from None if resolved_ui_dist: # UI mode: the user opens the browser directly; there is no BFF to diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index eba968cb..1e7081fe 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -438,6 +438,51 @@ def _format_error( ) +_DEFAULT_CORS_ORIGINS = ( + "http://localhost:5173", # Vite dev default + "http://localhost:8000", # Node BFF + "http://127.0.0.1:5173", + "http://127.0.0.1:8000", +) + + +def _is_valid_cors_origin(origin: object) -> bool: + """True if ``origin`` is a concrete ``scheme://host[:port]`` CORS origin. + + Rejects ``"*"`` and any value carrying a path / query / fragment -- per the + CORS spec an Origin is scheme + host + optional port and nothing else. + """ + if not isinstance(origin, str) or origin == "*": + return False + for scheme in ("http://", "https://"): + if origin.startswith(scheme): + rest = origin[len(scheme) :] + return bool(rest) and not any(c in rest for c in "/?#") + return False + + +def _resolve_cors_origins(cors_origins: list[str] | None) -> list[str]: + """Resolve CORS origins for the credentialed app, rejecting unsafe values. + + The app sets ``allow_credentials=True``. Combined with a wildcard (or + otherwise malformed) origin, Starlette reflects the request ``Origin`` and + returns ``Access-Control-Allow-Credentials: true`` -- letting any website + read authenticated cross-origin responses (GHSA-5mh2-6xgr-rf89). Fail fast + rather than ship that: reject ``"*"`` and any non ``scheme://host[:port]`` + origin. Default (no ``--cors-origin``) is the localhost dev set. + """ + origins = cors_origins or list(_DEFAULT_CORS_ORIGINS) + invalid = [o for o in origins if not _is_valid_cors_origin(o)] + if invalid: + raise ConfigError( + f"Refusing to start: CORS origin(s) {invalid} are unsafe with " + f"credentialed requests. Use explicit 'scheme://host[:port]' origins " + f"(e.g. http://localhost:5173); '*' is rejected because it would " + f"expose authenticated responses to any website." + ) + return origins + + def create_app( *, config_dir: str | None = None, @@ -524,13 +569,7 @@ async def _lifespan(app_: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=cors_origins - or [ - "http://localhost:5173", # Vite dev default - "http://localhost:8000", # Node BFF - "http://127.0.0.1:5173", - "http://127.0.0.1:8000", - ], + allow_origins=_resolve_cors_origins(cors_origins), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/tests/test_serve_ui.py b/tests/test_serve_ui.py index 0698da5f..2889a0a4 100644 --- a/tests/test_serve_ui.py +++ b/tests/test_serve_ui.py @@ -156,6 +156,52 @@ def test_non_api_path_stays_public_not_auth_walled(self, tmp_path: Path, ui_dist ) +class TestCorsCredentialsGuard: + """GHSA-5mh2-6xgr-rf89: allow_credentials=True must never pair with a + wildcard or malformed CORS origin (Starlette would reflect any Origin and + return Access-Control-Allow-Credentials: true). create_app rejects such a + config at startup so the unsafe combination can never ship.""" + + @pytest.mark.parametrize( + "bad", + [ + ["*"], + ["http://localhost:5173", "*"], + ["evil.com"], + ["http://evil.com/path"], + ["ws://x"], + ], + ) + def test_unsafe_cors_origins_rejected(self, tmp_path: Path, bad: list[str]) -> None: + from keboola_agent_cli.errors import ConfigError + + with pytest.raises(ConfigError): + create_app(config_dir=str(tmp_path / "cfg"), auth_token="t", cors_origins=bad) + + @pytest.mark.parametrize( + "good", + [ + None, + ["http://localhost:5173"], + ["https://app.example.com", "http://127.0.0.1:8000"], + ], + ) + def test_safe_cors_origins_accepted(self, tmp_path: Path, good: list[str] | None) -> None: + app = create_app(config_dir=str(tmp_path / "cfg"), auth_token="t", cors_origins=good) + assert app is not None + + def test_is_valid_cors_origin_predicate(self) -> None: + from keboola_agent_cli.server.app import _is_valid_cors_origin + + assert _is_valid_cors_origin("http://localhost:5173") + assert _is_valid_cors_origin("https://app.example.com") + assert _is_valid_cors_origin("http://127.0.0.1:8000") + assert not _is_valid_cors_origin("*") + assert not _is_valid_cors_origin("example.com") # no scheme + assert not _is_valid_cors_origin("http://x/path") # carries a path + assert not _is_valid_cors_origin("ws://x") # wrong scheme + + class TestCookieAuth: """Cookie path: ``GET /`` sets the cookie, subsequent requests use it. diff --git a/uv.lock b/uv.lock index c5d5e300..96387637 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.63.3" +version = "0.63.4" source = { editable = "." } dependencies = [ { name = "croniter" },