qwen-code/docs/developers/daemon/12-auth-security.md
BaboBen 9a64c0a963
feat(serve): add pollable daemon turn status (#9080)
* feat(serve): add pollable turn-status endpoints for daemon sessions

Add GET /session/:id/turns/current and GET /session/:id/turns/:promptId
so external callers can poll a turn's lifecycle state (queued / running /
completed / cancelled / error) and result instead of holding the SSE
stream for the whole turn lifetime.

- Live state comes from the bridge's pending prompt queue; settled
  outcomes from persisted turn_result transcript records, so results
  survive daemon restarts and the daemon keeps no per-turn memory
- Each prompt captures its own recording and settles exactly that one,
  so overlapping turns (DAEMON-003 deadline overlap) can never
  misattribute one turn's outcome to another promptId
- Enforces the same client authorization as POST /session/:id/prompt

Refs #8680

* test(serve): update telemetry route count

* fix(serve): prefer settled turn outcome over deadline error overlay

When the prompt-deadline path latches an error terminal in the overlay and the child later settles and persists a non-error turn_result for the same promptId, the poll surface previously kept the overlay error while enriching it with the successful resultText, and flipped to completed only after overlay eviction or restart. Merge via mergeTerminalWithPersisted at the two enrich call sites so the persisted outcome supersedes a bridge-synthesized error terminal once it exists; the exactly-once turn_error event publication and FIFO release are unchanged. The different-promptId endedAt tie-break is intentionally untouched.

* fix(serve): pin turn-start session identity for turn-result settle

R4-1: settle resolved the ChatRecordingService at settle time, so a startNewSession rotation mid-turn could land the turn_result record in the new session's transcript while the poll surface kept reading the old one. Capture the recorder at turn start and settle on that instance; pin the outgoing service's session identity at rotation so the late append keeps the pre-rotation sessionId.

R4-2: reject empty error.message/error.code in turn_result payloads, mirroring the existing empty-promptId rejection.

Adds rotation/pin regression tests plus the round-4 test suggestions (extractor fallback, startedAt, cancel/error race matrix, resultCode defaulting, removed-prompt projections).

* fix(serve): enforce the turn_result bounded contract on the write path

R3-3: cap promptId, stopReason, and originatorClientId at 256 chars in isTurnResultRecordPayload, closing the unbounded echo of corrupted-transcript values through GET /session/:id/turns/:promptId; recordTurnResult now validates payloads against the same contract before appending, so type-correct but invalid shapes (error state without error, error on non-error states) can no longer produce records invisible to the restart scan.

Also lands the four round-5 test assertions: merged-payload error-leak pin, multi-model-call settle count, successor attribution in the superseded-throws test, and the early session-mismatch guard pin.

* fix(serve): address round-6 review findings on daemon turn status

- Session: settle a successor-aborted turn as cancelled only when the
  thrown error is the abort itself; genuine failures after a NEW_PROMPT
  abort surface as error, matching the send-loop contract
- bridge: serve repeat polls of a settled promptId from the enriched
  overlay instead of re-scanning the child transcript, and give the
  turn-status read the transcript timeout instead of the 10s init default
- bridge: forward the channel display text unchanged; Session treats an
  empty display text as absent for the turn record ([image] fallback)
- Session: cap streamed-response accumulation for turns without a
  channel delivery at the turn-result bound
- docs: document the bounded non-monotonicity of poll terminals

* fix(serve): guard turn-status reads against rewind races and keep the trusted prompt projection

A successful rewind that completes while a getSessionTurnStatus child
transcript scan is in flight could let the pre-rewind record be cached
into the freshly cleared overlay and served forever. Track a per-session
rewind generation captured before the scan and discard the scanned
outcome when it moved.

enrichTerminalTurnStatus and the deadline-supersede merge returned the
child-recorded promptText ahead of the bridge's trusted display
projection, leaking hidden channel context on the poll surface. Make
promptText/promptTextTruncated backfill-only and keep the terminal's
projection in the supersede path. Make the pinning test adversarial and
correct a false comment about the child's ''-as-absent fallback.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qqqys <qys177@gmail.com>
2026-08-18 06:35:17 +00:00

