docs/developers/daemon/12-auth-security.md
qwen serve 默认是本地 daemon,配错就是暴露面。安全模型分层,错配时 fail-closed:
bearerAuth 中间件,常量时间 SHA-256 比较,覆盖除 loopback 上 /health 之外的每条路由(require_auth 把它扩展到 loopback 与 /health)。localhost、127.0.0.1、[::1]、host.docker.internal(带端口),防 DNS rebinding。Origin 头的请求(403)。配置 --allow-origin <pattern> 后切换为 CORS 允许列表模式(allowOriginCors),仅放行匹配的来源。code: 'token_required'。POST /workspace/auth/device-flow + GET/DELETE on /:id)。本文讲清每一层和 boot 路径强制的每个不变式。
runQwenServe.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. ...',
);
}
此外:
const parsed = parseAllowOriginPatterns(opts.allowOrigins);
if (parsed.allowAny && !token) {
throw new Error(
"Refusing to start with --allow-origin '*' but no bearer token configured. ...",
);
}
三个拒绝都是 boot-loud(stderr / 抛给嵌入方),从不静默。#3803 的威胁模型明文禁止 daemon 默默裸跑到 loopback 之外。
flowchart LR
REQ[Request] --> SO["strip same-origin Origin
(demo page support)"]
SO --> CORS{"--allow-origin?"}
CORS -->|yes| AO["allowOriginCors
(allowlist match)"]
CORS -->|no| DC["denyBrowserOriginCors
(reject all Origin)"]
AO --> HA["hostAllowlist"]
DC --> HA
HA --> LOG["access-log middleware
(DaemonLogger)"]
LOG --> BA["bearerAuth"]
BA --> RL["rate-limit middleware
(when enabled)"]
RL --> JSON["express.json
(body parser)"]
JSON --> TEL["daemonTelemetryMiddleware
(OTel span)"]
TEL --> MG["per-route: mutationGate
(opt-in strict)"]
MG --> HANDLER["route handler"]
mutationGate 是 per-route 中间件工厂(createMutationGate 返回 mutate()),各路由在注册时调 mutate() 或 mutate({strict: true}) 接入。它不是全局 app.use() 中间件。access-log 在 bearerAuth 之前注册,这样 401 拒绝也会被日志捕获。rate-limit 在 bearerAuth 之后、express.json() 之前注册:只计已通过 auth 的请求,并在解析大 body 前尽早 429。
bearerAuthtimingSafeEqual 比较;不走字符串等值短路,不漏时序信息。Bearer 大小写不敏感(RFC 7235 §2.1),scheme 与凭证之间允许 SP\tHTAB BWS(RFC 7230 §3.2.6),但纯 HTAB 作分隔被拒。indexOf 解析而不是带 \s+ / .+ 重叠的正则,避免多项式正则风险。hostAllowlist仅 loopback。按端口缓存 Set<string>。允许的 Host:
localhost:<port>、127.0.0.1:<port>、[::1]:<port>、host.docker.internal:<port>。localhost、127.0.0.1、[::1]、host.docker.internal),仅当绑端口 80 时(RFC 7230 §5.4 默认端口省略)。Host 比较大小写不敏感 —— Express 规范 header 名但不规范值,Docker 代理大写 Host(Localhost:4170、HOST.docker.internal)严格比对会 403。
非 loopback 绑定跳过该中间件(operator 选了暴露面,bearer 顶上挡 Host 伪造)。
denyBrowserOriginCors任何带 Origin 的请求直接 403 { error: 'Request denied by CORS policy' }。CLI/SDK 永不发 Origin,只有浏览器发。返回确定性 403 而不是 cors 包错误回调的 500 HTML。
例外:demo 页的同源 XHR 由 server.ts 里另一个中间件先把匹配本机地址的 Origin 剥掉。
allowOriginCors(--allow-origin 模式)配置 --allow-origin <pattern> 后,denyBrowserOriginCors 被替换为 allowOriginCors(parsedPatterns)。行为:
Origin 匹配 pattern → 添加 Access-Control-Allow-Origin、Access-Control-Allow-Headers、Access-Control-Allow-Methods 头,OPTIONS preflight 返 204。Origin 不匹配 → 返回与 deny-wall 相同的 403 { error: 'Request denied by CORS policy' }。--allow-origin '*' 需要同时配 --token,否则 boot 拒绝(防止无认证的全开 CORS 暴露面)。parseAllowOriginPatterns() 验证格式。allow_origin 能力 tag 仅在配了该参数时条件广播。createMutationGateper-route opt-in 闸门。行为矩阵:
| daemon 配置 | route opts | 结果 |
|---|---|---|
requireAuth=true | 任意 | passthrough¹ |
配了 token | 任意 | passthrough² |
| 无 token(loopback dev) | strict: false | passthrough |
| 无 token(loopback dev) | strict: true | 401 { code: 'token_required' } |
¹ --require-auth 只在配了 token 时启动,全局 bearerAuth 已经 401 过未认证调用。
² 配了 token 的任何路径,全局 bearerAuth 都强制 bearer;这里冗余但无害。
code: 'token_required' 与 bearerAuth 普通 Unauthorized 不同形状,SDK 据此渲染「请用 --token / --require-auth 启动 daemon」提示而不是泛 401。
Wave 4+ strict 路由:/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。
/health 豁免loopback 绑定上,/health 注册在 bearer 中间件之前,pod 内部 liveness 探针不必带 token。非 loopback 绑定下 /health 也走 bearer。--require-auth 撤销豁免:loopback 上 /health 也要 Authorization: Bearer <token>。
X-Qwen-Client-Id) 是自报daemon 只校验 X-Qwen-Client-Id 的格式([A-Za-z0-9._:-]{1,128})并按 session 跟踪 attach 的 client id;当下不做 proof-of-possession 检查。客户端只要观察到 SSE 帧里的 originatorClientId 就能用同 id 重新注册,在后续请求里冒充 originator。
影响范围:designated 策略(远端可以伪装 originator 给本应只属于 prompt 发起人的请求投票);consensus 策略(如果 votersAtIssue 快照里已经有伪装 id,它能投)。不影响 local-only(按 fromLoopback 闸,daemon 按连接 remote address 盖戳),不影响 first-responder(与身份无关)。
「pair-token」机制(POST /session 时 daemon 发一个 per-session secret,designated / consensus 投票必须带)将来 PR 落地。当下需要加固 designated 策略的部署应当绑 loopback 或挂在做认证的反代后面。详见 04-permission-mediation.md 各策略的具体影响。
provider 认证(Qwen OAuth 等)的独立 OAuth surface:
POST /workspace/auth/device-flow — 启动一个流;返回 {deviceFlowId, providerId, expiresAt, verificationUrl, userCode}。GET /workspace/auth/device-flow/:id — 轮询状态。DELETE /workspace/auth/device-flow/:id — 取消。GET /workspace/auth/status — 当前账号 / provider 快照。SSE 事件 auth_device_flow_{started, throttled, authorized, failed, cancelled} 把流状态扇出给所有订阅者,多客户端 UI 同步。见 09-event-schema.md。
实现:packages/cli/src/serve/auth/deviceFlow.ts + qwenDeviceFlowProvider.ts。
日志注入 / Trojan-Source 防御:sanitizeForStderr(value)(deviceFlow.ts)剥掉 ASCII C0 / DEL / C1 控制字符外加 Unicode 同形字符 —— 恶意 IdP 可能用它们伪造日志行或隐藏 payload:
| 范围 | 为什么剥 |
|---|---|
\x00–\x1f、\x7f、\x80–\x9f | ASCII C0 / DEL / C1,日志行伪造、终端控制序列 |
| U+200B–U+200F | 零宽字符 + LRM / RLM,隐形但能改终端渲染 |
| U+2028–U+2029 | LINE / PARAGRAPH SEPARATOR,许多 Unicode-aware 终端把它当换行,最直接的日志伪造向量 |
| U+202A–U+202E | 双向 EMBEDDING / OVERRIDE 控制 |
| U+2066–U+2069 | 双向 ISOLATE 控制(LRI / RLI / FSI / PDI),CVE-2021-42574 "Trojan Source" 主攻击向量。恶意 IdP 用 U+2066 (LRI) 替换 U+202D (LRO) 会绕过 EMBEDDING/OVERRIDE 范围却达到同样视觉重排 |
| U+FEFF | BOM / 零宽不折断空格 |
长度保持(每个被剥码点替换为 ? 而不是消失),operator 在那索引处仍能看出有东西曾经在。两层都用:qwenDeviceFlowProvider 净化 IdP 的 oauthError,registry 的 late-poll 观察者净化插值进 audit hint 的 provider 可控值(latePollResult.kind / lateErr.name)。
auth_device_flow 能力 tag 无条件广播;路由本身在 daemon 不支持指定 provider 时返 400 unsupported_provider。支持的 provider 列表在 /workspace/auth/status 而不是 /capabilities,保持 descriptor 形状统一。
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 ...
都返 401 { error: 'Unauthorized' }(missing header / wrong scheme / wrong token 一致),探测者无法区分。
--require-auth 阴影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 pre-flight require_auth tag
before authenticating. Discovery surface is the 401 body.
认证成功后 caps.features.includes('require_auth') 确认部署是 hardened。
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: '...' }
cat token.txt 带尾换行默默永不匹配)。0 → listen 后的真实端口)才重建。passthrough 和 strictDenier 一次;每路由调用返回缓存闭包(无 per-request 分配)。shutdown() 第一阶段释放,pending flow 在 HTTP 收尾前解析为 cancelled。node:crypto —— createHash、timingSafeEqual。packages/cli/src/serve/loopbackBinds.ts —— isLoopbackBind。packages/cli/src/serve/auth/deviceFlow.ts —— device-flow 状态机。@qwen-code/acp-bridge —— 把 device-flow 事件吐到 per-session SSE bus。| 来源 | 旋钮 | 效果 |
|---|---|---|
| Env | QWEN_SERVER_TOKEN | Bearer token(trim 后) |
| 参数 | --token | Bearer token(覆盖 env) |
| 参数 | --require-auth | Bearer 扩展到 loopback + /health。仅在配了 token 时启动 |
| 参数 | --hostname | 非 loopback 绑定要求 --token(或 env) |
| 能力 tag | require_auth(条件)、auth_device_flow(恒) | 见 11-capabilities-versioning.md |
--require-auth 遮蔽 feature pre-flight。未认证客户端无法发现 require_auth tag;它们的发现 surface 是 401 响应体。express.json() 之后才发;满载 loopback 监听器最坏 --max-connections × express.json({limit: '10mb'}) ≈ 2.5 GB 瞬时。loopback only,刻意接受。denyBrowserOriginCors 之前;如果未来 refactor 把剥离挪走,demo 页会坏。--rate-limit 提供的是按 client-id / IP key 的 HTTP 限流,不等同于客户端身份认证。packages/cli/src/serve/auth.ts(整文件)packages/cli/src/serve/runQwenServe.ts(refuse 规则)packages/cli/src/serve/loopbackBinds.tspackages/cli/src/serve/auth/deviceFlow.tspackages/cli/src/serve/auth/qwenDeviceFlowProvider.ts../../users/qwen-serve.md。../qwen-serve-protocol.md。