feat(serve): --allow-origin <pattern> CORS allowlist (T2.4 #4514)#4527
Conversation
Replace the unconditional `denyBrowserOriginCors` 403-wall with a configurable allowlist when `--allow-origin <pattern>` is set. Each pattern is either `*` (any origin, refuses to boot without a bearer token) or a canonical URL origin validated by round-tripping through `new URL(...).origin`. Matched origins receive standard CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, methods/headers/max-age) plus 204 short-circuit for OPTIONS preflight; unmatched origins keep today's 403 envelope. `Origin: null` is always rejected even under `*`. Conditional capability tag `allow_origin` advertised when the flag is set so SDK/webui clients can pre-flight. When `--allow-origin` is unset the install path is unchanged and today's behavior is preserved bit-for-bit. Loopback self-origin hits are unaffected — the existing demo-page Origin-strip shim runs first. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in CORS allowlist to qwen serve via a new repeatable --allow-origin <pattern> flag, replacing the unconditional browser-Origin 403 “wall” when configured and advertising a new conditional capability tag (allow_origin) so browser clients can feature-detect support.
Changes:
- Add
--allow-originCLI flag + boot-time validation (parseAllowOriginPatterns) with a*wildcard option and guardrails. - Install new
allowOriginCors()middleware when allowlist patterns are configured; preserve existingdenyBrowserOriginCorsbehavior when unset. - Advertise
allow_originconditionally in/capabilities, and add/extend tests and docs for the new behavior.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cli/src/serve/types.ts | Adds allowOrigins to ServeOptions with detailed behavior/compat notes. |
| packages/cli/src/serve/auth.ts | Implements allowlist parsing/error type and new allowOriginCors middleware. |
| packages/cli/src/serve/runQwenServe.ts | Validates allowlist at boot and adds operator breadcrumb/warning. |
| packages/cli/src/serve/server.ts | Switches CORS middleware install path based on allowOrigins; gates capability advertisement. |
| packages/cli/src/serve/capabilities.ts | Registers allow_origin and adds conditional toggle plumbing. |
| packages/cli/src/commands/serve.ts | Adds the --allow-origin CLI option and wires it into serve options. |
| packages/cli/src/serve/auth.test.ts | Adds unit tests for parsing and CORS middleware behavior. |
| packages/cli/src/serve/server.test.ts | Adds integration tests covering CORS allowlist install + capability tag behavior. |
| docs/users/qwen-serve.md | Documents --allow-origin and default CORS posture. |
| docs/developers/qwen-serve-protocol.md | Documents allowlist behavior and capability tag semantics in the protocol reference. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
📋 Review SummaryThis PR implements a configurable CORS allowlist for the Qwen daemon, replacing the unconditional browser-origin rejection with an opt-in allowlist via 🔍 General FeedbackPositive aspects:
Architecture decisions:
🎯 Specific Feedback🟢 Medium
🔵 Low
✅ Highlights
|
|
Thanks — agreed, the docs/help/warning overstate the boot check. The actual gate is |
Copilot review on QwenLM#4527 caught a doc/code mismatch: 5 spots said `*` is "only safe with --require-auth" but the actual boot check refuses `*` only when no bearer token is configured (any source: --token, env, or --require-auth). Update the wording in all 5 spots to match the implementation, and call out the secondary loopback-only caveat that /health and /demo remain pre-auth on loopback unless --require-auth is set — operators with a `*` allowlist on loopback should pair with --require-auth for full hardening. Tightening the code instead would break legitimate `*` + token + loopback dev workflows that want /health to remain reachable for k8s/Compose probes; the actual API surface is gated regardless of --require-auth. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code)
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
✅ Local verification report — PR 4527Tested as the maintainer reviewer ahead of merge. Verified at head Build
Unit tests→ 294 / 294 passed in 13.77s (PR description said 289 — the extra 5 were added in Live daemon smoke — flag set (
|
| # | Case | Expected | Result |
|---|---|---|---|
| T1 | GET /capabilities features list |
allow_origin present |
✅ |
| T2 | Matched origin http://localhost:3000 |
200 + Access-Control-Allow-Origin: http://localhost:3000, Vary: Origin, Methods/Headers/Max-Age/Expose-Headers |
✅ |
| T3 | OPTIONS preflight (Access-Control-Request-Method: POST) |
204 + full CORS headers, no body | ✅ |
| T4 | Unmatched https://evil.example.com |
403 + Vary: Origin, no body leak |
✅ |
| T5 | No Origin header (CLI/SDK) |
200 pass-through | ✅ |
| T6 | Origin: null (sandboxed-iframe attack vector) |
403 with same envelope | ✅ |
| T7 | Case-insensitive match (HTTP://LocalHost:3000) |
200 (RFC 6454 §4) | ✅ |
| T8 | Unmatched 403 body | {"error":"Request denied by CORS policy"} — bit-for-bit match with denyBrowserOriginCors envelope |
✅ |
| T9 | Unmatched path | 0 Access-Control-Allow-Origin headers (no leakage to evil origin) |
✅ |
| T10 | Matched origin reaches downstream | /capabilities body fully delivered (v=1, 42 features incl. allow_origin) |
✅ |
| T11 | Plain OPTIONS (no preflight hdr) | 200 (continues past 204 short-circuit) | ✅ |
| T12 | /demo self-origin shim under --allow-origin |
200 (loopback strip runs before CORS) | ✅ |
Boot-time * + token gate
| # | Case | Expected | Result |
|---|---|---|---|
| T13a | --allow-origin '*', no token |
exit 1, stderr names QWEN_SERVER_TOKEN / --token / specific origins |
✅ exit 1, message identical to source |
| T13b | --allow-origin '*' + QWEN_SERVER_TOKEN set |
boots with (WARNING: \*` admits any cross-origin browser …)` breadcrumb; any origin then admitted |
✅ |
Malformed-pattern boot rejection (all exit 1)
| # | Pattern | Error message names canonical form? |
|---|---|---|
| T14a | http://localhost:3000/ |
✅ "expected the canonical origin http://localhost:3000" |
| T14b | http://localhost:3000/app |
✅ same |
| T14c | localhost:3000 (no scheme) |
✅ canonical resolves to null → rejected |
| T14d | http://user:pass@localhost:3000 |
✅ userinfo stripped in canonical guidance |
serve --help
T15 — --allow-origin advertised with full description including the boot-refuse-on-* invariant and the caps.features.allow_origin pre-flight pointer. ✅
Backward-compat regression (flag unset)
| # | Case | Expected | Result |
|---|---|---|---|
| T16a | caps.features.allow_origin |
absent | ✅ |
| T16b | Any browser Origin (matched or otherwise) | 403 (today's denyBrowserOriginCors) |
✅ |
| T16c | No Origin header | 200 | ✅ |
The "no flag → bit-for-bit prior behavior" claim in the PR description holds end-to-end.
Observations for the merge decision
- Single-middleware ownership of the CORS policy is what's actually shipped —
denyBrowserOriginCorsis replaced, not layered, when--allow-originis set. Confirmed viapackages/cli/src/serve/server.ts:526-539(if (opts.allowOrigins?.length) … else { app.use(denyBrowserOriginCors); }). - Defense in depth on the
*+no-token invariant: the gate runs both at boot inrunQwenServe.ts:374-388and again insidecreateApp(server.ts:528-535) for embedded callers that construct the app directly. T13a observed the boot one; embedded callers cannot bypass it. - No header leakage on reject (T9) is the property that makes the unmatched-403 a true CORS denial, not an over-eager allow + downstream rejection.
- The
Origin: nullrejection (T6) holds even when*is configured — confirms the carve-out for sandboxed-iframe/file:// attack vectors. - Lint warnings on the build are all pre-existing (not introduced by this PR's files).
Verdict
Implementation behaves exactly as the PR description and follow-up review thread describe. Backward-compat regression passes. No surprises in the live daemon. Recommendation: ready to merge once the daemon_mode_b_main integration branch is otherwise green.
Verified locally on Linux 6.12.63 / Node from repo node_modules, worktree at qwen-code-pr4527, tmux session pr4527. Daemon bound to 127.0.0.1:4170, workspace /tmp.
wenshao
left a comment
There was a problem hiding this comment.
R2 review at 6d61549cd: all 5 R1 Suggestions addressed cleanly (Vary: Origin on denyBrowserOriginCors, Access-Control-Expose-Headers, OPTIONS preflight gate, runQwenServe boot refusal test, createServeApp defensive * check). No new high-confidence findings in the 44-line incremental diff. Two low-confidence items for awareness only: (1) Vary: Origin missing on the no-Origin passthrough in allowOriginCors — shared-cache confusion risk behind corporate proxies; (2) no diagnostic logging on CORS rejection — operators have no server-side visibility into rejected origins. CI all_pass 21/21, 294 tests pass. — qwen3.7-max via Qwen Code /review
Squashed feature work from daemon_mode_b_main branch, rebased onto latest main to establish proper merge-base and clean PR diff. Original commits: - perf(core): F2 cleanup PR A — R9/W11/W12/R10 (post-merge follow-ups) (#4411) - refactor(acp-bridge): F1 test split — lift bridge.test.ts (6861 LOC) to acp-bridge (#4445) - fix(core): F2 cleanup PR B — self-heal observability (W133-a + W134) (#4460) - feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353) - docs(serve): v0.16-alpha known limits + SDK QWEN_SERVER_TOKEN env fallback (PR 27) (#4473) - docs(deploy): local launch templates for v0.16-alpha (PR 30a) (#4483) - feat(daemon+sdk): cross-client real-time sync completeness (#4484) - feat(serve): add POST /session/:id/recap (#4504) - feat(daemon): add voterClientId to permission_resolved (A4) (#4539) - feat(serve): --allow-origin <pattern> CORS allowlist (T2.4 #4514) (#4527) - feat(daemon): in-session model switch reaches the bus (A1) (#4546) - feat(serve): prompt absolute deadline + SSE writer idle timeout (#4514 T2.9) (#4530) - Feat/daemon react cli (#4380)
) * feat(serve): --allow-origin <pattern> CORS allowlist (T2.4 #4514) Replace the unconditional `denyBrowserOriginCors` 403-wall with a configurable allowlist when `--allow-origin <pattern>` is set. Each pattern is either `*` (any origin, refuses to boot without a bearer token) or a canonical URL origin validated by round-tripping through `new URL(...).origin`. Matched origins receive standard CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, methods/headers/max-age) plus 204 short-circuit for OPTIONS preflight; unmatched origins keep today's 403 envelope. `Origin: null` is always rejected even under `*`. Conditional capability tag `allow_origin` advertised when the flag is set so SDK/webui clients can pre-flight. When `--allow-origin` is unset the install path is unchanged and today's behavior is preserved bit-for-bit. Loopback self-origin hits are unaffected — the existing demo-page Origin-strip shim runs first. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * docs(serve): align --allow-origin '*' wording with the actual boot gate Copilot review on #4527 caught a doc/code mismatch: 5 spots said `*` is "only safe with --require-auth" but the actual boot check refuses `*` only when no bearer token is configured (any source: --token, env, or --require-auth). Update the wording in all 5 spots to match the implementation, and call out the secondary loopback-only caveat that /health and /demo remain pre-auth on loopback unless --require-auth is set — operators with a `*` allowlist on loopback should pair with --require-auth for full hardening. Tightening the code instead would break legitimate `*` + token + loopback dev workflows that want /health to remain reachable for k8s/Compose probes; the actual API surface is gated regardless of --require-auth. 🤖 Generated with [Qwen Code](https://github.com/QwenLM/qwen-code) * fix(serve): address allow-origin review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Summary
denyBrowserOriginCors403-wall with a configurable allowlist when--allow-origin <pattern>is set, unblocking browser webui clients.allow_originadvertised when the flag is set so SDK / webui clients can pre-flight before issuing a cross-origin request.--allow-originis unset — the install path is unchanged anddenyBrowserOriginCorskeeps its job.What's added
--allow-origin <pattern>(repeatable). Each pattern is*(any origin) or a canonical URL origin (<scheme>://<host>[:<port>]).parseAllowOriginPatternsrejects malformed entries withInvalidAllowOriginPatternErrornaming the bad pattern + canonical form.*+ no token = boot refuses, mirroring the--require-auth + no tokenboot-refusal.allowOriginCors(patterns)installed in place ofdenyBrowserOriginCorswhen patterns are configured. Match → echo origin + standard CORS headers + 204 OPTIONS short-circuit. Unmatched → 403 with the same envelope as today's wall.Origin: nullalways rejected.Vary: Originon both paths.allow_origin: { since: 'v1' }advertised conditionally viaCONDITIONAL_SERVE_FEATURES, gated on a newallowOriginActivetoggle. The configured pattern list is intentionally NOT echoed in/capabilities(would leak the trusted-origin set to unauthenticated readers).Architecture
Single-middleware ownership of CORS policy. Layering the new middleware in addition to
denyBrowserOriginCorsdoes NOT work — a matchednext()hits the existing wall which 403s anyway. The branch at install time:The demo self-origin shim (
server.tsnearcachedSelfOrigins) runs BEFORE either CORS middleware and stripsOriginfor loopback self-hits, so it works regardless of--allow-originconfiguration.OPTIONS preflight short-circuits with 204 — standard CORS pattern. Safe because the actual subsequent request still runs the full chain (
hostAllowlist→bearerAuth→ routes), so anti-DNS-rebinding and bearer enforcement still fire before any state read or mutation.Access-Control-Allow-Originechoes the request's origin verbatim (not literal*, even under the*pattern) — paired withVary: Origin, this is what every browser cache expects, and it leaves room to addAccess-Control-Allow-Credentialsin a future flag without a schema change.Access-Control-Allow-Credentialsis intentionally NOT set today: bearer-token-via-Authorizationworks cross-origin withoutcredentials: 'include'. Adding credentials would need a separate flag plus a "no*allowed" boot check (CORS spec forbids*with credentials).Test plan
packages/cli/src/serve/auth.test.ts(+25 tests):parseAllowOriginPatternshappy + every reject branch (mixed-case host, trailing slash, path, userinfo, non-URL, empty hostname, malformed entry naming);allowOriginCorsno-Origin pass-through, matched + CORS headers, OPTIONS 204, case-insensitive matching, unmatched 403 +Vary: Origin,*echo,*+Origin: nullrejection.packages/cli/src/serve/server.test.ts(+10 route tests): matched origin → 200 + CORS headers; OPTIONS preflight; unmatched origin still 403 with no leaked CORS headers; CLI/SDK clients (no Origin) pass; capability tag advertised on/off;*admits any origin; demo self-origin shim still works under--allow-origin; empty array (allowOrigins: []) regression anchor.allow_originadded toEXPECTED_REGISTERED_FEATURESand theCONDITIONAL_SERVE_FEATURESdrift-insurance test.npm test -w @qwen-code/qwen-code -- src/serve/auth.test.ts src/serve/server.test.ts→ 289 / 289 ✓npm run build→ 0 errorsEnd-to-end smoke (manual, against a freshly built daemon):
Out of scope (follow-ups)
Access-Control-Allow-Credentials: trueand the cookie-auth deployment model — needs a separate flag plus the CORS-spec "no*with credentials" boot check.https://*.example.com) — operators with N subdomains list N origins. Adds parser footguns (e.g.https://*.evil.example.commatchinghttps://attacker.evil.example.com.attacker.com).Docs
docs/developers/qwen-serve-protocol.md— new--allow-originsection in Authentication, plusallow_originrow in the conditional-tags table.docs/users/qwen-serve.md— feature bullet next to the CORS-deny description, plus full--allow-originrow in the CLI flags table including the unsupported-subdomain-wildcard hint and theOrigin: nullalways-rejected note.🤖 Generated with Qwen Code