20 KiB
Raw Blame History

Auth & Security Model

Overview

qwen serve is a local daemon by default and an exposed surface in the wrong configuration. Its security model is layered so that misconfiguration fails closed:

  1. Bind — non-loopback bind without a bearer token refuses to start.
  2. Bearer authbearerAuth middleware with constant-time SHA-256 compare protects every route except /health on loopback (require_auth extends this to loopback and /health too).
  3. Host header allowlist — on loopback, only localhost, 127.0.0.1, [::1], host.docker.internal (plus port) are accepted; defense against DNS rebinding. The Local Control LAN listener is the exception that always enforces its advertised-authority Host check, whatever the primary bind is.
  4. Origin control — the runtime app always installs allowOriginCors over a mutable allowlist (MutableOriginAllowlist): the --allow-origin <pattern> entries seed it, and Local Control adds the LAN origin while enabled. Non-matching origins receive the 403 deny envelope. The unconditional deny wall (denyBrowserOriginCors) survives only in the bootstrap app that answers before the runtime starts.
  5. Per-route mutation gate — Wave 4 mutating routes can opt in to 401 responses even on loopback when no token is configured, using a distinct code: 'token_required' error.
  6. Device-flow auth — separate OAuth surface for providers (POST /workspace/auth/device-flow + GET/DELETE on /:id).

This doc walks through each layer and the explicit invariants the boot path enforces.

Responsibilities

  • Refuse to boot in unsafe configurations.
  • Gate every HTTP request through bearer (when configured) + host (loopback) + origin checks.
  • Provide a per-route mutation gate Wave 4 routes opt into.
  • Host the device-flow registry that drives provider OAuth flows visible via SSE events.

Architecture

Boot-time refuse rules

In run-qwen-serve.ts:

if (!isLoopbackBind(opts.hostname) && !token) {
  throw new Error('Refusing to bind <host>:<port> without a bearer token. ...');
}
if (opts.requireAuth && !token) {
  throw new Error(
    'Refusing to start with --require-auth set but no bearer token configured. ...',
  );
}

The allow-origin wildcard has its own refuse rule:

const parsed = parseAllowOriginPatterns(opts.allowOrigins);
if (parsed.allowAny && !token) {
  throw new Error(
    "Refusing to start with --allow-origin '*' but no bearer token configured. ...",
  );
}

All three refusals are explicit boot failures (visible in stderr / thrown to the embedder), never silent. The threat model from #3803 explicitly forbids silently letting a daemon bind beyond loopback in the open.

Middleware chain (HTTP request order)

flowchart LR
    REQ[Request] --> SO["strip same-origin Origin<br/>(Web Shell support)"]
    SO --> AO["allowOriginCors<br/>(mutable allowlist: --allow-origin<br/>patterns + Local Control LAN origin)"]
    AO --> HA["hostAllowlist"]
    HA --> LOG["access-log middleware<br/>(DaemonLogger)"]
    LOG --> BA["bearerAuth"]
    BA --> RL["rate-limit middleware<br/>(when enabled)"]
    RL --> JSON["express.json<br/>(body parser)"]
    JSON --> TEL["daemonTelemetryMiddleware<br/>(OTel span)"]
    TEL --> MG["per-route: mutationGate<br/>(opt-in strict)"]
    MG --> HANDLER["route handler"]

mutationGate is a per-route middleware factory (createMutationGate returns mutate()); routes call mutate() or mutate({strict: true}) at registration time. It is not a global app.use() middleware. Access logging is registered before bearerAuth so 401 rejects are still logged. Rate limiting runs after bearerAuth and before express.json(), so only authenticated requests count and large bodies are rejected before parsing when a limit is exceeded.

bearerAuth

  • No token configured → middleware is a no-op (loopback developer default). Exception: the Local Control LAN listener is listener-scoped and always requires its pairing credential (CredentialStore.isOpen is never true for local-control), so it is never open even on a token-less daemon.
  • Token configured → SHA-256 the configured token once at construction; on every request hash the candidate and timingSafeEqual compare. No string-equality short-circuit; no time-leak.
  • Scheme parsing: case-insensitive Bearer per RFC 7235 §2.1; tolerant of SP\tHTAB between scheme and credentials per RFC 7230 §3.2.6 BWS; rejects pure-HTAB-as-separator.
  • CodeQL hardening: hand-rolled indexOf parsing rather than regex with \s+ / .+ overlap (no polynomial-regex risk).

