mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-22 23:44:11 +00:00
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:
parent
cb1c20ebd5
commit
8b19cffcff
4 changed files with 91 additions and 10 deletions
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue