Skip to content

Commit 797499a

Browse files
committed
fix(voice): address private endpoint review feedback
1 parent 395179b commit 797499a

10 files changed

Lines changed: 269 additions & 69 deletions

File tree

‎docs/design/trusted-private-voice-base-urls.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Voice transcription rejects non-loopback HTTP endpoints and endpoints that resol
1010

1111
## Design
1212

13-
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.
13+
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. Wildcards and hostname suffix matching are not supported.
1414

1515
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.
1616

@@ -22,23 +22,27 @@ The exact-match result travels with the resolved voice configuration so every eg
2222

2323
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.
2424

25-
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.
25+
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, 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.
2626

2727
## Configuration ownership
2828

2929
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.
3030

3131
## Failure and rollback behavior
3232

33-
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.
33+
Malformed entries and non-matches fail closed. Removing the entry immediately restores the existing HTTPS/public-network requirement after settings reload or process restart.
34+
35+
Desktop now treats a provider whose ID exactly matches the selected voice model as authoritative. Duplicate providers, a provider without a complete explicit base URL, an incomplete provider, or an unresolved provider key fail instead of silently falling back to environment credentials. Operators with such an existing entry must either complete it or remove it so the legacy DashScope/environment fallback can apply. This fail-closed behavior prevents an accidental fallback to a different provider or region.
3436

3537
## Verification
3638

3739
- Preserve default rejection for non-localhost HTTP and private endpoints.
40+
- Require allowlist entries to include an explicit scheme and full provider path on both CLI and Desktop.
3841
- Accept two unrelated regional private gateway URLs only when the selected URL exactly matches an entry.
3942
- Reject scheme, port, host, or path mismatches.
4043
- Reject non-HTTP(S) URL schemes even when exactly listed.
4144
- Ignore and warn about Workspace-scoped entries.
4245
- Continue rejecting link-local and cloud metadata addresses, including AWS IMDS IPv6, after an exact match.
46+
- Decode IPv4-mapped IPv6 literals consistently so trusted private addresses are accepted while mapped loopback and metadata addresses remain blocked.
4347
- Match Desktop credentials to one unambiguous provider with the selected voice model ID.
4448
- Exercise both CLI and Desktop resolution and DNS guard paths.

‎docs/users/configuration/settings.md‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -530,15 +530,15 @@ LSP server configuration is done through `.lsp.json` files in your project root
530530

531531
#### security
532532

533-
| Setting | Type | Description | Default |
534-
| --------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
535-
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
536-
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
537-
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
538-
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
539-
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
540-
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
541-
| `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. | `[]` |
533+
| Setting | Type | Description | Default |
534+
| --------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
535+
| `security.folderTrust.enabled` | boolean | Setting to track whether Folder trust is enabled. | `false` |
536+
| `security.auth.selectedType` | string | The currently selected authentication type. | `undefined` |
537+
| `security.auth.enforcedType` | string | The required auth type (useful for enterprises). | `undefined` |
538+
| `security.auth.useExternal` | boolean | Whether to use an external authentication flow. | `undefined` |
539+
| `security.auth.apiKey` | string | **Deprecated.** API key for OpenAI-compatible authentication. Migrate to `modelProviders` with `envKey` instead — see [Model Providers](./model-providers). | `undefined` |
540+
| `security.auth.baseUrl` | string | **Deprecated.** Base URL for the OpenAI-compatible API. Migrate to `modelProviders` instead — see [Model Providers](./model-providers). | `undefined` |
541+
| `security.allowedInsecureVoiceBaseUrls` | array of strings | Complete voice provider base URLs that may use HTTP or resolve to private-network addresses. Each entry must include an explicit `http://` or `https://` scheme and the full path (for example, `/v1`); only URL serialization and trailing slashes are normalized. 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. | `[]` |
542542

543543
#### advanced
544544

‎packages/cli/src/config/settingsSchema.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2947,11 +2947,12 @@ const SETTINGS_SCHEMA = {
29472947
requiresRestart: false,
29482948
default: [] as string[],
29492949
description:
2950-
'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.',
2950+
'Complete voice base URLs that may use HTTP or private-network addresses. Entries must include an explicit http:// or https:// scheme and the full provider path; only URL serialization and trailing slashes are normalized. 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.',
29512951
showInDialog: false,
29522952
items: {
29532953
type: 'string',
2954-
description: 'Complete voice provider base URL (no wildcards)',
2954+
description:
2955+
'Complete voice provider base URL with explicit scheme and full path (no wildcards)',
29552956
},
29562957
},
29572958
},

‎packages/cli/src/services/voice-transcriber.ts‎

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,30 @@ function readIpv4CompatibleIpv6(host: string): string | undefined {
202202
].join('.');
203203
}
204204

205+
function readIpv4MappedIpv6(host: string): string | undefined {
206+
const dotted = host.match(/^::ffff:(\d+(?:\.\d+){3})$/i);
207+
if (dotted && isIP(dotted[1]!) === 4) {
208+
return dotted[1];
209+
}
210+
const hex = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
211+
if (!hex) {
212+
return undefined;
213+
}
214+
const high = Number.parseInt(hex[1]!, 16);
215+
const low = Number.parseInt(hex[2]!, 16);
216+
return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
217+
}
218+
205219
// Blocks IP-literal private networks only. Hostname DNS resolution and
206220
// rebinding protection require an async lookup or socket-level remoteAddress check.
207221
function isPrivateNetworkIp(hostname: string): boolean {
208222
const host = normalizeHostname(hostname);
209223
if (isLoopbackHost(host)) {
210224
return false;
211225
}
212-
const ipv4Mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
226+
const ipv4Mapped = readIpv4MappedIpv6(host);
213227
if (ipv4Mapped) {
214-
return isPrivateNetworkIp(ipv4Mapped[1]!);
228+
return isPrivateNetworkIp(ipv4Mapped);
215229
}
216230
const ipv4Compatible = host.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
217231
if (ipv4Compatible) {
@@ -250,9 +264,9 @@ function isAlwaysBlockedVoiceAddress(hostname: string): boolean {
250264
if (isLoopbackHost(host)) {
251265
return true;
252266
}
253-
const ipv4Mapped = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
267+
const ipv4Mapped = readIpv4MappedIpv6(host);
254268
if (ipv4Mapped) {
255-
return isAlwaysBlockedVoiceAddress(ipv4Mapped[1]!);
269+
return isAlwaysBlockedVoiceAddress(ipv4Mapped);
256270
}
257271
const ipv4Compatible = host.match(/^::(\d+\.\d+\.\d+\.\d+)$/);
258272
if (ipv4Compatible) {
@@ -302,9 +316,8 @@ export async function assertVoiceBaseUrlNetworkAllowed(
302316
}
303317
if (isIP(hostname) !== 0) {
304318
if (
305-
isPrivateNetworkIp(hostname) &&
306-
(!voiceConfig.allowInsecureBaseUrl ||
307-
isAlwaysBlockedVoiceAddress(hostname))
319+
isAlwaysBlockedVoiceAddress(hostname) ||
320+
(!voiceConfig.allowInsecureBaseUrl && isPrivateNetworkIp(hostname))
308321
) {
309322
throw new Error(
310323
`Voice model '${voiceConfig.model}' resolved to a private-network address.`,
@@ -343,10 +356,11 @@ export async function assertVoiceBaseUrlNetworkAllowed(
343356
}
344357
const records = Array.isArray(result) ? result : [result];
345358
if (
346-
records.some((record) =>
347-
voiceConfig.allowInsecureBaseUrl
348-
? isAlwaysBlockedVoiceAddress(record.address)
349-
: isPrivateNetworkIp(record.address),
359+
records.some(
360+
(record) =>
361+
isAlwaysBlockedVoiceAddress(record.address) ||
362+
(!voiceConfig.allowInsecureBaseUrl &&
363+
isPrivateNetworkIp(record.address)),
350364
)
351365
) {
352366
throw new Error(
@@ -432,14 +446,13 @@ export function resolveVoiceTranscriptionConfig({
432446
!allowInsecureBaseUrl
433447
) {
434448
throw new Error(
435-
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext.`,
449+
`Voice model '${voiceModel}' must use an https baseUrl. Voice audio must not be transmitted in cleartext. To trust this managed endpoint, add its exact complete URL to security.allowedInsecureVoiceBaseUrls.`,
436450
);
437451
}
438452
if (
439453
!isLocalhost &&
440-
isPrivateNetworkIp(parsedBaseUrl.hostname) &&
441-
(!allowInsecureBaseUrl ||
442-
isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname))
454+
(isAlwaysBlockedVoiceAddress(parsedBaseUrl.hostname) ||
455+
(!allowInsecureBaseUrl && isPrivateNetworkIp(parsedBaseUrl.hostname)))
443456
) {
444457
throw new Error(
445458
`Voice model '${voiceModel}' must not use a private-network baseUrl.`,

0 commit comments

Comments
 (0)