认证与安全模型
概述
qwen serve 默认是一个本地守护进程,在错误配置下会成为暴露面。它的安全模型是 分层 的,以便错误配置时 fail closed:
- 绑定 — 非回环绑定始终携带 bearer:操作员的,或在启动时生成并打印一次的临时 128 位 bearer。仅当提供的令牌源明确为空/空白,或请求的
localhost解析到非回环地址(无令牌源已解析,即永不生成)时,启动才拒绝。 - Bearer 认证 —
bearerAuth中间件使用常量时间 SHA-256 比较保护普通 API 路由,/health在普通回环绑定上除外(require_auth也会将该端点移到 bearer 之后)。Channel webhook 入口是独立的 pre-bearer 路由,通过x-qwen-webhook-secret认证。Web Shell 文档和资源路由在所有模式下都保持 pre-auth。 - 主机头允许列表 — 在回环上,只接受
localhost、127.0.0.1、[::1]、host.docker.internal或精确绑定的回环地址(加端口);监听 80 或 443 时也接受对应的无端口形式。该允许列表防御 DNS 重绑定。Local Control LAN 监听器是例外,它始终强制执行其通告权限的 Host 检查,无论主绑定是什么。 - 来源控制 — 运行时应用始终在可变允许列表(
MutableOriginAllowlist)上安装allowOriginCors:--allow-origin <pattern>条目作为种子,Local Control 在启用时添加 LAN 来源。不匹配的来源收到 403 拒绝信封。无条件拒绝墙(denyBrowserOriginCors)仅保留在运行时启动之前回答请求的引导应用中。 - 逐路由变更门控 — 严格路由需要操作员权限。无令牌的回环主监听器被信任;bearer 认证和配对的 Local Control 请求也符合条件。没有可信权限到达此门控的无令牌主请求会收到独特的
code: 'token_required'错误。缺失或无效的配置凭证以及未配对的 Local Control 凭证由其监听器作用域的 bearer 中间件以普通的401 Unauthorized提前拒绝。 - 设备流认证 — 为提供商认证提供独立的 OAuth 表面(
POST /workspace/auth/device-flow+ GET/DELETE 于/:id)。
本文档将逐一介绍每个层次以及启动路径强制执行的显式不变量。
职责
- 拒绝在不安全的配置下启动。
- 在配置时通过 bearer 门控普通 API 请求(受回环
/health豁免约束);保持 channel webhook 入口在其独立的共享密钥门控之后;保持回环 Host 和浏览器 Origin 检查在认证和豁免路由之前。 - 提供 Wave 4 路由选择启用的逐路由变更门控。
- 托管设备流注册表,驱动提供商 OAuth 流程,并通过 SSE 事件可见。
架构
启动时的拒绝规则
在 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. ...',
);
}无令牌的允许来源配置限制为回环 HTTP(S) 来源; 非 HTTP(S) 条目保留其现有处理方式:
const parsed = parseAllowOriginPatterns(opts.allowOrigins);
if (parsed.allowAny && !token) {
throw new Error(
"Refusing to start with --allow-origin '*' but no bearer token configured. ...",
);
}
if (findNonLoopbackHttpOrigin(parsed) && !token) {
throw new Error(
'Refusing to start with a non-loopback HTTP(S) --allow-origin but no bearer token configured. ...',
);
}这些拒绝是显式的启动失败(显示在 stderr / 抛给嵌入者),绝不会静默忽略。#3803 中的威胁模型明确禁止允许守护进程在开放状态下绑定到回环之外。
runQwenServe() 解析 localhost 一次,将监听器固定到该地址,并在发布可信回环权限之前验证实际监听地址;如果结果不在 127.0.0.0/8 或 ::1 范围内,无令牌启动失败并关闭监听器。createServeApp() 不拥有套接字,因此其调用者仍负责确保声明的回环主机名仅绑定到回环。声明的非回环嵌入保持严格路由、会话 shell 和 Local Control 配对材料 fail closed。它还在构造时拒绝 requireAuth: true 而没有非空令牌,这样非严格路由不会在无效的加固配置下意外保持开放。
中间件链(HTTP 请求顺序)
mutationGate 是一个逐路由的中间件工厂(createMutationGate 返回 mutate());路由在注册时调用 mutate() 或 mutate({strict: true})。它不是全局 app.use() 中间件。访问日志和入站 trace-id 捕获在来源墙和同源凭证检查之前注册,因此那些 403/401 短路会像其他每次拒绝一样被记录,并且日志行仍会加入调用方的 trace id;两者也都先于 bearerAuth,因此 401 拒绝仍会被记录。回环 Host 允许列表在 pre-auth health 路由之前注册,因此 DNS 重绑定防御覆盖它。Pre-auth /health 位于来源墙之下(匹配的跨域探测携带 CORS 头);访问日志在附加其 finish logger 之前按路径豁免 GET /health 和 POST */heartbeat,因此精确路径的 GET /health 和 POST */heartbeat 探测在任何挂载位置都保持未记录,那些豁免路径上的墙拒绝同样未记录(HEAD /health 和 GET /health/ 像任何请求一样被记录)。普通 API 限流在 bearerAuth 之后、express.json() 之前运行,因此只有经过认证的请求才会被计数,并且在超出限制时大体积 body 会在解析前被拒绝。Channel webhook 入口在 bearer 认证之前分支,并应用其自己的共享密钥检查、变更层级限流检查和 1 MiB 解析器。
bearerAuth
- 未配置令牌 → 中间件是无操作(回环开发者默认配置)。例外:Local Control LAN 监听器是监听器作用域的,始终需要其配对凭证(
CredentialStore.isOpen对local-control永远不为 true),因此即使在无令牌的守护进程上也不会开放。 - 配置了令牌 → 在构造时对配置的令牌进行一次 SHA-256 哈希;在每个请求上对候选令牌进行哈希并与
timingSafeEqual比较。没有字符串相等短路;没有时间泄露。 - 方案解析:根据 RFC 7235 §2.1,不区分大小写地解析
Bearer;根据 RFC 7230 §3.2.6 BWS,容忍方案与凭证之间的SP\tHTAB;拒绝纯HTAB作为分隔符。 - CodeQL 加固:手工编写的
indexOf解析,而不是使用带有\s+/.+重叠的正则表达式(无多项式正则风险)。
hostAllowlist
仅限回环。维护一个按端口键控的 Set<string>。允许的主机:
localhost:<port>、127.0.0.1:<port>、[::1]:<port>、host.docker.internal:<port>,以及具有相同端口的精确绑定回环地址。最后一种形式覆盖完整的受支持 IPv4 回环范围(127.0.0.0/8),而不会引入无关的 Host。- 以及对应的无端口形式,仅在绑定到端口 80 或 443 时(根据 RFC 7230 §5.4 默认端口省略)。
主机比较是不区分大小写的——Express 会标准化头名称但不会标准化值,因此 Docker 代理将 Host 大写(如 Localhost:4170、HOST.docker.internal)时,如果使用精确字符串比较会返回 403。
非回环绑定绕过主闸门(操作员选择了暴露面;bearer 令牌门控 Host 伪造)。Local Control LAN 监听器是例外:它始终强制执行其通告权限的 Host 检查,无论主绑定是什么。
denyBrowserOriginCors(仅引导应用)
拒绝任何带有 Origin 头的请求。CLI/SDK 从不设置 Origin;只有浏览器会设置。返回确定性的 403 { error: 'Request denied by CORS policy' },而不是 cors 包的 error-callback 会产生的 500 HTML。运行时应用不再安装此墙 — 它在可变允许列表上运行 allowOriginCors(见下文);拒绝行为作为未匹配来源分支保留在那里。此墙保留在引导应用(run-qwen-serve.ts)中,在运行时启动之前服务请求。
例外:Web Shell 在回环绑定上的同源 XHR 由单独的中间件(在 server/self-origin.ts 中)处理,该中间件在 Origin 匹配规范回环自身来源(127.0.0.1、localhost、[::1]、host.docker.internal)或精确绑定回环地址之一时将其剥离。方案匹配的无端口来源仅在其默认端口(http 为 80,https 为 443)时被接受。在非回环绑定上,第二个中间件(installRemoteSelfOriginMiddleware,在同一文件中)代替覆盖 Shell 的 XHR:它对规范 Origin 等于直接套接字方案加规范化 Host 权限的请求进行 bearer 认证 — 转发头从不被信任 — 并在墙前剥离该 Origin,因此这些请求不需要 --allow-origin 条目。跨域和 null 来源仍被拒绝,WebSocket 升级路由以及 TLS 前置代理 https 来源仍需要一个条目(参见中间件链)。
allowOriginCors(运行时应用,始终安装)
运行时应用无条件安装 allowOriginCors(originAllowlist);允许列表是一个 MutableOriginAllowlist,由 --allow-origin <pattern> 条目(可能为空)作为种子,并在 Local Control 启用时在运行时扩展(LAN 来源随监听器添加/移除):
- 匹配的
Origin值会收到Access-Control-Allow-Origin、Access-Control-Allow-Headers和Access-Control-Allow-Methods;OPTIONS预检返回204。 - 不匹配的
Origin值会收到与拒绝模式相同的确定性403 { error: 'Request denied by CORS policy' }。 --allow-origin '*'需要 bearer 令牌;在回环绑定上启动时在没有配置的情况下拒绝,而在非回环绑定上生成的临时令牌满足守卫(拒绝仅限回环)。- 没有令牌时,HTTP(S)
--allow-origin值限制为回环绑定上的回环主机;在非回环绑定上生成的令牌满足相同的守卫。非回环浏览器来源在每个 API 路由上使用 bearer 进行认证,因为它否则可以行使完整的操作员 API,包括以守护进程用户身份执行代码;pre-auth 例外是 Web Shell 文档/资源路由和 MCP App 沙箱(/、/assets*、/mcp-app-sandbox、精确的/session/:id导航),以及 channel webhook 入口,它在bearerAuth之前分支并使用自己的x-qwen-webhook-secret而不是 bearer 进行认证,以及 — 仅在回环绑定上 —/health(在其他地方由 bearer 门控)。 - 显式浏览器扩展来源保留其无令牌的本地自动化路径。启动日志记录任何无令牌允许的浏览器来源获得完整操作员权限。
parseAllowOriginPatterns()在启动时验证模式语法。- 只有在配置此模式时,才会公布
allow_origin能力标签。
createMutationGate
逐路由的可选门控。行为矩阵:
| 守护进程/请求权限 | 路由选项 | 结果 |
|---|---|---|
| 配置了令牌 | 任意 | 透传¹ |
| 可信回环主监听器 | 任意 | 透传 |
| 配对的 Local Control 监听器 | strict: true | 透传 |
| 无可信回环权限的无令牌主请求 | strict: true | 401 { code: 'token_required' } |
| 任何无令牌部署 | strict: false | 透传 |
¹ 任何令牌配置都会使全局 bearerAuth 在普通 API 路由上在门控之前强制执行 bearer 认证,回环 /health 除外,除非设置了 --require-auth。Channel webhook 入口在此中间件之前使用自己的共享密钥进行认证。该门控在它保护的路由上是冗余但无害的。--require-auth 本身不是认证,只有与令牌一起才有效。
可信回环模式从 loopback bind && no configured token && !requireAuth 派生一次。它仅授权通过主监听器到达的请求。它不会标记内部 bearer 认证的标记,因此监听器凭证和部署权限保持为独立的事实。code: 'token_required' 形式保留给旧的守护进程和无令牌的非可信嵌入,其请求到达严格门控,因此 SDK 客户端可以渲染配置提示而不是通用的 401。配置令牌和 Local Control 凭证失败保留较早的纯 401 Unauthorized 响应。
Local Control 状态和启用响应仅向具有操作员权限的调用方公开其配对 URL 和 QR:可信的主监听器调用方、bearer 认证的主调用方和已配对的 LAN 客户端。未配对的 LAN 调用方和非可信嵌入无法检索它。启用仍然需要主监听器;LAN 客户端可以在配对后访问或根据现有规则请求禁用。
Wave 4+ 严格路由:/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 以及 /session/:id/shell。
即使配置了 ACP 传输,Rewind 在 TypeScript SDK 中仍然保持纯 REST。这保留了严格的变更门控和 bearer/客户端身份头;ACP 路由表故意不包含 rewind 映射。在 rewind 或 shell 到达次级运行时 bridge 之前,owner 路由还会重新检查工作区信任。重复的实时会话 ID 会 fail closed 为 ambiguous_session_owner,而不是回退到主运行时。
/health 豁免
在回环绑定上,/health 在 bearer 中间件之前注册,因此 Pod 内的存活探针不需要携带令牌。非回环绑定将 /health 与其他普通 API 路由一起门控。--require-auth 会移除豁免:回环上的 /health 也需要 Authorization: Bearer <token>。Channel webhook 入口在所有模式下都保持在 bearer 认证之外,并需要自己的 x-qwen-webhook-secret。
v1 客户端标识(X-Qwen-Client-Id)是自报告的
守护进程只验证 X-Qwen-Client-Id 的格式([A-Za-z0-9._:-]{1,128}),并在每个会话中追踪附着的客户端 ID。它目前不执行持有证明(proof-of-possession)。一个在 SSE 上观察到 originatorClientId 的客户端可以重新注册相同的 ID,并在后续请求中冒充该发起方。
影响:
designated— 远程调用方可以冒充发起方,并对仅针对 prompt 发起方的请求进行投票。consensus— 如果伪造的 ID 已经存在于votersAtIssue快照中,它就可以投票。local-only不受影响,因为它基于fromLoopback进行门控,而守护进程从连接远程地址中标记fromLoopback。first-responder不受影响,因为它不依赖身份标识。
未来的配对令牌机制将从 POST /session 发放每个会话的秘密;designated / consensus 投票将需要出示该秘密。在此之前,需要强化 designated 策略的部署应绑定回环或在认证的反向代理后面运行。有关策略级别的详细信息,请参阅 04-permission-mediation.md。
设备流认证
为提供商认证提供独立的 OAuth 表面。v1 提供商标识符是 qwen-oauth,但 Qwen OAuth 免费层已于 2026-04-15 停止;新的设置应使用当前受支持的认证提供商(如果有)。
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— 当前账户/提供商快照。
SSE 事件 auth_device_flow_{started, throttled, authorized, failed, cancelled} 将流程状态广播给所有订阅者,使多客户端 UI 保持同步。参见 09-event-schema.md。
实现:packages/cli/src/serve/auth/device-flow.ts + qwen-device-flow-provider.ts。
日志注入 / Trojan Source 防御:sanitizeForStderr(value)(device-flow.ts)将 ASCII 控制字符和 Unicode 控制字符替换为 ?。否则恶意 IdP 可以伪造日志行或隐藏载荷:
| 范围 | 为何被剥离 |
|---|---|
\x00–\x1f、\x7f、\x80–\x9f | ASCII C0 / DEL / C1 控制符、终端转义以及日志行伪造。 |
| U+200B-U+200F | 零宽度字符加上 LRM / RLM;不可见但可以改变终端渲染。 |
| U+2028-U+2029 | 行分隔符 / 段落分隔符;许多支持 Unicode 的终端将它们视为换行符。 |
| U+202A-U+202E | 双向嵌入 / 覆盖控制符。 |
| U+2066-U+2069 | 双向隔离控制符(LRI / RLI / FSI / PDI),是 CVE-2021-42574 “Trojan Source” 的主要攻击向量。使用 U+2066 (LRI) 而不是 U+202D (LRO) 的 IdP 可以绕过仅过滤嵌入/覆盖的过滤器,实现类似的视觉重排。 |
| U+FEFF | BOM / 零宽度不间断空格。 |
长度保持不变:每个被剥离的码点被替换为 ? 而不是删除,因此操作员仍然可以看到该索引处存在某些内容。两个层都使用了该清理器:qwenDeviceFlowProvider 清理 IdP 的 oauthError,而注册表的延迟轮询观察器清理插入到审计提示中的提供商控制的值(latePollResult.kind / lateErr.name)。
auth_device_flow 能力标签无条件地公布;如果守护进程无法满足特定提供商,路由本身会返回 400 unsupported_provider。受支持的提供商列表位于 /workspace/auth/status 而不是 /capabilities,以保持描述符形式统一。
工作流程
Bearer 认证成功请求
Bearer 认证失败模式
所有失败都返回 401 { error: 'Unauthorized' }(missing header / wrong scheme / wrong token 统一,因此无法通过探测区分)。
--require-auth 阴影
认证后,caps.features.includes('require_auth') 确认部署已经加固。
受信任回环上的严格变更
状态与生命周期
- Bearer 令牌在启动时读取并去除首尾空格(否则
cat token.txt中的换行符会静默破坏比较)。 - 仅限 CLI 的
--open-with-auth模式在启动前运行:在经过确定性的回环/Web Shell 检查后,它应用相同的选项优先于环境变量选择,并且仅当没有非空的所选令牌存在时,才会用 32 个随机字节(编码为 base64url)填充ServeOptions.token。生成的凭证具有进程生命周期,不会被写入process.env或由守护进程持久化,并通过现有的 URL fragment 传递给浏览器。Web Shell 将其浏览器副本保留在每标签的sessionStorage中。裸--open和直接runQwenServe()调用者永远不会生成它。 - 允许主机集合按端口缓存;在端口更改时重建(临时端口
0→listen后的实际端口)。 - 变更门控在每次应用构建时构造
passthrough和strictDenier;每个路由调用返回缓存的闭包(无每次请求分配)。 - 设备流注册表在
shutdown()的第 1 阶段释放,因此待处理的流在 HTTP 拆除前解析为cancelled。
依赖
node:crypto—createHash、timingSafeEqual。packages/cli/src/serve/loopback-binds.ts—isLoopbackBind。packages/cli/src/serve/auth/device-flow.ts— 设备流状态机。@qwen-code/acp-bridge— 在每会话 SSE 总线上发布设备流事件。
配置
| 来源 | 配置项 | 效果 |
|---|---|---|
| 环境变量 | QWEN_SERVER_TOKEN | Bearer 令牌(去除首尾空格)。 |
| 标志 | --token | Bearer 令牌(覆盖环境变量)。 |
| CLI 标志 | --open-with-auth | 在 daemon 启动前复用或生成环回 Web Shell bearer。 |
| 标志 | --require-auth | 将 bearer 扩展到回环 + /health。仅在有令牌时启动。 |
| 标志 | --hostname | 非回环绑定始终携带 bearer — --token、QWEN_SERVER_TOKEN 或生成的临时 bearer;明确空的源会拒绝。 |
| 标志 | --allow-origin <pattern> | 切换到 CORS 允许列表模式。通配符和非回环 HTTP(S) 来源需要令牌。 |
| 能力标签 | require_auth(条件性)、auth_device_flow(始终)、allow_origin(条件性) | 参见 11-capabilities-versioning.md。 |
注意事项与已知限制
--require-auth遮蔽了特性预检。 未认证的客户端无法发现require_auth标签;它们的发现面就是 401 响应体本身。- 变更门控 body 解析器顺序:
mutationGate({strict: true})的 401 响应在express.json()解析 body 之后 触发。在饱和监听器上的最坏情况:--max-connections × express.json({limit: '10mb'})≈ 2.5 GB 瞬时。非回环生产入口点已经在普通 API 解析器之前要求 bearer 认证;channel webhook 入口在其单独的 1 MiB 解析器之前检查其共享密钥。直接非可信嵌入拥有自己的监听器暴露。 - 同源 Origin 剥离 在
server.ts中发生在allowOriginCors之前。如果未来的改动将剥离移到其他地方,Web Shell 将失效。 - 令牌比较是基于 SHA-256 摘要,而不是原始令牌。通过将可变长度的令牌比较缩减为固定大小的摘要比较,减少了时间泄露。
- 守护进程目前不支持 mTLS、请求签名或配对令牌持有证明。
--rate-limit提供基于 client-id / IP 键的 HTTP 限流;它不是客户端身份认证。
参考
packages/cli/src/serve/auth.ts(整个文件)packages/cli/src/serve/run-qwen-serve.ts(拒绝规则)packages/cli/src/serve/loopback-binds.tspackages/cli/src/serve/auth/device-flow.tspackages/cli/src/serve/auth/qwen-device-flow-provider.ts- 面向用户的威胁模型:
../../users/qwen-serve.md。 - 协议参考:
../qwen-serve-protocol.md。