Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 Aug 1, 2026
aa54f5a
fix(voice): address private endpoint review findings
rockybot2026 Aug 1, 2026
5b81f8f
test(voice): cover private endpoint edge cases
rockybot2026 Aug 1, 2026
395179b
test(voice): pin remaining endpoint edge cases
rockybot2026 Aug 1, 2026
797499a
fix(voice): address private endpoint review feedback
rockybot2026 Aug 2, 2026
0692403
fix(voice): clarify allowlist URL and normalize IPv6
rockybot2026 Aug 2, 2026
46c9b1a
fix(voice): harden NAT64 address validation
rockybot2026 Aug 2, 2026
0741498
fix(voice): address managed endpoint review findings
rockybot2026 Aug 2, 2026
18a56da
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 2, 2026
57de7aa
refactor(voice): extract shared IPv6 transition unwrap ladder (#8350)
qwen-code-dev-bot Aug 2, 2026
ebf63d6
test(voice): cover allowInsecureBaseUrl wiring through desktop defaul…
qwen-code-dev-bot Aug 2, 2026
3822724
Merge branch 'main' into feat/voice-private-base-url-allowlist
rockybot2026 Aug 2, 2026
3759465
fix(voice): add allowlist hint to private-network rejection error (#8…
qwen-code-ci-bot Aug 2, 2026
998e483
fix(voice): reject always-blocked base URLs before offering the allow…
qwen-code-ci-bot Aug 2, 2026
48cafaa
fix(voice): resolve exact desktop voice provider before OAuth (#8350)
qwen-code-ci-bot Aug 3, 2026
ed6922a
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 3, 2026
4073a4a
Merge branch 'feat/voice-private-base-url-allowlist' of https://githu…
qwen-code-dev-bot Aug 3, 2026
d562eeb
fix(voice): address review feedback for trusted private base URLs (#8…
qwen-code-dev-bot Aug 3, 2026
17ccec3
fix(voice): align desktop voice resolution with CLI semantics (#8350)
qwen-code-dev-bot Aug 3, 2026
ba08e5e
fix(voice): scope desktop fail-closed resolution to policy-bearing en…
qwen-code-dev-bot Aug 4, 2026
8a6f99a
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 4, 2026
bc6df33
fix(voice): address round-8 review findings for trusted private base …
qwen-code-dev-bot Aug 4, 2026
5f69a89
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 4, 2026
34b37ca
fix(voice): address round-9 review findings for trusted private base …
qwen-code-dev-bot Aug 5, 2026
a8c0bd2
fix(voice): address round-10 review findings for trusted private base…
qwen-code-dev-bot Aug 5, 2026
6f26b36
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 5, 2026
9cd5530
Merge branch 'main' into feat/voice-private-base-url-allowlist
wenshao Aug 5, 2026
f350652
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 5, 2026
59e1315
fix(voice): classify desktop voice duplicates before ambiguity check …
qwen-code-dev-bot Aug 5, 2026
ea59527
fix(scripts): compare voice guard mirrors as parse trees (#8350)
qwen-code-dev-bot Aug 6, 2026
ce6b480
Merge branch 'main' into feat/voice-private-base-url-allowlist
qwen-code-dev-bot Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions docs/design/trusted-private-voice-base-urls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Trusted private voice base URLs

## Status

Proposed 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. A configured voice provider receives the exception only when its normalized base URL exactly matches a list entry, including scheme, host, port, and path. 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.

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.

Desktop voice merges SystemDefaults, User, and System settings with the same trusted-scope precedence as the CLI; it never reads Workspace settings for this exception. It resolves the selected voice model before credentials and accepts exactly one provider entry with the same model ID. A non-DashScope provider can be selected only when its base URL is present in the exact allowlist, preventing an unrelated model or region from supplying the endpoint and API key.

## 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.

## 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. There is no migration because the default list is empty and existing settings retain their behavior.

## Verification

- Preserve default rejection for non-localhost HTTP and private endpoints.
- 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.
- Match Desktop credentials to one unambiguous provider with the selected voice model ID.
- Exercise both CLI and Desktop resolution and DNS guard paths.
17 changes: 9 additions & 8 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,14 +530,15 @@ LSP server configuration is done through `.lsp.json` files in your project root

#### security

| Setting | Type | Description | Default |
| ------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
| Setting | Type | Description | Default |
| --------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
| `security.allowedInsecureVoiceBaseUrls` | array of strings | Exact normalized voice provider base URLs that may use HTTP or resolve to private-network addresses. Wildcards are not supported; metadata and link-local addresses remain blocked. Only User, System, and SystemDefaults scopes are honored. Use only for trusted endpoints in managed private networks. | `[]` |

#### advanced

Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3332,6 +3332,91 @@ describe('Settings Loading and Merging', () => {
});
});

describe('allowedInsecureVoiceBaseUrls scope handling', () => {
it('should honor the allowlist from user scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
},
});
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
'http://voice.region-a.internal.example/v1',
]);
});

it('should strip and warn about the allowlist from workspace scope', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
allowedHttpHookUrls: ['https://hooks.example.com/*'],
},
});
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(
settings.merged.security?.allowedInsecureVoiceBaseUrls,
).toBeUndefined();
expect(settings.merged.security?.allowedHttpHookUrls).toEqual([
'https://hooks.example.com/*',
]);
expect(
getSettingsWarnings(settings).some((warning) =>
warning.includes('security.allowedInsecureVoiceBaseUrls'),
),
).toBe(true);
});

