From e879f620792c13f968f98ebbd5a32ff3dc1edd05 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Tue, 26 May 2026 10:02:32 +0800 Subject: [PATCH 1/3] feat(serve): --allow-origin CORS allowlist (T2.4 #4514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the unconditional `denyBrowserOriginCors` 403-wall with a configurable allowlist when `--allow-origin ` 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: `, `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/developers/qwen-serve-protocol.md | 32 +++- docs/users/qwen-serve.md | 29 +-- packages/cli/src/commands/serve.ts | 21 +++ packages/cli/src/serve/auth.test.ts | 252 ++++++++++++++++++++++++- packages/cli/src/serve/auth.ts | 152 +++++++++++++++ packages/cli/src/serve/capabilities.ts | 23 +++ packages/cli/src/serve/runQwenServe.ts | 40 ++++ packages/cli/src/serve/server.test.ts | 176 +++++++++++++++++ packages/cli/src/serve/server.ts | 22 ++- packages/cli/src/serve/types.ts | 24 +++ 10 files changed, 752 insertions(+), 19 deletions(-) diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index dc5b96066d5..e8812cbf9ac 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -18,6 +18,31 @@ Without a configured token (loopback dev default) the header is optional. Token When the flag is on, the global `bearerAuth` middleware gates **every** route โ€” including `/capabilities`. An **unauthenticated** client therefore cannot pre-flight `caps.features` to discover that auth is required: the discovery surface for that case is the **401 response body** itself (uniform across all routes per the [Authentication](#authentication) section). The `require_auth` capability tag is a **post-authentication confirmation** โ€” once a client successfully authenticates and reads `/capabilities`, the tag's presence confirms the daemon was started with `--require-auth` (useful for audit / compliance UIs and for SDK clients to surface "this deployment is hardened" in a settings panel). Mutation routes that opt into per-route strict mode (Wave 4 follow-ups) refuse with `401 { code: "token_required", error: "โ€ฆ" }` when reached on a no-token loopback default โ€” but with `--require-auth` enabled the global bearer middleware short-circuits the request before the per-route gate, so the legacy `Unauthorized` body is what unauthenticated callers actually see. +**`--allow-origin ` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser webuis hitting the daemon cross-origin are blocked by default โ€” any request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. Pass `--allow-origin ` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either: + +- The literal `*` โ€” admit any origin. **Risky**: only safe when paired with `--require-auth` (so the cross-origin request still has to carry a valid bearer) or behind a trusted reverse proxy on a non-loopback bind. The boot breadcrumb emits a stderr warning when `*` is in the list. +- A canonical URL origin โ€” `://[:]`. **No trailing slash, no path, no userinfo, no query.** Boot refuses with `InvalidAllowOriginPatternError` if the entry fails the round-trip `new URL(pattern).origin === pattern`; the error message names the bad pattern and the canonical form. Strict-by-intent: silent normalization (e.g. trimming a trailing `/`) would let typos slip through and accept ambiguous input. + +Matched origins receive the standard CORS response headers on every request: + +``` +Access-Control-Allow-Origin: +Vary: Origin +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +Access-Control-Allow-Headers: Authorization, Content-Type, X-Qwen-Client-Id, Last-Event-ID +Access-Control-Max-Age: 86400 +``` + +`Access-Control-Allow-Origin` echoes the request's origin verbatim (lowercase / uppercase as the browser sent it) rather than the literal `*`, even under the `*` pattern โ€” browser caches key responses on it paired with `Vary: Origin`, and echoing leaves room to add `Access-Control-Allow-Credentials` in a later release without a schema change. `Access-Control-Allow-Credentials` is **NOT** sent today: the daemon authenticates via bearer-in-`Authorization`, which works cross-origin without `credentials: 'include'`. + +OPTIONS preflight requests short-circuit with `204 No Content` plus the headers above. This is the conventional CORS pattern and is safe โ€” the preflight only confirms which methods/headers the daemon will accept; the actual subsequent request still runs the full chain (host allowlist โ†’ bearer auth โ†’ routes), so anti-DNS-rebinding and bearer enforcement still fire before any state is read or mutated. + +Origins that don't match the allowlist still get `403 {"error":"Request denied by CORS policy"}` โ€” same envelope as the default wall, so clients that already parsed the wall's response don't have to special-case allowlist-deployed daemons. The reject path **does not** emit any `Access-Control-*` headers (the browser would ignore them, and emitting would indirectly advertise the allowlist size through header presence). + +The configured pattern list is intentionally NOT echoed in `/capabilities` โ€” browser webui already knows its own origin (it called the daemon, after all), and surfacing the list would let an unauthenticated reader of `/capabilities` enumerate every trusted origin (useful recon for a misconfigured deployment). SDK clients gate on the `caps.features.allow_origin` tag for "this daemon honors cross-origin browser hits" without needing to know which specific origins. + +Loopback self-origin requests (e.g. the `/demo` page calling the daemon at the same `127.0.0.1:port`) are handled by a **separate** Origin-strip shim that runs BEFORE the CORS middleware and removes the `Origin` header for `127.0.0.1:port` / `localhost:port` / `[::1]:port` / `host.docker.internal:port`. So they pass through regardless of `--allow-origin` configuration โ€” operators don't need to list the daemon's own port to make the demo page work. + ## Common error shape 5xx responses carry the original error's `code` and `data` when present (JSON-RPC style โ€” the ACP SDK forwards `{code, message, data}` from the agent): @@ -144,9 +169,10 @@ routes and require a configured bearer token even on loopback. **Conditional tags.** A small number of feature tags are advertised only when the matching deployment toggle is on. Tag presence = behavior is on; absence = either an older daemon predating the tag, OR a current daemon where the operator did not opt in. Currently: -| Tag | Advertised when โ€ฆ | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| Tag | Advertised when โ€ฆ | +| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `require_auth` | the daemon was started with `--require-auth` (or `requireAuth: true` via the embedded API). Bearer token is mandatory on every route, including `/health` on loopback binds. | +| `allow_origin` | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). The daemon was started with at least one `--allow-origin ` (or `allowOrigins: [...]` via the embedded API). Cross-origin requests from matched origins receive proper CORS response headers; unmatched origins still get the default 403. The configured pattern list is intentionally NOT echoed in `/capabilities` to avoid leaking the trusted-origin set to unauthenticated readers โ€” browser webui already knows its own origin. | `mcp_guardrails` is **not** in this conditional table โ€” it's an always-on tag, advertised whenever the binary supports the new `/workspace/mcp` budget fields, regardless of whether the operator configured a budget. Operators who haven't set `--mcp-client-budget` still get the new fields (with `budgetMode: 'off'`, `budgets: []`). diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 0bc91349302..a074ac9b9b6 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -198,19 +198,20 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 ## CLI flags -| Flag | Default | Purpose | -| ------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--port ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | -| `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | -| `--token ` | โ€” | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped โ€” handy for `$(cat token.txt)`). | -| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. | -| `--max-sessions ` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30โ€“50 MB per session). | -| `--workspace ` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) ยง02 โ€” 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. | -| `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count โ€” slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | -| `--event-ring-size ` | `8000` | Per-session SSE replay ring depth (#3803 ยง02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. | -| `--mcp-client-budget ` | โ€” | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. | -| `--mcp-budget-mode ` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at โ‰ฅ75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. | -| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) ยง02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | +| Flag | Default | Purpose | +| ------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--port ` | `4170` | TCP port. `0` = OS-assigned ephemeral port. | +| `--hostname ` | `127.0.0.1` | Bind interface. Anything beyond loopback requires a token. | +| `--token ` | โ€” | Bearer token. Falls back to `QWEN_SERVER_TOKEN` env var (with leading/trailing whitespace stripped โ€” handy for `$(cat token.txt)`). | +| `--require-auth` | `false` | Refuse to start without a bearer token, even on loopback. Hardens the `127.0.0.1` developer default for shared dev hosts / CI runners / multi-tenant workstations where any local user can hit the listener. Boots only with `--token` or `QWEN_SERVER_TOKEN` set; gates `/health` behind the bearer too. | +| `--max-sessions ` | `20` | Cap on concurrent live sessions. New `POST /session` requests that would spawn a fresh child return `503` (with `Retry-After: 5`) when the cap is hit; attaches to existing sessions are NOT counted. Set to `0` to disable. Sized for single-user / small-team usage; raise it if your deployment has the RAM/FD headroom (~30โ€“50 MB per session). | +| `--workspace ` | `process.cwd()` | Absolute workspace path this daemon binds to (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) ยง02 โ€” 1 daemon = 1 workspace). `POST /session` requests with a mismatched `cwd` return `400 workspace_mismatch`. For multi-workspace deployments, run one `qwen serve` per workspace on separate ports. | +| `--max-connections ` | `256` | Listener-level TCP connection cap (`server.maxConnections`). Bounds raw socket count irrespective of session count โ€” slow / phantom SSE clients get rejected at accept time once full. Raise alongside `--max-sessions` if your deployment expects many SSE subscribers per session. | +| `--event-ring-size ` | `8000` | Per-session SSE replay ring depth (#3803 ยง02 target). Sets the backlog available to `GET /session/:id/events` with `Last-Event-ID: N`. Larger = more reconnect headroom at the cost of a few hundred KB extra RAM per session. SDK clients can additionally request a larger per-subscriber backlog cap on a specific subscription via `?maxQueued=N` (range `[16, 2048]`, default 256). Daemons also emit a non-terminal `slow_client_warning` SSE frame at 75% queue fill so clients can drain / reconnect before getting evicted. Pre-flight `caps.features.slow_client_warning`. | +| `--mcp-client-budget ` | โ€” | Positive integer cap on live MCP clients **per ACP session** (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14 v1; PR 23 graduates this to per-workspace via the shared MCP pool). Combine with `--mcp-budget-mode`. When unset, no accounting-driven enforcement (but `GET /workspace/mcp` still reports `clientCount`). Distinct from claude-code's `MCP_SERVER_CONNECTION_BATCH_SIZE` which gates startup concurrency, not the total client count. Pre-flight `caps.features.mcp_guardrails`. | +| `--mcp-budget-mode ` | `warn` / `off` | How `--mcp-client-budget` is enforced. `warn` (default when budget set): no refusal, snapshot's `budgets[0].status` flips to `warning` at โ‰ฅ75% of budget. `enforce`: connects past the cap are refused, per-server cell shows `disabledReason: 'budget'`, deterministic by `mcpServers` declaration order. `off` (default when budget unset): pure observability. Boot rejects `enforce` without a budget. | +| `--http-bridge` | `true` | Stage 1 mode: one `qwen --acp` child per daemon (bound to one workspace at boot, per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) ยง02); N sessions multiplex onto that child via ACP `newSession()`. Stage 2 native in-process becomes available later. | +| `--allow-origin ` | โ€” | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser webui clients. Repeatable. Each value is `*` (any origin โ€” boot refuses if no bearer token is configured) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo / query). **Subdomain wildcards (`https://*.example.com`) are intentionally unsupported** โ€” list each subdomain explicitly, or use `*` paired with `--require-auth`. Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. | > **Sizing the load knobs.** `--max-sessions` is the **new-child** cap. > Three other layers also limit load โ€” when sizing for a high-concurrency @@ -250,7 +251,7 @@ The token comparison is constant-time (SHA-256 + `crypto.timingSafeEqual`); 401 - **`--hostname 0.0.0.0` requires a token** โ€” boot refuses without one. - **`LOOPBACK_BINDS` includes IPv6** โ€” `::1` and `[::1]` count as loopback for the no-token rule. - **Host header allowlist** โ€” on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` (case-insensitive per RFC 7230 ยง5.4) to defend against DNS rebinding. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** โ€” the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy. -- **CORS denies any browser Origin** โ€” returns `403` JSON. **Implication for browser-served webuis** (BUy4e): any `packages/webui`-style frontend that lives on a separate origin will get 403 at the wire. Stage 1 options for browser-style consumption: (a) package the webui as a native shell (Electron/Tauri) so no `Origin` header is sent, or (b) front the daemon with a same-origin reverse proxy that strips/rewrites `Origin` for a known frontend. Stage 1.5 will add `--allow-origin ` for opt-in named frontends. +- **CORS denies any browser Origin by default** โ€” returns `403` JSON. Pass **`--allow-origin `** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin โ€” only safe paired with `--require-auth`) or a canonical URL origin (`://[:]`, no trailing slash / path / userinfo). Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: `, `Vary: Origin`, plus standard methods / headers / max-age); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the `/demo` page) are unaffected โ€” a separate Origin-strip shim handles them regardless of `--allow-origin`. **Browser webuis without `--allow-origin` configured** still fall back to the same Stage 1 options as before: package as a native shell (Electron/Tauri) so no `Origin` header is sent, or front the daemon with a same-origin reverse proxy. - **Spawned `qwen --acp` child inherits the daemon's environment** with one explicit scrub: `QWEN_SERVER_TOKEN` is removed before the child starts (the daemon's own bearer; the agent doesn't need it). Everything else โ€” `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `QWEN_*` / `DASHSCOPE_API_KEY` / your custom `modelProviders[].envKey` / etc. โ€” passes through, because the agent legitimately needs those to authenticate to the LLM. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc` / `~/.aws/credentials` / `~/.npmrc` is reachable by prompt injection regardless. The env passthrough is not the security boundary; the user-as-trust-root is. Don't run `qwen serve` under an identity that has env-resident credentials you wouldn't trust the agent with. - **Per-subscriber bounded SSE queues** โ€” a slow client that overflows its queue gets a `client_evicted` terminal frame and is closed; one stuck consumer can't pin the daemon. - **Graceful shutdown** โ€” SIGINT/SIGTERM drain the agent children before closing the listener (10s deadline per child). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index dae40a9285f..39c7741112d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -41,6 +41,7 @@ interface ServeArgs { 'http-bridge': boolean; 'mcp-client-budget'?: number; 'mcp-budget-mode'?: 'enforce' | 'warn' | 'off'; + 'allow-origin'?: string[]; } export const serveCommand: CommandModule = { @@ -144,6 +145,20 @@ export const serveCommand: CommandModule = { 'refused (`disabledReason: "budget"`, deterministic by mcpServers ' + 'declaration order). `off`: pure observability. Boot rejects ' + '`enforce` without a budget.', + }) + .option('allow-origin', { + type: 'string', + array: true, + description: + 'T2.4 (#4514). Cross-origin allowlist for browser webui clients. ' + + 'Repeatable; each value must be a canonical URL origin ' + + '(`://[:]`, no trailing slash) or `*` for any ' + + 'origin (loud warning, only safe with --require-auth). When unset, ' + + 'the daemon rejects every request carrying an `Origin` header with ' + + "403 (today's behavior). Matched origins receive proper CORS " + + 'response headers; unmatched still 403. Example: `--allow-origin ' + + 'http://localhost:3000 --allow-origin http://localhost:5173`. ' + + 'Pre-flight via `caps.features.allow_origin`.', }) as unknown as Argv, handler: async (argv) => { if (!argv['http-bridge']) { @@ -219,6 +234,12 @@ export const serveCommand: CommandModule = { requireAuth: argv['require-auth'], mcpClientBudget, mcpBudgetMode: resolvedMcpMode, + // T2.4 (#4514). Pass through verbatim; runQwenServe re-runs + // `parseAllowOriginPatterns` at boot so a malformed entry is + // rejected before the listener binds. + ...(argv['allow-origin'] && argv['allow-origin'].length > 0 + ? { allowOrigins: argv['allow-origin'] } + : {}), }); } catch (err) { writeStderrLine( diff --git a/packages/cli/src/serve/auth.test.ts b/packages/cli/src/serve/auth.test.ts index f83aa92578d..3890b907b85 100644 --- a/packages/cli/src/serve/auth.test.ts +++ b/packages/cli/src/serve/auth.test.ts @@ -6,7 +6,12 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; import { describe, expect, it } from 'vitest'; -import { createMutationGate } from './auth.js'; +import { + allowOriginCors, + createMutationGate, + InvalidAllowOriginPatternError, + parseAllowOriginPatterns, +} from './auth.js'; interface GateResult { status?: number; @@ -141,3 +146,248 @@ describe('createMutationGate (#4175 PR 15)', () => { expect(passA).not.toBe(strictA); }); }); + +interface AllowOriginResult { + status?: number; + body?: unknown; + headers: Map; + nextCalled: boolean; + ended: boolean; +} + +function invokeAllowOrigin( + handler: RequestHandler, + req: { + method?: string; + headers?: Record; + } = {}, +): AllowOriginResult { + let status: number | undefined; + let body: unknown; + let nextCalled = false; + let ended = false; + const headers = new Map(); + const response = {} as Response; + response.status = ((code: number): Response => { + status = code; + return response; + }) as Response['status']; + response.json = ((payload: unknown): Response => { + body = payload; + return response; + }) as Response['json']; + response.setHeader = ((name: string, value: string | number): Response => { + headers.set(name.toLowerCase(), String(value)); + return response; + }) as Response['setHeader']; + response.end = ((): Response => { + ended = true; + return response; + }) as Response['end']; + const next: NextFunction = () => { + nextCalled = true; + }; + handler( + { + method: req.method ?? 'GET', + headers: req.headers ?? {}, + } as unknown as Request, + response, + next, + ); + return { status, body, headers, nextCalled, ended }; +} + +describe('parseAllowOriginPatterns (T2.4 #4514)', () => { + it('parses an empty list to an empty allowlist with no wildcard', () => { + const out = parseAllowOriginPatterns([]); + expect(out.allowAny).toBe(false); + expect(out.origins.size).toBe(0); + }); + + it('rejects mixed-case host in the input (URL.origin normalizes, so the round-trip fails)', () => { + // Documents the strict-by-intent rejection: operators must write + // the canonical (lowercased) origin. Auto-normalizing would + // silently accept ambiguous input โ€” explicit failure is clearer. + expect(() => parseAllowOriginPatterns(['http://Localhost:3000'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('accepts a clean canonical origin and stores it lowercased', () => { + const out = parseAllowOriginPatterns(['http://localhost:3000']); + expect(out.allowAny).toBe(false); + expect(out.origins.has('http://localhost:3000')).toBe(true); + }); + + it('accepts the `*` literal and sets allowAny', () => { + const out = parseAllowOriginPatterns(['*']); + expect(out.allowAny).toBe(true); + expect(out.origins.size).toBe(0); + }); + + it('accepts a mix of `*` and concrete origins', () => { + const out = parseAllowOriginPatterns(['*', 'https://app.example.com']); + expect(out.allowAny).toBe(true); + expect(out.origins.has('https://app.example.com')).toBe(true); + }); + + it('rejects trailing slash โ€” operators must write the canonical origin', () => { + expect(() => parseAllowOriginPatterns(['http://localhost:3000/'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('rejects path components โ€” origins do not carry paths', () => { + expect(() => + parseAllowOriginPatterns(['https://app.example.com/foo']), + ).toThrow(InvalidAllowOriginPatternError); + }); + + it('rejects userinfo โ€” leaks credentials in capability metadata', () => { + expect(() => + parseAllowOriginPatterns(['http://user:pass@example.com']), + ).toThrow(InvalidAllowOriginPatternError); + }); + + it('rejects values that are not parseable URLs', () => { + expect(() => parseAllowOriginPatterns(['not-a-url'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('rejects URLs with empty hostname (http://:3000)', () => { + // Defensive lock against a future Node URL-parser change that + // accepts the no-host form. Today it throws `Invalid URL`, which + // the parser-error branch in `parseAllowOriginPatterns` catches. + expect(() => parseAllowOriginPatterns(['http://:3000'])).toThrow( + InvalidAllowOriginPatternError, + ); + }); + + it('throws on the first malformed entry, naming it for the operator', () => { + try { + parseAllowOriginPatterns(['http://localhost:3000', 'http://broken/']); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidAllowOriginPatternError); + const e = err as InvalidAllowOriginPatternError; + expect(e.pattern).toBe('http://broken/'); + expect(e.message).toContain('http://broken/'); + } + }); +}); + +describe('allowOriginCors (T2.4 #4514)', () => { + const middleware = allowOriginCors( + parseAllowOriginPatterns(['http://localhost:3000']), + ); + const wildcardMiddleware = allowOriginCors(parseAllowOriginPatterns(['*'])); + + it('passes through requests with no Origin header (CLI / SDK callers)', () => { + const res = invokeAllowOrigin(middleware, {}); + expect(res.nextCalled).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.headers.size).toBe(0); + }); + + it('matches an allowlisted origin, sets CORS headers, and calls next()', () => { + const res = invokeAllowOrigin(middleware, { + method: 'GET', + headers: { origin: 'http://localhost:3000' }, + }); + expect(res.nextCalled).toBe(true); + expect(res.status).toBeUndefined(); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'http://localhost:3000', + ); + expect(res.headers.get('vary')).toBe('Origin'); + expect(res.headers.get('access-control-allow-methods')).toMatch(/GET/); + expect(res.headers.get('access-control-allow-headers')).toMatch( + /Authorization/, + ); + expect(res.headers.get('access-control-max-age')).toBe('86400'); + }); + + it('short-circuits OPTIONS preflight with 204 + CORS headers (no chain continuation)', () => { + const res = invokeAllowOrigin(middleware, { + method: 'OPTIONS', + headers: { origin: 'http://localhost:3000' }, + }); + expect(res.nextCalled).toBe(false); + expect(res.ended).toBe(true); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'http://localhost:3000', + ); + }); + + it('matches case-insensitively on scheme/host (RFC 6454 ยง4)', () => { + const res = invokeAllowOrigin(middleware, { + method: 'GET', + headers: { origin: 'HTTP://LOCALHOST:3000' }, + }); + expect(res.nextCalled).toBe(true); + // Echo the request's origin verbatim โ€” browser caches use it as a + // key paired with `Vary: Origin`, so we must echo the exact value + // the client sent, not a normalized form. + expect(res.headers.get('access-control-allow-origin')).toBe( + 'HTTP://LOCALHOST:3000', + ); + }); + + it('rejects unmatched origins with the same 403 envelope as denyBrowserOriginCors', () => { + const res = invokeAllowOrigin(middleware, { + method: 'POST', + headers: { origin: 'https://evil.example.com' }, + }); + expect(res.nextCalled).toBe(false); + expect(res.status).toBe(403); + expect((res.body as { error?: string }).error).toBe( + 'Request denied by CORS policy', + ); + // No CORS response headers leak on the reject path โ€” the browser + // would have nothing to do with them anyway (it's about to block + // the response), but emitting them would advertise the allowlist + // size indirectly through header presence. + expect(res.headers.has('access-control-allow-origin')).toBe(false); + }); + + it('`*` admits any origin and echoes the request value', () => { + const res = invokeAllowOrigin(wildcardMiddleware, { + method: 'GET', + headers: { origin: 'https://anywhere.example.com' }, + }); + expect(res.nextCalled).toBe(true); + expect(res.headers.get('access-control-allow-origin')).toBe( + 'https://anywhere.example.com', + ); + }); + + it('`Origin: null` (sandboxed iframes, file:// docs) is rejected even under `*`', () => { + // Defense against a sandboxed-iframe attack: a malicious page can + // spawn an `