fix(serve): reject unsafe CORS origins (wildcard + credentials) (GHSA-5mh2, code-only) - #422
Conversation
39c60be to
6ec7b5a
Compare
padak
left a comment
There was a problem hiding this comment.
Review of #422 — fix(serve): reject unsafe CORS origins (wildcard + credentials) (GHSA-5mh2, code-only)
Generated by
kbagent-pr-reviewersubagent. Verdict and findings below
are advisory; the human author retains every veto. CI-coverable issues
(lint, format, tests) are confirmed viamake check, not duplicated here.
Per the PR description, the absent version bump / changelog entry / version-sync
are intentional and are NOT flagged in this review.
Summary
This PR closes GHSA-5mh2-6xgr-rf89: when kbagent serve was started with
--cors-origin '*' (or any malformed origin), Starlette reflected the
request Origin header and returned Access-Control-Allow-Credentials: true,
allowing any website to read authenticated cross-origin responses. The fix
introduces _resolve_cors_origins and _is_valid_cors_origin in server/app.py
that validate origins at startup, raising ConfigError for wildcards or
non-scheme://host[:port] values; commands/serve.py converts the ConfigError
to a clean typer.BadParameter (exit 2). The core fix is correct: Starlette's
allow_all_origins + allow_credentials reflection path is fully blocked. One
non-blocking gap exists in the test coverage (the CLI-layer error path is not
exercised via CliRunner), one non-blocking documentation gap exists (no
gotchas.md entry for the new restriction), and one nit on the validator.
Verdict: COMMENT (no blocking findings; two non-blocking items worth addressing).
Verdict
- Verdict: COMMENT
- Blocking findings: 0
- Non-blocking findings: 3
- Nits: 1
Blocking findings
(none)
Non-blocking findings
[NB-1] src/keboola_agent_cli/commands/serve.py:223 — except ConfigError catch is scoped to --cors-origin hint but will misattribute future ConfigError raises from create_app
The handler catches all ConfigError from create_app() and re-raises as
typer.BadParameter(..., param_hint="--cors-origin"). Today this is correct
because _resolve_cors_origins (line 477 of app.py) is the only ConfigError
raise inside create_app. However, the catch is against the entire create_app
call, so any future ConfigError from a different part of the function
(e.g., config-store init failure, registry boot failure) would surface as a
misleading --cors-origin usage error rather than a general startup failure.
A minimal mitigation is to push the validation out of create_app and into
serve_command before the create_app() call, making the exception boundary
explicit. Alternatively, a narrow sentinel exception (e.g.,
class CorsConfigError(ConfigError): pass) would let the except clause
stay in serve_command without over-catching. The current code ships safely
since no other ConfigError is raised in create_app today; this is a
forward-maintenance concern, not an immediate bug.
[NB-2] tests/test_serve_ui.py — CLI-layer error path (ConfigError → typer.BadParameter, exit 2) is not exercised via CliRunner
TestCorsCredentialsGuard tests the guard by calling create_app() directly.
This verifies that the security check fires, but it does not verify that
serve_command converts the ConfigError to typer.BadParameter with exit
code 2 (rather than letting a traceback escape). The serve_command ultimately
calls uvicorn.run(), but typer.BadParameter is raised and the process exits
before uvicorn.run() is reached — so a CliRunner-based test can exercise
this path without spawning uvicorn by mocking uvicorn.run or by relying on
the early-exit: e.g.,
result = runner.invoke(app, ["serve", "--cors-origin", "*"]); assert result.exit_code == 2.
Adding this test closes the gap between "the guard fires" and "the CLI surface
exits correctly".
[NB-3] plugins/kbagent/skills/kbagent/references/gotchas.md — missing (since vX.Y.Z) entry for the --cors-origin '*' startup rejection
The CORS guard changes user-visible behavior: kbagent serve --cors-origin '*'
previously started successfully; after this PR it exits with code 2.
An AI agent recommending --cors-origin '*' based on generic CORS advice or
prior session experience would cause a startup failure on versions that include
this fix. Per CONTRIBUTING.md gotchas.md is explicitly called out as the
place for this kind of "(since vX.Y.Z) behavior change" note. The entry should
be tagged with the version that ships this PR and note that '*' is rejected
at startup with a BadParameter error pointing at --cors-origin. Since the
PR intentionally defers the version bump, the token (since vNEXT) or
(since 0.65.0) can be filled in at release time — but the entry should be
drafted in this PR so it is not forgotten.
Nits
[NIT-1]src/keboola_agent_cli/server/app.py:460—_is_valid_cors_originacceptshttp://user:pass@evil.combecause@is not in the rejection set/?#. A browser never sends credentials in anOriginheader (RFC 6454 strips auth info), so Starlette's exact-match would never match a legituser:pass@config entry against an attacker'sOrigin; the security impact is negligible. Still, explicitly rejecting@in the host segment ("@" in rest) would make the validator match the RFC definition more precisely and future-proof it against non-browser callers that might send a literaluser:pass@hostOrigin.
Verification log
gh pr view 422 --json title,body,files,additions,deletions,state→ state OPEN, 3 files, +105/-14, conventionalfix(serve):prefix ✓git rev-parse --abbrev-ref HEAD→fix/serve-cors-credentials-guardmatches<branch>✓make check→ ruff: all checks passed;ruff format: 318 files already formatted;ty check: clean;check_error_codes.py: OK; 4146 passed, 8 skipped → exit 0 ✓- Layer violation grep (typer in services, httpx in commands, formatter in clients) → empty ✓
grep -E '^\+.*@.*_app\.command' diff→ no new CLI command decorators ✓ (no plugin sync map obligations triggered)grep -E '^\+' diff | grep -E 'except\s*:'→ no bare except ✓grep -E '^\+.*error_code\s*=\s*"[A-Z_]+"' diff→ no raw error_code strings ✓grep -E '^\+(from httpx|import httpx|httpx\.Client)' diff→ no new HTTP calls outside clients ✓- Starlette
CORSMiddleware.send()source inspection → confirmed:allow_all_origins + allow_credentialscauses Starlette to callallow_explicit_origin(headers, origin)which setsAccess-Control-Allow-Originto whateverOrigin:header was sent, enabling the CORS credentials attack. The fix is correctly targeted ✓ _is_valid_cors_originmanual edge-case run:http://user:pass@evil.com→True(NIT-1);http://[::1]:8080→True(IPv6 localhost, safe);HTTP://localhost→False(uppercase scheme rejected);//localhost→False✓grep -rn 'CliRunner\|runner.invoke' tests/test_serve_ui.py→ no CliRunner tests; CLI-layer BadParameter path untested (NB-2)grep -n 'cors\|CORS' plugins/kbagent/skills/kbagent/references/gotchas.md→ no entry (NB-3)grep -rn 'raise ConfigError' src/keboola_agent_cli/server/app.py→ exactly one raise, in_resolve_cors_origins(line 477). Theexcept ConfigErrorinserve_commandis correctly scoped for current code (NB-1 is a forward-maintenance concern, not a present bug) ✓
Open questions for the author
(none)
…artup 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 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. Code-only (no version bump / changelog entry) so the PR stays conflict-free against the rapid main release cadence; version + changelog to be added at the next release. Private advisory GHSA-5mh2-6xgr-rf89.
6ec7b5a to
fe4f1f3
Compare
Author response — review findings addressed (commit fe4f1f3, still code-only)Thanks for the review. All three actionable code findings addressed; the one doc finding is deferred to release. PR stays code-only (3 files), so it remains conflict-immune.
|
Summary
Fixes M6 from the 2026-06-12 security audit (private advisory GHSA-5mh2-6xgr-rf89) — a permissive-CORS misconfiguration in
kbagent serve.create_appregistersCORSMiddlewarewithallow_credentials=True. Combined with a wildcard--cors-origin '*'(or a malformed origin), Starlette reflects the requestOriginand returnsAccess-Control-Allow-Credentials: true— letting any website read authenticated cross-origin responses from a victim's runningserve.Fix
create_appvalidates origins via_resolve_cors_origins: rejects*and any value that isn't a concretescheme://host[:port](raisingConfigError);kbagent servesurfaces it as a cleantyper.BadParameteron--cors-origin(exit 2). The default localhost dev set is unchanged — only the actively-dangerous wildcard/malformed config is refused.This PR deliberately touches only the 3 code files (
server/app.py,commands/serve.py,tests/test_serve_ui.py) and does not bump the version or add a changelog entry. Rationale:mainreleases ~daily, and a security PR carrying a version bump + changelog entry conflicts onpyproject.toml/changelog.pyagainst every release — this PR was rebased 4× over 2 days chasing the moving version. Dropping the release-bookkeeping makes it conflict-immune so it can be merged on the owner's schedule.At merge/next release, add: a version bump and a
changelog.pyentry for the CORS guard (text available in the advisory / prior PR revisions).Tests
TestCorsCredentialsGuardintest_serve_ui.py(9 tests): parametrized rejection of*, mixed-with-*, scheme-less, path-carrying, andws://origins; acceptance of defaults + explicithttp(s)://host[:port]; unit test of the_is_valid_cors_originpredicate. Full suite green: 4146 passed, 133 skipped; lint/format/ty/version-check clean.