-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(voice): support trusted private ASR base URLs #8350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rockybot2026
merged 31 commits into
QwenLM:main
from
rockybot2026:feat/voice-private-base-url-allowlist
Aug 6, 2026
Merged
Changes from all commits
Commits
Show all changes
31 commits
Select commit
Hold shift + click to select a range
0e1e5c4
feat(voice): support trusted private ASR base URLs
rockybot2026 aa54f5a
fix(voice): address private endpoint review findings
rockybot2026 5b81f8f
test(voice): cover private endpoint edge cases
rockybot2026 395179b
test(voice): pin remaining endpoint edge cases
rockybot2026 797499a
fix(voice): address private endpoint review feedback
rockybot2026 0692403
fix(voice): clarify allowlist URL and normalize IPv6
rockybot2026 46c9b1a
fix(voice): harden NAT64 address validation
rockybot2026 0741498
fix(voice): address managed endpoint review findings
rockybot2026 18a56da
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot 57de7aa
refactor(voice): extract shared IPv6 transition unwrap ladder (#8350)
qwen-code-dev-bot ebf63d6
test(voice): cover allowInsecureBaseUrl wiring through desktop defaul…
qwen-code-dev-bot 3822724
Merge branch 'main' into feat/voice-private-base-url-allowlist
rockybot2026 3759465
fix(voice): add allowlist hint to private-network rejection error (#8…
qwen-code-ci-bot 998e483
fix(voice): reject always-blocked base URLs before offering the allow…
qwen-code-ci-bot 48cafaa
fix(voice): resolve exact desktop voice provider before OAuth (#8350)
qwen-code-ci-bot ed6922a
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot 4073a4a
Merge branch 'feat/voice-private-base-url-allowlist' of https://githu…
qwen-code-dev-bot d562eeb
fix(voice): address review feedback for trusted private base URLs (#8…
qwen-code-dev-bot 17ccec3
fix(voice): align desktop voice resolution with CLI semantics (#8350)
qwen-code-dev-bot ba08e5e
fix(voice): scope desktop fail-closed resolution to policy-bearing en…
qwen-code-dev-bot 8a6f99a
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot bc6df33
fix(voice): address round-8 review findings for trusted private base …
qwen-code-dev-bot 5f69a89
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot 34b37ca
fix(voice): address round-9 review findings for trusted private base …
qwen-code-dev-bot a8c0bd2
fix(voice): address round-10 review findings for trusted private base…
qwen-code-dev-bot 6f26b36
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot 9cd5530
Merge branch 'main' into feat/voice-private-base-url-allowlist
wenshao f350652
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot 59e1315
fix(voice): classify desktop voice duplicates before ambiguity check …
qwen-code-dev-bot ea59527
fix(scripts): compare voice guard mirrors as parse trees (#8350)
qwen-code-dev-bot ce6b480
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| # Trusted private voice base URLs | ||
|
|
||
| ## Status | ||
|
|
||
| Implemented for [#8286](https://github.com/QwenLM/qwen-code/issues/8286). | ||
|
|
||
| ## Problem | ||
|
|
||
| Voice transcription rejects non-loopback HTTP endpoints and endpoints that resolve to private addresses. Those checks are safe defaults, but they also prevent managed deployments from routing ASR traffic through an isolated private gateway. Gateway URLs are deployment-specific, so vendor or region hostname lists would not scale. | ||
|
|
||
| ## Design | ||
|
|
||
| Add `security.allowedInsecureVoiceBaseUrls`, an empty-by-default list of complete base URLs. Every entry must include an explicit `http://` or `https://` scheme and the full provider path. A configured voice provider receives the exception only when its normalized base URL exactly matches a list entry, including scheme, host, port, and path; URL serialization and trailing slashes are normalized, but missing schemes or path segments such as `/v1` are not inferred for custom or regional gateways. The pre-existing `/v1` inference is preserved, for provider entries, only for official DashScope compatible-mode endpoints; desktop OAuth- and environment-derived base URLs still pass through the same legacy inference for any host before matching. The CLI voice resolver performs no `/v1` inference at all, so a DashScope provider entry meant to resolve identically on both surfaces must carry the `/v1`-suffixed baseUrl; without it the CLI resolves the pre-`/v1` URL while desktop appends `/v1`, and each surface's allowlist entry must match its own resolved URL. Wildcards and hostname suffix matching are not supported. | ||
|
|
||
| The setting is trusted configuration. User, System, and SystemDefaults scopes may provide it; Workspace values are ignored and reported as a settings warning. This prevents a cloned repository from granting itself access to an insecure or private endpoint. Settings values pass through environment-variable interpolation before matching, so anything that controls the process environment can supply an interpolated allowlist entry or provider `baseUrl`; treat the process environment as part of the trusted configuration surface. | ||
|
|
||
| The exact-match result travels with the resolved voice configuration so every egress path applies the same decision: | ||
|
|
||
| - CLI batch transcription | ||
| - CLI and daemon streaming transcription | ||
| - Desktop batch and streaming transcription | ||
|
|
||
| An exact match permits cleartext transport and private RFC 1918, CGNAT, or IPv6 unique-local addresses. Loopback aliases, unspecified addresses, link-local ranges, and known cloud metadata addresses remain blocked. Explicit localhost behavior remains unchanged. | ||
|
|
||
| Streaming transports derive their WebSocket URL from the resolved base URL (`deriveWebSocketBase` drops a trailing `/v1` or `/compatible-mode/v1` and appends `/api-ws/v1/inference` or `/api-ws/v1/realtime`), so the wire path intentionally differs from the allowlisted path. The exact-match guarantee therefore covers the provider endpoint; the batch request path uses it verbatim, while the streaming wire path is derived from it rather than matched against the allowlist. | ||
|
|
||
| Desktop voice merges SystemDefaults, User, and System settings with the same trusted-scope precedence as the CLI; `modelProviders` deep-merges per provider-group key exactly like the CLI (the higher scope's array wins for the same key; disjoint keys all survive). It never reads Workspace settings for this exception. It resolves the selected voice model before credentials; same-ID provider entries are ambiguous unless they are exact `(id, baseUrl)` duplicates (where the first registered entry wins like the CLI model registry; `envKey` is not part of the composite key, so a differing `envKey` also keeps the first registration) or none of the matching entries needs a network-policy decision (in which case the whole set keeps the legacy fall-through, like a single public HTTPS entry), preventing an unrelated model or region from supplying the endpoint and API key. Public HTTPS providers do not require an insecure allowlist entry; cleartext or private-network providers still require an exact match. | ||
|
|
||
| Provider-group visibility intentionally differs between the surfaces in one narrow way. The CLI resolves voice models through the model registry, so entries in a custom provider group are only visible when the group id resolves to a protocol — a built-in group id or a `providerProtocol` mapping, exactly as in the rest of the CLI model surface. The desktop resolver reads trusted settings directly and scans provider entries across all groups, protocol-agnostic, because it has no model registry. A voice entry under a custom group without a `providerProtocol` mapping therefore resolves on desktop while the CLI reports it as not configured. The scan also admits entries the CLI registry filters out — voice entries under non-OpenAI protocol groups (for example `gemini`), `imageOnly` entries, and `qwen-oauth` groups resolve on desktop while the CLI rejects or never registers them — and it widens the ambiguity check: a same-ID entry with a differing baseUrl in any scanned group makes the model ambiguous on desktop when any matching entry needs a network-policy decision, hard-failing dictation even when the duplicate sits in a group the CLI never sees and the CLI resolves the model normally; duplicates that all keep the legacy fall-through (public HTTPS, unallowlisted) fail on neither surface. Every resolution path stays network-policy-checked on both surfaces; these divergences change which entries resolve, never the checks applied to them. | ||
|
|
||
| ## Configuration ownership | ||
|
|
||
| The operator that provisions a regional gateway owns the allowlist entry. Managed deployments should render the provider `baseUrl` and the allowlist entry from the same declarative endpoint value. Adding a region therefore requires no Qwen Code change and cannot drift into a hostname-wide exception. An allowlisted hostname is only as trustworthy as its DNS — a later DNS record change redirects the exception (and the provider credentials) wherever the name points. Prefer IP-literal entries when the gateway address is stable. | ||
|
|
||
| ## Failure and rollback behavior | ||
|
|
||
| Malformed entries and non-matches fail closed. Removing the entry immediately restores the existing HTTPS/public-network requirement after settings reload or process restart. | ||
|
|
||
| Desktop treats a provider whose ID exactly matches the selected voice model as authoritative only when the entry needs a network-policy decision — its base URL is allowlisted, cleartext HTTP, a private-network address, or loopback. Those entries resolve before OAuth credentials so a managed gateway wins for OAuth-signed-in users, and they fail closed on duplicate matches, unsupported schemes, always-blocked addresses, a missing allowlist match, or an unresolved `envKey`, preventing an accidental fallback to a different provider or region. Public HTTPS entries keep the legacy fall-through (OAuth, then the shared DashScope provider, then environment credentials), preserving the pre-allowlist credential precedence for existing installs; entries too incomplete to classify (a missing or unparseable base URL) fall through the same way. An entry without `envKey` resolves without an API key, matching the CLI for keyless local or private gateways. | ||
|
|
||
| Hostnames whose DNS records resolve to loopback addresses (for example `asr.localtest.me` or `/etc/hosts` aliases for a local ASR server) are always blocked, with or without an allowlist entry; the CLI previously allowed such DNS results. To reach a local endpoint, configure an explicit loopback baseUrl such as `http://localhost`, `http://127.0.0.1`, or `http://[::1]`, which remains allowed. | ||
|
|
||
| Two more behavior changes relative to the pre-allowlist guard: | ||
|
|
||
| - CLI: a voice model `baseUrl` with embedded credentials (`https://user:pass@host/...`) is rejected instead of proceeding with the credentials stripped — userinfo can make the URL parser resolve an attacker-controlled host. | ||
| - Desktop: IPv4-mapped IPv6 literals such as `::ffff:127.0.0.1` are classified by their embedded IPv4 address and no longer bypass the loopback block; configure an explicit loopback spelling instead. | ||
|
|
||
| ## Verification | ||
|
|
||
| - Preserve default rejection for non-localhost HTTP and private endpoints. | ||
| - Require allowlist entries to include an explicit scheme and full provider path on both CLI and Desktop. | ||
| - Accept two unrelated regional private gateway URLs only when the selected URL exactly matches an entry. | ||
| - Reject scheme, port, host, or path mismatches. | ||
| - Reject non-HTTP(S) URL schemes even when exactly listed. | ||
| - Ignore and warn about Workspace-scoped entries. | ||
| - Continue rejecting link-local and cloud metadata addresses, including AWS IMDS IPv6, after an exact match. | ||
| - Decode IPv4-mapped, IPv4-compatible, and well-known-prefix NAT64 IPv6 literals consistently so trusted private addresses are accepted while embedded loopback and metadata addresses remain blocked. | ||
| - Reject local-use NAT64, IETF protocol-assignment/Teredo, and 6to4 transition prefixes on both trusted and default-deny paths. | ||
| - Match Desktop credentials to one unambiguous provider with the selected voice model ID. | ||
| - Exercise both CLI and Desktop resolution and DNS guard paths. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] Two groups of mirrored voice-policy units have no sync mechanism — absent from
MIRROR_SETS, no keep-in-sync comments at the definitions, and unlisted in the script's "Not covered" note:normalizeAllowedVoiceBaseUrl/isInsecureVoiceBaseUrlAllowed(CLIvoice-transcriber.ts:140-165↔ desktopresolve-voice-config.ts:427-451): semantically identical but textually different today (trimTrailingSlashes(url.toString())vs inline.replace(/\/+$/, '')). These two functions compute the exact-match decision the design doc says every egress path applies, and their sibling mirrors (mergeModelProviders,resolveSettingsEnvVars,getSystemSettingsPath,DOTENV_LINE, …) are all comment-guarded.assertVoiceBaseUrlNetworkAllowed(desktopnet-guard.ts:256-297↔ CLIvoice-transcriber.ts:390-465): verified byte-equivalent today (modulo the CLI'sabortSignalshape difference), but per this script's own header, "a comment is not a mechanism" — and there isn't even one here.Failure scenario: a future one-sided edit — dropping
isAlwaysBlockedVoiceAddress(host) ||from a literal branch, inverting!allowInsecureBaseUrl &&in a DNS branch, or changing entry normalization on one surface — plus that surface's own test update passes CI green on both sides while the runtimes disagree about whether a metadata/transition/private voice baseUrl is blocked: the exact silent, unsafe-direction drift the guard exists to catch.Suggested fix: extract the decision core (e.g.
isBlockedVoiceNetworkTarget(host, allowInsecureBaseUrl)plus the message ternary) into shared-shape helpers and add them toMIRROR_SETS(aligning bodies first), or list both groups in the "Not covered" note and add keep-in-sync marker comments at each definition.中文说明
[Suggestion] 两组镜像的语音策略单元没有任何同步机制——不在
MIRROR_SETS中、定义处无 keep-in-sync 注释、也未列入脚本的 "Not covered" 说明:normalizeAllowedVoiceBaseUrl/isInsecureVoiceBaseUrlAllowed(CLIvoice-transcriber.ts:140-165↔ desktopresolve-voice-config.ts:427-451):当前语义相同但文本不同(trimTrailingSlashes(url.toString())vs 内联.replace(/\/+$/, ''))。这两个函数计算设计文档所称"每个出口路径都应用"的精确匹配判定,而它们的兄弟镜像(mergeModelProviders、resolveSettingsEnvVars、getSystemSettingsPath、DOTENV_LINE等)都有注释守卫。assertVoiceBaseUrlNetworkAllowed内部的策略组合与四条错误消息字符串(desktopnet-guard.ts:256-297↔ CLIvoice-transcriber.ts:390-465):已验证当前逐字节等价(除 CLI 的abortSignal形状差异),但按该脚本自己的头部注释,"注释不是机制"——而这里连注释都没有。失败场景:未来单侧修改——从字面量分支删掉
isAlwaysBlockedVoiceAddress(host) ||、在 DNS 分支反转!allowInsecureBaseUrl &&、或在某一端更改条目规范化——加上该端自身的测试更新,两端 CI 全绿,而两个运行时对"metadata/过渡/私网语音 baseUrl 是否被阻断"产生分歧:正是该守卫要捕获的静默、不安全方向漂移。建议修复:将判定核心(如
isBlockedVoiceNetworkTarget(host, allowInsecureBaseUrl)加消息三元运算)提取为共享形状的辅助函数并加入MIRROR_SETS(先对齐函数体),或将两组列入 "Not covered" 说明并在每个定义处添加 keep-in-sync 标记注释。— qwen3.8-max via Qwen Code /review (v0.21.6)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Deferred to a follow-up — Critical-only mode. The gap is real, but the suggested fixes (extracting a shared-shape decision helper into
MIRROR_SETSon both surfaces, or aligning bodies and adding markers) expand production code on both runtimes — out of scope for round 11, where only the Critical normalization holes are fixed. Deferred with this thread as the record.中文说明
延后到后续 PR——仅处理 Critical 模式。 缺口真实存在,但建议的修复方式(在两端提取共享形状的判定辅助函数并加入
MIRROR_SETS,或先对齐函数体再添加标记)会扩展两个运行时的生产代码——在第 11 轮(本轮仅修复 Critical 规范化漏洞)超出范围。随本讨论记录一并延后。