From d5ac1ea11dcd1b83bd5fddf322acdb9e0c27b0ac Mon Sep 17 00:00:00 2001 From: Sean Carey Date: Sun, 4 Oct 2026 09:06:24 +0900 Subject: [PATCH] fix: reject non-object body and validate messages array (fixes #3) --- server.js | 42 ++++++++++++++++++++++++++++++---- test/transform.test.js | 52 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/server.js b/server.js index 32a9b8b..5709faa 100644 --- a/server.js +++ b/server.js @@ -89,6 +89,12 @@ function imagePart(url) { } function transform(oaiBody) { + if (!oaiBody || typeof oaiBody !== 'object' || Array.isArray(oaiBody)) { + throw new Error('body must be a JSON object'); + } + if (oaiBody.messages !== undefined && !Array.isArray(oaiBody.messages)) { + throw new Error('messages must be an array'); + } const model = oaiBody.model || 'deepseek/deepseek-v4-pro'; let systemText = ''; const messages = []; @@ -186,13 +192,19 @@ function errorText(evt) { return evt.error?.message || evt.message || JSON.stringify(evt.error ?? evt); } +const MAX_EVENT_BUFFER_BYTES = 10 * 1024 * 1024; + // Reads NDJSON lines off the upstream response and hands each parsed event to -// onEvent. Buffers partial lines across chunks without a size cap. +// onEvent. Buffers partial lines across chunks with a size cap. function readEvents(proxyRes, onEvent, onEnd) { let buf = ''; proxyRes.setEncoding('utf8'); proxyRes.on('data', chunk => { buf += chunk; + if (buf.length > MAX_EVENT_BUFFER_BYTES) { + proxyRes.destroy(new Error('upstream NDJSON event line exceeded 10MB limit')); + return; + } let nl; while ((nl = buf.indexOf('\n')) !== -1) { const line = buf.slice(0, nl).trim(); @@ -333,7 +345,12 @@ function handleUpstreamResponse(proxyRes, res, model, isStream, t0) { proxyRes.on('error', e => { logErr(`[upstream stream] ${netError(e)}`); if (!res.headersSent) sendError(res, 502, netError(e)); - else if (!res.writableEnded) res.end(); + else if (!res.writableEnded) { + if (isStream) { + res.write(`data: ${JSON.stringify({ error: { message: netError(e), type: 'upstream_error' } })}\n\n`); + } + res.end(); + } }); } @@ -363,9 +380,17 @@ function handleRequest(req, res) { if (res.headersSent) return; const body = Buffer.concat(chunks).toString('utf8'); let oai; try { oai = JSON.parse(body); } catch { sendError(res, 400, 'Invalid JSON', 'invalid_request_error'); return; } + if (!oai || typeof oai !== 'object' || Array.isArray(oai)) { + sendError(res, 400, 'Request body must be a JSON object', 'invalid_request_error'); + return; + } + if (oai.messages !== undefined && !Array.isArray(oai.messages)) { + sendError(res, 400, 'messages must be an array', 'invalid_request_error'); + return; + } const model = oai.model || '-', isStream = oai.stream === true; const t0 = Date.now(); - logReq(`${model} | ${req.socket.remoteAddress || '-'} | ${isStream ? 'stream' : 'sync'} | ${size} bytes`); + logReq(`${model} | ${req.socket?.remoteAddress || '-'} | ${isStream ? 'stream' : 'sync'} | ${size} bytes`); let upstream; try { upstream = transform(oai); } catch (e) { sendError(res, 400, `transform error: ${e.message}`, 'invalid_request_error'); return; } @@ -392,9 +417,18 @@ function handleRequest(req, res) { }); } -module.exports = { transform, toOpenAIUsage, toFinishReason }; +module.exports = { transform, toOpenAIUsage, toFinishReason, handleRequest }; if (require.main === module) { + process.on('uncaughtException', err => { + logErr(`uncaughtException: ${err && err.stack ? err.stack : err}`); + process.exit(1); + }); + process.on('unhandledRejection', reason => { + logErr(`unhandledRejection: ${reason && reason.stack ? reason.stack : reason}`); + process.exit(1); + }); + log(`proxy-commandcode | listening :${PORT} | upstream ${HOST}${PATH} | CC ${ccVersion}${PINNED_VERSION ? ' (pinned)' : ''} | debug ${DEBUG ? 'on' : 'off'}`); refreshVersion(); setInterval(refreshVersion, VERSION_REFRESH_MS).unref(); diff --git a/test/transform.test.js b/test/transform.test.js index 9077728..aa562e8 100644 --- a/test/transform.test.js +++ b/test/transform.test.js @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { transform, toOpenAIUsage, toFinishReason } = require('../server.js'); +const { transform, toOpenAIUsage, toFinishReason, handleRequest } = require('../server.js'); +const { EventEmitter } = require('node:events'); test('builds the strict envelope and always streams upstream', () => { const body = JSON.parse(transform({ model: 'zai-org/GLM-5.2', stream: false, max_tokens: 100, messages: [{ role: 'user', content: 'hi' }] })); @@ -68,3 +69,52 @@ test('finish reasons', () => { assert.equal(toFinishReason('stop', true), 'tool_calls'); assert.equal(toFinishReason(undefined, false), 'stop'); }); + +test('transform rejects null, arrays, and non-array messages', () => { + assert.throws(() => transform(null), /body must be a JSON object/); + assert.throws(() => transform([]), /body must be a JSON object/); + assert.throws(() => transform({ messages: 'not-an-array' }), /messages must be an array/); +}); + +test('handleRequest rejects null body with 400', async () => { + const req = new EventEmitter(); + req.method = 'POST'; + req.url = '/v1/chat/completions'; + req.headers = {}; + + let status = null; + let responseData = ''; + const res = { + writeHead(code, headers) { status = code; }, + end(data) { responseData = data; }, + }; + + handleRequest(req, res); + req.emit('data', Buffer.from('null')); + req.emit('end'); + + assert.equal(status, 400); + assert.match(responseData, /Request body must be a JSON object/); +}); + +test('handleRequest rejects non-array messages with 400', async () => { + const req = new EventEmitter(); + req.method = 'POST'; + req.url = '/v1/chat/completions'; + req.headers = {}; + + let status = null; + let responseData = ''; + const res = { + writeHead(code, headers) { status = code; }, + end(data) { responseData = data; }, + }; + + handleRequest(req, res); + req.emit('data', Buffer.from(JSON.stringify({ messages: 'invalid' }))); + req.emit('end'); + + assert.equal(status, 400); + assert.match(responseData, /messages must be an array/); +}); +