Skip to content

feat: CORS support for inbound A2A endpoint (origin allowlist) + fix SSE header ordering - #10

Merged
ryubyte merged 1 commit into
mainfrom
fix/inbound-cors
Sep 28, 2026
Merged

ryubyte merged 1 commit into
mainfrom
fix/inbound-cors

Conversation

@ryubyte

@ryubyte ryubyte commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #8.

Problem

Browser-based A2A clients (Flutter Web, plain fetch()) can't call the inbound endpoint: no CORS headers are sent, and OPTIONS preflight falls into the 405 branch — so the browser refuses the real request even though curl/native clients work. There's also a latent SSE bug (below).

Approach: explicit origin allowlist, not a wildcard default

The original report (#8) suggested defaulting corsOrigin to '*', arguing it's safe because inbound is gated by the Bearer authToken. That reasoning only holds when a token is set — but authToken is optional and off by default. A '*' default would mean: on the common no-auth local setup, any website the user visits could drive their local agent cross-origin. Today the missing CORS is what blocks that. So this PR takes the safer shape:

  • New corsOrigins?: string[] server config (persisted to a2a.json).
    • Empty/absent = no CORS (default) — cross-origin browser calls stay blocked, exactly as before.
    • Each request's Origin is matched against the list; a hit echoes that exact origin back with Vary: Origin; a miss sends no CORS headers.
    • The literal ["*"] opts into a wildcard (logged as a warning) for fully-trusted local setups.
    • Credentials are never allowed (auth is a Bearer header, not a cookie), so no CSRF surface even with *.
  • Answer OPTIONS before routing and stamp resolved CORS on every response (AgentCard, JSON-RPC, SSE, 405, 401). The allowlist is read per-request from a mutable closure, so UI/config changes take effect live without a route rebuild.
  • Settings-panel editor ("对外服务" tab): add/remove allowed origins as chips, with a warning banner when * is set. New set-server-cors control action + setServerCors hook; serverStatus reports the current allowlist.
  • Fix a latent SSE bug: the success path called res.write() (via the onEvent callback) before res.writeHead(), so Node auto-flushed default headers and the stream never got content-type: text/event-stream or CORS. Auth is now checked up front (new public A2AServer.checkAuth), then stream headers are written before the first frame.

Testing

  • npm run typecheck + npm test (46) + client-bundle smoke — all green.

  • Verified live on dsh 0.1.7-rc.2 (server-mode instance):

    Scenario Result
    Empty allowlist (default), any Origin no Access-Control-* on OPTIONS/GET/POST — browser blocks ✅
    Allowlist [http://127.0.0.1:8080], matching Origin Access-Control-Allow-Origin: http://127.0.0.1:8080 + Vary: Origin ✅
    Same allowlist, non-matching Origin (evil.com) no CORS headers → blocked ✅
    ["*"], arbitrary Origin echoes caller origin + Vary: Origin; warning logged ✅
    SSE (SendStreamingMessage) content-type: text/event-stream + cache-control: no-cache + CORS, before first frame ✅
    Set via settings UI / API applies live (no restart) and persists to a2a.json ✅

Notes

…SSE headers

Browser-based A2A clients (Flutter Web, plain fetch) could not call the inbound
endpoint: no CORS headers were sent and OPTIONS preflight fell into the 405
branch, so the browser refused the real request. curl/native clients worked.

Design: an explicit **origin allowlist**, not a wildcard default.

- New `corsOrigins?: string[]` server config (persisted). EMPTY/ABSENT = NO
  CORS (safe default) — a cross-origin browser page is blocked, exactly as
  before this feature. Rationale: server-mode auth (authToken) is optional and
  off by default, so a wildcard default would let any site the user visits
  drive their local agent. Each request's Origin is matched against the list;
  a hit echoes that exact origin back with `Vary: Origin`, a miss sends no CORS
  headers. The literal `["*"]` opts into a wildcard (logged as a warning) for
  fully-trusted local setups. Credentials are never allowed (auth is a Bearer
  header, not a cookie).
- Answer OPTIONS before routing; stamp resolved CORS on every response
  (AgentCard, JSON-RPC, SSE, 405, 401). Allowlist is read per-request from a
  mutable closure, so UI changes take effect live without a route rebuild.
- New `set-server-cors` control action + `setServerCors` hook (persists to
  a2a.json), and a settings-panel editor: add/remove allowed origins as chips,
  with a warning when `*` is set. serverStatus reports the current allowlist.
- Fix a latent SSE bug: the success path called res.write() (via onEvent)
  before res.writeHead(), so Node auto-flushed default headers and the stream
  never got content-type: text/event-stream or CORS. Auth is now checked up
  front (new public A2AServer.checkAuth), then stream headers are written
  before the first frame.

Closes #8.
@ryubyte ryubyte changed the title feat: CORS support for inbound A2A endpoint + fix SSE header ordering feat: CORS support for inbound A2A endpoint (origin allowlist) + fix SSE header ordering Sep 28, 2026
@ryubyte
ryubyte merged commit 048ec9c into main Sep 28, 2026
1 check passed
@ryubyte
ryubyte deleted the fix/inbound-cors branch September 28, 2026 04:28
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.

Inbound A2A endpoint has no CORS support — browser-based A2A clients (e.g. Flutter Web apps) cannot call SendMessage/AgentCard

1 participant