hostAllowlist

Loopback-only. Maintains a Set<string> keyed by port. Allowed Hosts:

  • localhost:<port>, 127.0.0.1:<port>, [::1]:<port>, host.docker.internal:<port>.
  • Plus no-port forms (localhost, 127.0.0.1, [::1], host.docker.internal) only when bound to port 80 (per RFC 7230 §5.4 default-port omission).

Host comparison is case-insensitive — Express normalizes header names but not values, so Docker proxies that capitalize Hosts (Localhost:4170, HOST.docker.internal) would 403 with an exact-string compare.

Non-loopback binds bypass the primary gate (operator chose the surface area; bearer token gates Host spoofing instead). The Local Control LAN listener is the exception: it always enforces its advertised-authority Host check, whatever the primary bind is.

denyBrowserOriginCors (bootstrap app only)

Reject any request with an Origin header. CLI/SDK never set Origin; only browsers do. Returns deterministic 403 { error: 'Request denied by CORS policy' } rather than the 500 HTML the cors package's error-callback would produce. The runtime app no longer installs this wall — it runs allowOriginCors over the mutable allowlist (below); the deny behavior survives there as the unmatched-origin branch. The wall remains in the bootstrap app (run-qwen-serve.ts) that serves requests before the runtime starts.

Exception: the Web Shell's same-origin XHRs on a loopback bind are handled by a separate middleware (in server/self-origin.ts) that strips Origin when it matches one of the loopback self-origins (127.0.0.1, localhost, [::1], host.docker.internal). On non-loopback binds the shell's XHRs carry an unmatched Origin and need --allow-origin for the daemon origin.

allowOriginCors (runtime app, always installed)

The runtime app installs allowOriginCors(originAllowlist) unconditionally; the allowlist is a MutableOriginAllowlist seeded from the --allow-origin <pattern> entries (possibly none) and extended at runtime while Local Control is enabled (the LAN origin is added/removed with the listener):

  • Matching Origin values receive Access-Control-Allow-Origin, Access-Control-Allow-Headers, and Access-Control-Allow-Methods; OPTIONS preflight returns 204.
  • Non-matching Origin values receive the same deterministic 403 { error: 'Request denied by CORS policy' } as deny mode.
  • --allow-origin '*' requires --token; otherwise boot refuses.
  • parseAllowOriginPatterns() validates pattern syntax at boot.
  • The allow_origin capability tag is advertised only when this mode is configured.

createMutationGate

Per-route opt-in gate. Behavior matrix:

daemon config route opts result
requireAuth=true any passthrough¹
token configured any passthrough²
no token (loopback dev) strict: false passthrough
no token (loopback dev) strict: true, unauthenticated 401 { code: 'token_required' }
no token (loopback dev) strict: true, authenticated³ passthrough

¹ --require-auth boots only with a token, so global bearerAuth already 401'd unauthenticated callers. ² Any token configuration makes global bearerAuth enforce bearer-required-everywhere; the gate is redundant but harmless. ³ Authenticated via a listener-scoped credential: the Local Control LAN listener verifies its pairing credential even on a token-less daemon and stamps the request as authenticated, so strict routes pass for the paired LAN client.

The code: 'token_required' shape is distinct from bearerAuth's plain Unauthorized so SDK clients can render a "configure --token / --require-auth" hint instead of a generic 401.

