diff --git a/.changeset/rc-tunnel-base64-linear-check.md b/.changeset/rc-tunnel-base64-linear-check.md new file mode 100644 index 0000000000..c5f1cc79fc --- /dev/null +++ b/.changeset/rc-tunnel-base64-linear-check.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix Remote Control uploads larger than ~3.5MB always failing with a 400 error. diff --git a/packages/remote-control/src/remote-control.ts b/packages/remote-control/src/remote-control.ts index 1eaef02e20..cc6492a14d 100644 --- a/packages/remote-control/src/remote-control.ts +++ b/packages/remote-control/src/remote-control.ts @@ -541,7 +541,12 @@ class RemoteControlClient { ) { throw new SyntaxError('invalid HTTP tunnel request message'); } - const chunk = decodeBase64(parsed['body_base64']); + const bodyBase64 = parsed['body_base64']; + const minDecodedBytes = Math.floor(bodyBase64.length / 4) * 3 - 2; + if (this.pendingHttpBytes + minDecodedBytes > MAX_HTTP_REQUEST_BYTES) { + throw new SyntaxError('HTTP tunnel request exceeds 10 MiB'); + } + const chunk = decodeBase64(bodyBase64); const pending = this.pendingHttpRequests.get(requestId) ?? { chunks: [], size: 0 }; if (this.pendingHttpBytes + chunk.length > MAX_HTTP_REQUEST_BYTES) { throw new SyntaxError('HTTP tunnel request exceeds 10 MiB'); @@ -557,7 +562,8 @@ class RemoteControlClient { } catch (error) { if (requestId !== undefined) { this.clearPendingHttpRequest(requestId); - this.sendHttpResponse(requestId, buildErrorResponse(400)); + const status = error instanceof SyntaxError ? 400 : 502; + this.sendHttpResponse(requestId, buildErrorResponse(status)); } this.stderr.write(`Remote Control HTTP message error: ${errorMessage(error)}\n`); } @@ -1023,7 +1029,30 @@ function stringField( } function decodeBase64(value: string): Buffer { - if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + if (value.length % 4 !== 0) { + throw new SyntaxError('invalid HTTP tunnel request base64'); + } + let paddingStart = -1; + for (let i = 0; i < value.length; i++) { + const c = value.codePointAt(i)!; + if (c === 0x3d) { + if (paddingStart === -1) paddingStart = i; + continue; + } + if (paddingStart !== -1) { + throw new SyntaxError('invalid HTTP tunnel request base64'); + } + const ok = + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) || + (c >= 0x30 && c <= 0x39) || + c === 0x2b || + c === 0x2f; + if (!ok) { + throw new SyntaxError('invalid HTTP tunnel request base64'); + } + } + if (paddingStart !== -1 && value.length - paddingStart > 2) { throw new SyntaxError('invalid HTTP tunnel request base64'); } return Buffer.from(value, 'base64'); diff --git a/packages/remote-control/test/remote-control.test.ts b/packages/remote-control/test/remote-control.test.ts index e1da065908..10b59513c6 100644 --- a/packages/remote-control/test/remote-control.test.ts +++ b/packages/remote-control/test/remote-control.test.ts @@ -273,6 +273,7 @@ describe('Remote Control tunnel', () => { ); let localHttpRequest: IncomingMessage | undefined; + let localHttpBodyBytes = 0; let localWsRequest: IncomingMessage | undefined; const localWsServer = new WebSocketServer({ noServer: true }); const assetJs = `const boot = "/assets/boot.js";\n${'const chunk = "/assets/chunk.js";\n'.repeat(120)}`; @@ -307,13 +308,20 @@ describe('Remote Control tunnel', () => { response.end(assetText); return; } - response.writeHead(200, { - 'Content-Type': 'text/html', - 'Cache-Control': 'public, max-age=31536000, immutable', - Connection: 'X-Remove', - 'X-Remove': 'gone', + let bodyBytes = 0; + request.on('data', (chunk: Buffer) => { + bodyBytes += chunk.length; + }); + request.on('end', () => { + localHttpBodyBytes = bodyBytes; + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Cache-Control': 'public, max-age=31536000, immutable', + Connection: 'X-Remove', + 'X-Remove': 'gone', + }); + response.end('
'); }); - response.end(''); }); localServer.on('upgrade', (request, socket, head) => { localWsRequest = request; @@ -547,6 +555,25 @@ describe('Remote Control tunnel', () => { expect(rangeHead).not.toContain('Content-Encoding'); expect(rangeResponse.subarray(rangeSeparator + 4).toString()).toBe(assetText); + const largeBody = Buffer.alloc(4 * 1024 * 1024 + 512 * 1024, 0x61); + const largeRequest = Buffer.concat([ + Buffer.from(`POST /upload HTTP/1.1\r\nHost: relay.test\r\nContent-Length: ${largeBody.length}\r\n\r\n`), + largeBody, + ]); + const largeResponsePromise = nextJsonMessage(httpConnections[0]!); + httpConnections[0]!.send( + JSON.stringify({ + request_id: 'request-8', + type: 'request', + is_last: true, + body_base64: largeRequest.toString('base64'), + }), + ); + const largeResponseMessage = await largeResponsePromise; + const largeResponse = Buffer.from(largeResponseMessage['body_base64'] as string, 'base64').toString(); + expect(largeResponse).toContain('HTTP/1.1 200 OK'); + expect(localHttpBodyBytes).toBe(largeBody.length); + managementConnections[0]!.send( JSON.stringify({ type: 'open_ws',