fix(goose2): gate ACP bridge with ephemeral auth token and restrict CORS origins#8637
fix(goose2): gate ACP bridge with ephemeral auth token and restrict CORS origins#8637mvanhorn wants to merge 3 commits intoaaif-goose:mainfrom
Conversation
…ORS origins
The goose2 localhost ACP WebSocket bridge at ws://127.0.0.1:<port>/acp
had no client-auth mechanism on the transport and used
CorsLayer::new().allow_origin(Any). Any local process or browser tab
could connect and drive the agent.
Two layers of defense in depth:
1. Ephemeral bearer token. `goose serve` generates a 32-byte hex token
from OsRng at startup and prints a single `ACP_TOKEN=<hex>` line on
stdout before axum starts serving. The Tauri parent pipes stdout,
reads the token, and stores it on the singleton GooseServeProcess.
A new `get_goose_serve_connection` Tauri command returns
`{ url, token }`; the old `get_goose_serve_url` caller was updated.
The frontend opens the WebSocket with the `Sec-WebSocket-Protocol:
goose-acp-auth.<token>` subprotocol (browsers cannot set arbitrary
headers on `new WebSocket`, so subprotocol is the primary path).
HTTP callers can also present the token via `Authorization: Bearer`.
The router runs a from_fn_with_state middleware on the `/acp` routes
only - `/health` and `/status` remain unauthenticated. Token
comparison uses subtle::ConstantTimeEq.
2. CORS origin allowlist. Replaced `allow_origin(Any)` with
AllowOrigin::list covering http://127.0.0.1, http://localhost,
tauri://localhost, and https://tauri.localhost.
Unit tests cover missing auth (401), wrong token (401), right token via
Authorization header (accepted), and right token via WebSocket
subprotocol (accepted).
Closes aaif-goose#8601
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c7f1a628a7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Addresses Codex bot findings on PR aaif-goose#8637. P1: require_acp_auth now gates every /acp route, but http-stream.ts had no way to supply the token. Add an optional token parameter to createHttpStream() and GooseClient; when present, every request sends an Authorization: Bearer header. URL-based SDK callers can now authenticate by threading the token from get_goose_serve_connection. P2: AllowOrigin::list matched exactly, so dev origins like http://localhost:1520 and configurable VITE_PORT values never received the CORS headers. Switch to AllowOrigin::predicate backed by a new is_allowed_origin helper that accepts http://localhost and http://127.0.0.1 with any port, plus the existing tauri scheme origins. Added unit tests for the matcher. Also: cargo fmt fix in ui/goose2/src-tauri/.../goose_serve.rs:169 (tokio::spawn block formatting), which was the proximate cause of the failing Rust Lint and Desktop Build & E2E checks. Verified locally: cargo fmt -p goose-acp clean, cargo fmt in ui/goose2/src-tauri clean, cargo clippy -p goose-acp --all-targets -- -D warnings clean, cargo test -p goose-acp --lib 48 passed (includes 4 original auth tests and 3 new CORS tests).
|
Addressed bot findings in 225558c:
Also fixed the cargo fmt diff in Verified: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 225558c60c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| token?: string, | ||
| ) { | ||
| const stream = | ||
| typeof streamOrUrl === "string" | ||
| ? createHttpStream(streamOrUrl) | ||
| ? createHttpStream(streamOrUrl, token) |
There was a problem hiding this comment.
Require token when constructing GooseClient from URL
All /acp routes are now auth-gated, but URL-mode GooseClient still accepts a missing token and proceeds, so existing URL-only call sites keep compiling while failing with runtime 401 Unauthorized. Fresh evidence in this commit: a token parameter was added here as optional (token?) rather than enforced for string URLs, which leaves flows like ui/text --server <goose serve URL> unable to authenticate unless every caller is manually updated. Enforcing a token (or failing fast) for URL connections would prevent this regression.
Useful? React with 👍 / 👎.
| let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?; | ||
| let token = value.strip_prefix("Bearer ")?; |
There was a problem hiding this comment.
Parse Authorization scheme case-insensitively
The bearer parser now requires the exact prefix "Bearer ", but HTTP authentication scheme names are case-insensitive, so valid headers like authorization: bearer <token> will be rejected. Because this middleware now guards all /acp HTTP requests, this can break compliant clients or proxies that normalize scheme casing; parse the scheme case-insensitively before extracting the token.
Useful? React with 👍 / 👎.
Codex flagged that extract_bearer_token used exact-prefix matching on 'Bearer ', so RFC 7235 §2.1-compliant headers like 'bearer <token>' or 'BEARER <token>' were rejected even though the scheme name is case-insensitive. Split on the first space, compare the scheme with eq_ignore_ascii_case, and added a test covering the four casings plus Basic/malformed/empty negative cases. No behavior change for the canonical 'Bearer <token>' form the SDK clients already send; this just accepts valid variants from other HTTP clients and proxies that normalize scheme casing.
|
Codex review on 225558c walked through 4 findings. Status:
|
Summary
The goose2 localhost ACP WebSocket bridge at
ws://127.0.0.1:<port>/acphad no client-auth mechanism on the transport and usedCorsLayer::new().allow_origin(Any). Any local process or browser tab on the same machine could connect and drive the agent. Two layers of defense in depth:Layer 1: Ephemeral bearer token.
goose servegenerates a 32-byte hex token fromOsRngat startup and prints a singleACP_TOKEN=<hex>line on stdout before axum starts serving. The Tauri parent pipes stdout, reads the token, and stores it on the singletonGooseServeProcess. A newget_goose_serve_connectionTauri command returns{ url, token }; the oldget_goose_serve_urlcaller was updated. The frontend opens the WebSocket with theSec-WebSocket-Protocol: goose-acp-auth.<token>subprotocol (browsers cannot set arbitrary headers onnew WebSocket, so subprotocol is the primary transport). HTTP callers can also present the token viaAuthorization: Bearer.The router runs a
from_fn_with_statemiddleware on the/acproutes only -/healthand/statusremain unauthenticated. Token comparison usessubtle::ConstantTimeEq.Layer 2: CORS origin allowlist. Replaced
allow_origin(Any)withAllowOrigin::listcoveringhttp://127.0.0.1,http://localhost,tauri://localhost, andhttps://tauri.localhost.Testing
cargo fmt --check: cleancargo clippy -p goose-acp -p goose-cli --all-targets -- -D warnings: cleancargo test -p goose-acp --lib transport: 4 auth tests passacp_auth_rejects_missing_auth_headeracp_auth_rejects_wrong_auth_headeracp_auth_accepts_matching_auth_headeracp_auth_accepts_matching_websocket_subprotocolNot run:
pnpm -C ui/goose2 tsc --noEmit—ui/goose2/node_modulesis not populated in my working copy. Maintainers' CI will validate the TypeScript side; the TS changes are small (one invoke signature, one WebSocket constructor arg).Related Issues
Closes #8601
Security notes for reviewers
OsRng, hex-encoded (64 chars).goose-acp-auth.<hex>. If there's a prior-art pattern in the Block/Goose ecosystem (e.g. named after an RFC pattern), happy to rename.Happy to split Layer 2 (CORS) into a follow-up PR if you'd prefer to review the token gate in isolation.
This contribution was developed with AI assistance (Codex).