it('should preserve a user allowlist when workspace defines another', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-a.internal.example/v1',
],
},
});
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify({
security: {
allowedInsecureVoiceBaseUrls: [
'http://voice.region-b.internal.example/v1',
],
},
});
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.security?.allowedInsecureVoiceBaseUrls).toEqual([
'http://voice.region-a.internal.example/v1',
]);
});
});

describe('reloadScopeFromDisk', () => {
it('reloads a scope from disk and resolves home env vars', () => {
const homeQwenEnvPath = path.join(
Expand Down
35 changes: 23 additions & 12 deletions packages/cli/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,15 @@ export function getSettingsWarnings(loadedSettings: LoadedSettings): string[] {
`Warning: security.allowPrivateNetworkHooks in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
);
}
if (
workspaceFile.rawJson !== undefined &&
workspaceFile.originalSettings.security?.allowedInsecureVoiceBaseUrls !==
undefined
) {
warningSet.add(
`Warning: security.allowedInsecureVoiceBaseUrls in workspace settings (${workspaceFile.path}) is ignored. This setting is only honored from User, System, or SystemDefaults scope settings.`,
);
}

return [...warningSet];
}
Expand Down Expand Up @@ -399,18 +408,23 @@ function tagMcpServerScope(
}

/**
* `security.allowPrivateNetworkHooks` relaxes SSRF protection for HTTP hooks,
* so it must never be honored from Workspace scope — otherwise a malicious
* repository could self-grant the bypass and point hooks at link-local or
* private infrastructure. Strip it from workspace settings before merging.
* Network security bypasses must never be honored from Workspace scope —
* otherwise a malicious repository could self-grant access to private
* infrastructure. Strip them from workspace settings before merging.
* Returns a shallow copy — never mutates input.
*/
function stripWorkspacePrivateNetworkHooks(settings: Settings): Settings {
if (settings.security?.allowPrivateNetworkHooks === undefined) {
function stripWorkspaceSecurityBypasses(settings: Settings): Settings {
if (
settings.security?.allowPrivateNetworkHooks === undefined &&
settings.security?.allowedInsecureVoiceBaseUrls === undefined
) {
return settings;
}
const { allowPrivateNetworkHooks: _stripped, ...restSecurity } =
settings.security;
const {
allowPrivateNetworkHooks: _privateHooks,
allowedInsecureVoiceBaseUrls: _insecureVoice,
...restSecurity
} = settings.security;
return { ...settings, security: restSecurity };
}

Expand All @@ -422,10 +436,7 @@ function mergeSettings(
isTrusted: boolean,
): Settings {
const safeWorkspace = isTrusted
? tagMcpServerScope(
stripWorkspacePrivateNetworkHooks(workspace),
'workspace',
)
? tagMcpServerScope(stripWorkspaceSecurityBypasses(workspace), 'workspace')
: ({} as Settings);

// Settings are merged with the following precedence (last one wins for
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2940,6 +2940,20 @@ const SETTINGS_SCHEMA = {
'When true, HTTP hooks may target private/link-local IP ranges (the SSRF IP-range checks are skipped). Cloud metadata hostnames (e.g. 169.254.169.254, metadata.google.internal) remain blocked. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored so a cloned repository cannot self-grant this bypass. Enable only in trusted, managed environments, and pair with security.allowedHttpHookUrls.',
showInDialog: false,
},
allowedInsecureVoiceBaseUrls: {
type: 'array',
label: 'Allowed Insecure Voice Base URLs',
category: 'Security',
requiresRestart: false,
default: [] as string[],
description:
'Exact normalized voice base URLs that may use HTTP or private-network addresses. Wildcards are not supported, and metadata/link-local addresses remain blocked. Only honored from User, System, and SystemDefaults settings scopes; values set in Workspace settings are ignored. Enable only for trusted endpoints in managed private networks.',
showInDialog: false,
items: {
type: 'string',
description: 'Complete voice provider base URL (no wildcards)',
},
},
},
},

Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/serve/routes/workspace-voice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,40 @@ describe('workspace voice routes', () => {
expect(h.persistSetting).not.toHaveBeenCalled();
});

it('POST accepts an exactly allowlisted private voice provider', async () => {
const baseUrl = 'http://voice.region-a.internal.example/v1';
await writeJson(path.join(h.home, 'settings.json'), {
modelProviders: {
openai: [
{
id: 'qwen3-asr-flash',
label: 'Private Qwen ASR',
baseUrl,
envKey: 'PRIVATE_ASR_KEY',
},
],
},
env: { PRIVATE_ASR_KEY: 'sk-secret' },
security: { allowedInsecureVoiceBaseUrls: [baseUrl] },
voiceModel: 'qwen3-asr-flash',
});

const res = await request(h.app)
.post('/workspace/voice')
.set('Host', hostHeader)
.set('Authorization', 'Bearer secret')
.send({ enabled: true });

expect(res.status).toBe(200);
expect(h.persistSetting).toHaveBeenCalledWith(
h.workspace,
SettingScope.User,
'general.voice.enabled',
true,
expect.any(Function),
);
});

it('POST allows disabling voice without a selected model', async () => {
const res = await request(h.app)
.post('/workspace/voice')
Expand Down
Loading
Loading