Skip to content

fix(web): replace wildcard CORS with origin/Host validation; wire board SSE events - #51

Merged
junhoyeo merged 4 commits into
mainfrom
agent/fix-web-security
Jul 17, 2026
Merged

junhoyeo merged 4 commits into
mainfrom
agent/fix-web-security

Conversation

@junhoyeo

Copy link
Copy Markdown
Owner

Wave 2/3 — security hardening.

HIGH — wildcard CORS with no Host/Origin validation on state-changing REST and SSE. The dashboard is same-origin, so the wildcard was pure attack surface: any web page could drive the local API. Now: Host header validated (DNS-rebinding guard), no-Origin (curl/CLI) allowed, same-origin + loopback allowed, everything else rejected 403. Escape hatch: CONTRABASS_ALLOWED_ORIGINS (exact origins, or * to opt out). Exact CORS headers replace the wildcard.

Also: SetEventSink finally has a caller — board mutations now broadcast to SSE clients (the review caught a send-on-closed-channel shutdown race in the first wiring; fixed in the follow-up commit). ReadHeaderTimeout on the HTTP server, MaxBytesReader on board handlers, hub.Subscribe after Run exit returns a closed channel instead of a dead one.

Adversarial review: 1 blocking (the shutdown race) → fixed and re-verified. go test ./internal/web/... ./internal/hub/... ./cmd/... green after rebase.

junhoyeo added 4 commits July 17, 2026 13:08
Once Run returned (source closed or ctx done) the hub had no closed
state, so a late Subscribe registered a channel nothing would ever
write to or close. Reconnecting SSE clients subscribed to the dead hub
and hung on keepalives forever, silently frozen. Subscribe now hands
out an already-closed channel once Run has exited, and Closed() lets
handlers refuse new streams outright.

Constraint: Subscribe signature must stay (int, <-chan T) for existing callers
Rejected: error return from Subscribe | breaks both stream handlers and cmd callers for no added signal
Confidence: high
Scope-risk: narrow
…CORS

withCORS set Access-Control-Allow-Origin: * on every plain REST/SSE
route with no Host or Origin validation, so any web page could read
the full orchestrator state, stream /api/v1/events, and drive
POST/PATCH mutations (stop agents, create/modify board issues) against
the localhost dashboard. The dashboard is served by this same server,
so cross-origin access is unnecessary by default: requests now pass
only with no Origin header (curl/CLI), a same-origin Origin, or a
loopback Origin, and the Host header must name a loopback address or
the configured listen host to block DNS rebinding. Validated origins
are echoed exactly (with Vary: Origin) instead of *. Operators serving
the dashboard from another origin can allowlist it (or "*") via
CONTRABASS_ALLOWED_ORIGINS.

Also hardens the same surface: http.Server gains ReadHeaderTimeout to
bound slowloris clients, board create/update bodies are capped at
1 MiB via http.MaxBytesReader (parity with the streamable HTTP cap),
and /api/v1/events returns 503 once the hub has shut down so
EventSource clients surface the outage instead of reconnecting into a
silent stream.

Updated tests that pinned the removed behavior: the "*" grant
assertions and requests carrying httptest's default example.com Host,
which the rebinding guard now rejects by design.

Constraint: same-origin dashboard and no-Origin CLI clients must keep working unauthenticated
Constraint: WriteTimeout must stay unset so SSE streams are not severed
Rejected: reuse isAllowedLoopbackExactOrigin | its RemoteAddr loopback check breaks operators who deliberately bind non-localhost
Rejected: reject only state-changing routes | wildcard grant also let foreign origins read state snapshots and event streams
Confidence: high
Scope-risk: moderate
Directive: keep CONTRABASS_ALLOWED_ORIGINS parsing exact-origin only; pattern matching would reopen the bypass this fixes
Not-tested: IPv6 bracketed Host/Origin variants beyond net.ParseIP loopback handling
SetEventSink had no production caller, so publishEvent silently
no-oped and the board_issue_created/updated/moved events emitted by
the HTTP board handlers never reached the hub. A second dashboard tab
(or any stream subscriber) showed a stale Kanban board until a full
reload. Both server construction sites now pass the hub's source
channel as the sink.

Confidence: high
Scope-risk: narrow
Not-tested: no cmd-level regression test; the publish path itself is covered by internal/web board handler tests
The event forwarder in run() closed webEvents once orch.Events() ended.
With the server now publishing board mutations into the same channel via
SetEventSink, shutdown raced: ctx cancel ends orch.Run, the forwarder
closes webEvents, while graceful Shutdown gives in-flight handlers up to
5s — a POST/PATCH reaching publishEvent after the close panics with
"send on closed channel" (select/default does not guard a closed
channel). Extract the forwarder into forwardOrchestratorEvents, which
never closes the sink; the hub already tears down subscribers via ctx.

Constraint: publishEvent and the forwarder are concurrent writers to one channel, so neither writer may close it
Rejected: recover() inside publishEvent | hides the race instead of removing it
Rejected: routing publishEvent through a new hub broadcast API | larger surface change; team_root relies on the raw sink contract
Confidence: high
Scope-risk: narrow
Directive: webEvents must stay open for the process lifetime; hub shutdown is driven by ctx, never by closing the source
Not-tested: natural orchestrator completion without ctx cancel now leaves SSE streams open until process exit (benign; process is exiting)
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
contrabass-landing Ready Ready Preview, Comment Jul 17, 2026 4:11am

Request Review

@junhoyeo
junhoyeo merged commit e3ae4f9 into main Jul 17, 2026
11 checks passed

This branch was successfully deployed

1 active deployment
Preview — 919151a0 Deployed Jul 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant