fix(desktop): reject credentialed voice base URLs; scope batch byte cap to batch

Address qwen-code-ci-bot review on the desktop voice ASR handler.

- resolve-voice-config: normalizeBaseUrl now throws on embedded userinfo
  instead of stripping it. For `https://real-host@evil.com/...` the URL
  parser already resolves `evil.com` as the host, so clearing userinfo
  afterward would silently proceed with an attacker-controlled host. A
  legitimate voice base URL never carries credentials.
- voice-ws-handler: MAX_AUDIO_BYTES (~5.5-min / 10 MB) is the batch WAV/file
  cap. Streaming forwards frames immediately and is bounded by
  MAX_CONNECTION_MS (the 6-min hard timer) + queuedBytes, so counting
  already-forwarded frames toward the batch cap cut legit streams off ~30 s
  early. Enforce the file cap for batch sessions only; the batch 10 MB cap
  is unchanged.

Co-Authored-By: Qwen-Coder <noreply@qwen.ai>
This commit is contained in:
qqqys 2026-06-26 20:20:13 +08:00
parent cb1c20ebd5
commit 8b19cffcff
4 changed files with 91 additions and 10 deletions

View file

@ -179,4 +179,16 @@ describe('normalizeBaseUrl', () => {
'https://proxy.example.com/v1/dashscope',
)
})
// Credentials must never be sent. `real-host@evil.com` already parses with
// host evil.com, so stripping userinfo would hide that the configured host is
// attacker-controlled — reject the URL outright instead.
it('rejects base URLs that embed credentials instead of stripping them', () => {
expect(() =>
normalizeBaseUrl('https://dashscope.aliyuncs.com@evil.com/compatible-mode'),
).toThrow('must not contain embedded credentials')
expect(() =>
normalizeBaseUrl('https://user:pass@proxy.example.com/v1'),
).toThrow('must not contain embedded credentials')
})
})

View file

@ -40,25 +40,31 @@ interface ResolveDesktopVoiceConfigDeps {
/**
* Normalize a base URL: prepend `https://` when no scheme is present (an explicit
* `http://` is preserved here and rejected later by the cleartext guard), strip
* trailing slashes, and ensure a `/v1` suffix. Exported for tests.
* trailing slashes, and ensure a `/v1` suffix. Throws on embedded credentials
* (`user:pass@host`), which a legitimate endpoint never carries. Exported for tests.
*/
export function normalizeBaseUrl(raw: string): string {
const trimmed = raw.trim().replace(/\/+$/, '');
const withProto = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
let url: URL;
try {
const url = new URL(withProto);
// Drop any userinfo so a user:pass@host base URL can't leak credentials.
url.username = '';
url.password = '';
if (!url.pathname.split('/').includes('v1')) {
url.pathname = `${url.pathname.replace(/\/$/, '')}/v1`;
}
return url.toString().replace(/\/$/, '');
url = new URL(withProto);
} catch {
return withProto.includes('/v1') ? withProto : `${withProto}/v1`;
}
// Reject embedded credentials rather than stripping them: for
// `https://real-host@evil.com/...` the parser has already resolved `evil.com`
// as the host, so clearing userinfo afterward would silently proceed with the
// attacker-controlled host. A legitimate voice base URL never carries creds.
if (url.username || url.password) {
throw new Error('Voice base URL must not contain embedded credentials.');
}
if (!url.pathname.split('/').includes('v1')) {
url.pathname = `${url.pathname.replace(/\/$/, '')}/v1`;
}
return url.toString().replace(/\/$/, '');
}
/**

View file

@ -261,4 +261,62 @@ describe('createVoiceConnectionHandler', () => {
message: 'Too many pending voice messages.',
})
})
// Streaming frames are forwarded immediately, so the batch 10 MB/~5-min file
// cap must not cut a stream off before the 6-min hard timer. Two 6 MB frames
// exceed MAX_AUDIO_BYTES cumulatively while each stays under the queued-bytes
// limit (drained between flushes).
it('keeps forwarding streaming audio past the batch byte cap', async () => {
const pushed: Uint8Array[] = []
const ws = new FakeWebSocket()
const handler = createVoiceConnectionHandler({
resolveConfig: () => ({
model: 'qwen3-asr-flash-realtime',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}),
openStream: async () => ({
pushAudio: (pcm) => pushed.push(pcm),
finish: async () => 'final transcript',
abort: () => {},
}),
})
handler(ws as never)
ws.emitMessage(JSON.stringify({ type: 'start' }))
await flush()
ws.emitMessage(Buffer.alloc(6 * 1024 * 1024), true)
await flush()
ws.emitMessage(Buffer.alloc(6 * 1024 * 1024), true)
await flush()
expect(pushed).toHaveLength(2)
expect(ws.sentJson()).not.toContainEqual({
type: 'error',
message: 'Recording is too long for transcription (max ~5 minutes).',
})
})
// The batch file cap stays intact: an 11 MB batch frame (over MAX_AUDIO_BYTES
// but under the 20 MB queued-bytes limit) must be rejected.
it('still rejects batch audio that exceeds the file byte cap', async () => {
const ws = new FakeWebSocket()
const handler = createVoiceConnectionHandler({
resolveConfig: () => ({
model: 'qwen3-asr-flash',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
}),
transcribeBatch: async () => 'unused',
})
handler(ws as never)
ws.emitMessage(JSON.stringify({ type: 'start' }))
await flush()
ws.emitMessage(Buffer.alloc(11 * 1024 * 1024), true)
await flush()
expect(ws.sentJson()).toContainEqual({
type: 'error',
message: 'Recording is too long for transcription (max ~5 minutes).',
})
})
})

View file

@ -343,7 +343,12 @@ export function createVoiceConnectionHandler(
await ensureStarted();
if (isClosed() || !ctx) return;
bufferedBytes += data.byteLength;
if (bufferedBytes > MAX_AUDIO_BYTES) {
// MAX_AUDIO_BYTES is the batch file ceiling (Qwen-ASR caps each WAV at
// 10 MB / ~5 min). Streaming forwards frames immediately and is bounded
// by MAX_CONNECTION_MS (the 6-min hard timer) + queuedBytes, so counting
// already-forwarded frames toward the batch cap would cut a legit stream
// off ~30 s early — enforce the file cap for batch only.
if (!ctx.streaming && bufferedBytes > MAX_AUDIO_BYTES) {
fail('Recording is too long for transcription (max ~5 minutes).');
return;
}