Wave 4+ strict routes: /workspace/memory, /workspace/agents/*, /workspace/agents/generate, /file/write, /file/edit, /workspace/tools/:name/enable, /workspace/mcp/:server/restart, /workspace/mcp/:server/{enable,disable,authenticate,clear-auth}, /workspace/mcp/servers (POST/DELETE), /workspace/auth/device-flow, /workspace/init, /session/:id/approval-mode, /session/:id/rewind, and /session/:id/shell.

Rewind remains REST-only in the TypeScript SDK even when an ACP transport is configured. This preserves the strict mutation gate and bearer/client identity headers; the ACP route table intentionally has no rewind mapping. Owner routing also rechecks workspace trust before either rewind or shell reaches a secondary runtime bridge. Duplicate live session ids fail closed as ambiguous_session_owner instead of falling back to the primary runtime.

/health exemption

On loopback binds, /health is registered before the bearer middleware so liveness probes inside the pod do not need to carry the token. Non-loopback binds gate /health behind bearer like every other route. --require-auth drops the exemption: /health requires Authorization: Bearer <token> on loopback too.

v1 client identity (X-Qwen-Client-Id) is self-reported

The daemon validates only the format of X-Qwen-Client-Id ([A-Za-z0-9._:-]{1,128}) and tracks attached client ids per session. It does not currently perform proof-of-possession. A client that observes originatorClientId on SSE can re-register the same id and impersonate that originator in later requests.

Impact:

  • designated — a remote caller can impersonate the originator and vote on a request intended only for the prompt originator.
  • consensus — if the spoofed id was already in the votersAtIssue snapshot, it can vote.
  • local-only is not affected because it gates on fromLoopback, which the daemon stamps from the connection remote address.
  • first-responder is not affected because it is identity-agnostic.

A future pair-token mechanism will issue a per-session secret from POST /session; designated / consensus votes will have to present it. Until then, deployments that need a hardened designated policy should bind loopback or run behind an authenticated reverse proxy. See 04-permission-mediation.md for policy-level details.

Device-flow auth

Separate OAuth surface for provider authentication. The v1 provider identifier is qwen-oauth, but Qwen OAuth free tier was discontinued on 2026-04-15; new setups should use a currently supported auth provider when one is available.

  • POST /workspace/auth/device-flow — start a flow; returns {deviceFlowId, providerId, expiresAt, verificationUrl, userCode}.
  • GET /workspace/auth/device-flow/:id — poll state.
  • DELETE /workspace/auth/device-flow/:id — cancel.
  • GET /workspace/auth/status — current account / provider snapshot.

SSE events auth_device_flow_{started, throttled, authorized, failed, cancelled} fan-out flow state to all subscribers so multi-client UIs stay in sync. See 09-event-schema.md.

Implementation: packages/cli/src/serve/auth/device-flow.ts + qwen-device-flow-provider.ts.

Log injection / Trojan Source defense: sanitizeForStderr(value) (device-flow.ts) replaces ASCII control characters and Unicode control characters with ?. A malicious IdP could otherwise forge log lines or hide payloads:

Range Why it is stripped
\x00\x1f, \x7f, \x80\x9f ASCII C0 / DEL / C1 controls, terminal escapes, and log-line forging.
U+200B-U+200F Zero-width characters plus LRM / RLM; invisible but can change terminal rendering.
U+2028-U+2029 LINE / PARAGRAPH SEPARATOR; many Unicode-aware terminals treat them as line breaks.
U+202A-U+202E Bidirectional EMBEDDING / OVERRIDE controls.
U+2066-U+2069 Bidirectional ISOLATE controls (LRI / RLI / FSI / PDI), the main CVE-2021-42574 "Trojan Source" vector. An IdP using U+2066 (LRI) instead of U+202D (LRO) can bypass EMBEDDING/OVERRIDE-only filters with similar visual reordering.
U+FEFF BOM / zero-width no-break space.

Length is preserved by replacing each stripped code point with ? rather than deleting it, so operators can still see that something was present at that index. Both layers use the sanitizer: qwenDeviceFlowProvider sanitizes IdP oauthError, and the registry's late-poll observer sanitizes provider-controlled values interpolated into audit hints (latePollResult.kind / lateErr.name).

The auth_device_flow capability tag is advertised unconditionally; the routes themselves return 400 unsupported_provider if the daemon cannot satisfy a specific provider. The supported-providers list is on /workspace/auth/status rather than /capabilities to keep the descriptor shape uniform.

Workflow

Bearer auth successful request

sequenceDiagram
    autonumber
    participant C as Client
    participant BA as bearerAuth
    participant R as Route

    C->>BA: Authorization: Bearer abc...
    BA->>BA: parse scheme (case-insensitive), strip BWS
    BA->>BA: SHA-256(candidate)
    BA->>BA: timingSafeEqual(candidate, expected)
    BA->>R: next()
    R-->>C: 200 ...

Bearer auth failure modes

All return 401 { error: 'Unauthorized' } (uniform across missing header / wrong scheme / wrong token so probing cannot distinguish).

--require-auth shadow

sequenceDiagram
    autonumber
    participant C as Unauth client
    participant CAPS as GET /capabilities
    participant BA as bearerAuth

    C->>CAPS: GET /capabilities (no Authorization)
    CAPS->>BA: pass through middleware
    BA-->>C: 401 Unauthorized
    Note over C,BA: client cannot preflight require_auth tag<br/>before authenticating. Discovery surface is the 401 body.

After authenticating, caps.features.includes('require_auth') confirms the deployment is hardened.

Wave 4 mutation gate on no-token loopback

sequenceDiagram
    autonumber
    participant C as Client
    participant BA as bearerAuth (no-op, no token)
    participant MG as mutationGate({strict: true})
    participant R as Handler

    C->>BA: POST /workspace/memory (no Authorization)
    BA->>MG: passthrough
    MG-->>C: 401 { code: 'token_required', error: '...' }

State & Lifecycle

  • Bearer token is read at boot and trimmed (newlines from cat token.txt would otherwise silently break comparison).
  • Allowed-Host Set is cached per port; rebuilt on port change (ephemeral 0 → real port post-listen).
  • Mutation gate constructs passthrough and strictDenier once per app build; per-route call returns the cached closure (no per-request allocation).
  • Device-flow registry is disposed on shutdown() Phase 1 so pending flows resolve as cancelled before HTTP teardown.

Dependencies

  • node:cryptocreateHash, timingSafeEqual.
  • packages/cli/src/serve/loopback-binds.tsisLoopbackBind.
  • packages/cli/src/serve/auth/device-flow.ts — device-flow state machine.
  • @qwen-code/acp-bridge — surfaces device-flow events on the per-session SSE bus.

Configuration

Source Knob Effect
Env QWEN_SERVER_TOKEN Bearer token (trimmed).
Flag --token Bearer token (overrides env).
Flag --require-auth Extends bearer to loopback + /health. Boots only with a token.
Flag --hostname Non-loopback bind requires --token (or env).
Flag --allow-origin <pattern> Switch to CORS allowlist mode. '*' requires a token.
Capability tags require_auth (conditional), auth_device_flow (always), allow_origin (conditional) See 11-capabilities-versioning.md.

Caveats & Known Limits

  • --require-auth shadows feature preflight. Unauthenticated clients cannot discover the require_auth tag; their discovery surface is the 401 body itself.
  • Mutation gate body-parser ordering: mutationGate({strict: true}) 401 responses fire after express.json() parses the body. Worst case on a saturated loopback listener: --max-connections × express.json({limit: '10mb'}) ≈ 2.5 GB transient. Loopback-only attack surface, intentionally accepted.
  • Same-origin Origin stripping in server.ts happens before allowOriginCors. If a future change moves the strip elsewhere, the Web Shell breaks.
  • Token comparison is over the SHA-256 digest, not the raw token. Reduces timing leakage by collapsing variable-length token compares to a fixed-size digest compare.
  • The daemon does not carry mTLS, request signing, or pair-token proof-of-possession today. --rate-limit provides HTTP rate limiting by client-id / IP key; it is not client identity authentication.

References

  • packages/cli/src/serve/auth.ts (entire file)
  • packages/cli/src/serve/run-qwen-serve.ts (refuse rules)
  • packages/cli/src/serve/loopback-binds.ts
  • packages/cli/src/serve/auth/device-flow.ts
  • packages/cli/src/serve/auth/qwen-device-flow-provider.ts
  • User-facing threat model: ../../users/qwen-serve.md.
  • Wire reference: ../qwen-serve-protocol.md.