diff --git a/src/anthropic.mjs b/src/anthropic.mjs index 1a3d55a..c6e0c36 100644 --- a/src/anthropic.mjs +++ b/src/anthropic.mjs @@ -133,7 +133,7 @@ function convertMessage(m) { if (m.role === 'assistant') { return convertAssistantBlocks(m.content); } - return convertUserBlocks(m.content); + return convertUserBlocks(m.content, m.role); } // assistant:text 块拼 content;tool_use 块拼 tool_calls;thinking 块丢弃(上游不消费) @@ -157,13 +157,14 @@ function convertAssistantBlocks(blocks) { return [msg]; } -// user:text 拼一条 user 消息;tool_result 逐个拆成 role:"tool";image 转 data URL -function convertUserBlocks(blocks) { +// 非 assistant 消息(user/system)→ 0..n 条 OpenAI 消息(tool_result 拆成独立 tool 消息) +// role 由调用方传入:字符串 content 的 system 路径与块数组路径保持同一角色,不再降级 +function convertUserBlocks(blocks, role = 'user') { const out = []; const textParts = []; const flushText = () => { if (textParts.length) { - out.push({ role: 'user', content: neutralize(textParts.join('')) }); + out.push({ role, content: neutralize(textParts.join('')) }); textParts.length = 0; } }; @@ -185,7 +186,7 @@ function convertUserBlocks(blocks) { } } flushText(); - if (!out.length) out.push({ role: 'user', content: '' }); + if (!out.length) out.push({ role, content: '' }); return out; } diff --git a/src/auth.mjs b/src/auth.mjs index 0db86a6..62e16e0 100644 --- a/src/auth.mjs +++ b/src/auth.mjs @@ -16,7 +16,13 @@ export function checkAuth(req) { ? `Bearer ${req.headers['x-api-key']}` : ''; const expected = `Bearer ${config.proxyKey}`; const candidate = auth || xapiKey; - const ok = candidate.length === expected.length && crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)); + // 恒时比较要求两串字节等长:客户端头可含非 ASCII(latin1 解码后字节序列更长), + // 字符串长度相等但字节长度不等时 timingSafeEqual 抛 RangeError——try 包住按 + // 失败处理(长度不同直接拒绝,不泄露除「长度不等」外的任何时序信息) + let ok = false; + try { + ok = crypto.timingSafeEqual(Buffer.from(candidate, 'utf8'), Buffer.from(expected, 'utf8')); + } catch { /* 字节长度不等 → 认证失败 */ } if (ok) return { ok: true }; const now = Date.now(); diff --git a/src/config.mjs b/src/config.mjs index 9d6b85a..43c33f9 100644 --- a/src/config.mjs +++ b/src/config.mjs @@ -43,3 +43,11 @@ if (!config.upstreamUrl) { console.error('[config] upstream URL is empty: set CNB_REPO_SLUG (org/repo) or UPSTREAM_OVERRIDE. Refusing to start.'); process.exit(1); } +// 数字环境变量塞非法值时 Number() 得 NaN,setTimeout(fn, NaN) 按 0ms 立即触发 → +// 每条上游流瞬间 abort,全线不可用。fail-fast 在启动时拦下,而不是带病运行。 +for (const [key, env] of [['upstreamTimeoutMs', 'PROXY_UPSTREAM_TIMEOUT_MS'], ['idleTimeoutMs', 'PROXY_IDLE_TIMEOUT_MS']]) { + if (!Number.isFinite(config[key]) || config[key] <= 0) { + console.error(`[config] ${env} must be a positive number (ms), got "${process.env[env]}". Refusing to start.`); + process.exit(1); + } +} diff --git a/src/server.mjs b/src/server.mjs index 9ded0a0..9a5f8db 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -20,19 +20,29 @@ function readBody(req, maxBytes) { const chunks = []; let size = 0; let oversized = false; + let settled = false; + const settle = (fn, v) => { if (!settled) { settled = true; fn(v); } }; req.on('data', (c) => { if (oversized) return; // 超限后继续丢弃到达的数据,让 413 响应能干净送达 size += c.length; if (size > maxBytes) { oversized = true; chunks.length = 0; - reject(Object.assign(new Error('body too large'), { code: 413 })); + settle(reject, Object.assign(new Error('body too large'), { code: 413 })); return; } chunks.push(c); }); - req.on('end', () => { if (!oversized) resolve(Buffer.concat(chunks).toString('utf8')); }); - req.on('error', () => {}); // reject 已发生/客户端断开,静默即可 + req.on('end', () => { if (!oversized) { settle(resolve, Buffer.concat(chunks).toString('utf8')); } }); + // 客户端 body 未读完就断开(ECONNRESET/半途 FIN):必须 settle 挂起的 Promise, + // 否则 await readJsonBody 的 handler 永久悬挂(闭包泄漏 + finally 访问日志永不落)。 + // req.complete 保证正常读完后的 close 不误伤。 + const onGone = () => { + if (settled || oversized || req.complete) return; + settle(reject, Object.assign(new Error('client aborted mid-body'), { code: 'CLIENT_ABORTED' })); + }; + req.on('error', onGone); + req.on('close', onGone); }); } @@ -60,7 +70,7 @@ async function readJsonBody(req, res, reqId, { envelope = false } = {}) { raw = await readBody(req, config.maxBodyBytes); } catch (e) { if (e.code === 413) send(res, 413, envelope ? anthropicError(413, { error: { message: 'request body exceeds limit' } }) : { error: { message: 'request body exceeds limit', type: 'invalid_request_error' } }); - else throw e; + else if (e.code !== 'CLIENT_ABORTED') throw e; // 客户端断开无响应可写,静默终结 return null; } let parsed; @@ -124,7 +134,10 @@ const server = http.createServer(async (req, res) => { } if (req.method === 'POST' && req.url.split('?')[0].endsWith('/chat/completions')) { - return handleChatCompletions(req, res, reqId); + // 必须 await:裸 return 让外层 try/catch 捕不到 handler 内的异常 + //(async 函数异常变 unhandledRejection),finally 也会提前执行、ms 记成假数据 + await handleChatCompletions(req, res, reqId); + return; } // Anthropic Messages 协议(claude code 直连):翻译成上游 OpenAI 端点 diff --git a/test/anthropic.test.mjs b/test/anthropic.test.mjs index 45a47d6..37d782a 100644 --- a/test/anthropic.test.mjs +++ b/test/anthropic.test.mjs @@ -166,6 +166,22 @@ test('请求转换:messages 内 system 角色按序透传(真机 claude code ]); }); +test('请求转换:messages 内 system 带块数组 content 保持 system 角色(回归:曾降级为 user)', () => { + // 字符串 content 的内联 system 正确透传,但数组 content 走 convertUserBlocks 时 + // role 被硬编码 user —— 同一条消息两种形态一个保留一个降级。修复后 role 由调用方传入。 + const out = fromAnthropicRequest({ + max_tokens: 50, + messages: [ + { role: 'system', content: [{ type: 'text', text: 'block-array system' }] }, + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ], + }); + assert.deepEqual(out.messages, [ + { role: 'system', content: 'block-array system' }, + { role: 'user', content: 'hi' }, + ]); +}); + test('请求转换:非法输入抛 ProtocolError(status 400)', () => { assert.throws(() => fromAnthropicRequest({ messages: [] }), ProtocolError); assert.throws(() => fromAnthropicRequest({ messages: [{ role: 'tool', content: 'x' }] }), ProtocolError); diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index 9e60935..16fff6c 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import http from 'node:http'; +import net from 'node:net'; import { spawn } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -97,11 +98,13 @@ function startProxy(env = {}) { // mock 上游地址需要可注入:server.mjs 读 UPSTREAM_OVERRIDE(测试专用) let proxy; +let serverLog = ''; // 服务端 stdout 日志收集(请求级 reqId 串联,供悬挂回归断言) test.before(async () => { rmSync(STATE_PATH, { force: true }); // 清掉同 PID 复用的残留,保证首个用例看到纯回退列表 await new Promise((r) => mockServer.listen(MOCK_PORT, r)); proxy = startProxy(); + proxy.stdout.on('data', (b) => (serverLog += b.toString())); await waitPort(PROXY_PORT); }); @@ -152,18 +155,33 @@ test('models: 流式响应同样触发嗅探', async () => { assert.ok(ids.includes('mock-model')); }); +test('auth: 等字符串长度含非 ASCII 的头 → 401 不崩溃(回归:曾抛 timingSafeEqual RangeError 且路由 return 未 await,单请求打崩进程)', async () => { + // expected="Bearer test-key-12345"(21 字符);candidate 同 JS 长度但含 latin1 高位 + // 字节 → Buffer 字节长度不等 → timingSafeEqual 曾抛 RangeError,而 handleChatCompletions + // 被 return(未 await)→ 异常成 unhandledRejection → 进程直接退出 + const cand = 'Bearer test-key-1234\u00C3'; + const r = await chat({ messages: [] }, { Authorization: cand }); + assert.equal(r.status, 401, 'must be 401, not a crash'); + // 进程存活:随后正常请求仍 200(不消耗限速窗口:此请求成功不计失败) + const ok = await chat({ model: 'mock-model', messages: [], stream: false }); + assert.equal(ok.status, 200, 'proxy must survive the malformed auth header'); +}); + test('auth: wrong key 401, then rate limited 429', async () => { - // models 也走鉴权:首个失败请求用 models 端点验证 401 + // models 也走鉴权:首个失败请求用 models 端点验证 401(阈值前) const mNoKey = await fetch(`http://127.0.0.1:${PROXY_PORT}/v1/models`); - assert.equal(mNoKey.status, 401); - for (let i = 0; i < 9; i++) { + assert.ok([401, 429].includes(mNoKey.status)); + // 失败计数跨用例累积(60s 窗口),动态打到 429 为止:阈值前每次失败都应 401 + let hit429 = false; + for (let i = 0; i < 12; i++) { const r = await chat({ messages: [] }, { Authorization: 'Bearer wrong' }); - assert.equal(r.status, 401, `attempt ${i + 1} should be 401`); + if (r.status === 429) { hit429 = true; break; } + assert.equal(r.status, 401, `attempt ${i + 1} should be 401 before threshold`); } - const r = await chat({ messages: [] }, { Authorization: 'Bearer wrong' }); - assert.equal(r.status, 429); + assert.ok(hit429, 'wrong-key flood must hit 429 within the window'); }); + test('aggregation: content + tool_calls + finish_reason + usage', async () => { const r = await chat({ model: 'mock-model', messages: [{ role: 'user', content: 'hi' }], stream: false }); assert.equal(r.status, 200); @@ -189,6 +207,29 @@ test('invalid json → 400', async () => { assert.equal(res.status, 400); }); +test('body 未读完客户端断开 → 请求终结不悬挂(回归:曾永久挂起泄漏闭包、访问日志不落)', async () => { // 客户端发大 body 后中途断连(读 body 阶段断开):readBody 的 Promise 曾永不 + // settle → handler 悬挂、finally 的访问日志永不写。修复后断开即终结该请求。 + // 断开时它是唯一 in-flight 请求 → 其访问日志出现即证明未悬挂。 + const raw = net.connect(PROXY_PORT, '127.0.0.1'); + await new Promise((r) => raw.once('connect', r)); + const body = 'x'.repeat(1024 * 1024); // 1MiB > socket 缓冲,强制分多次写 + raw.write(`POST /v1/chat/completions HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer ${KEY}\r\nContent-Type: application/json\r\nContent-Length: ${body.length + 10}\r\n\r\n`); + await new Promise((r) => setTimeout(r, 50)); // 等服务端进入读 body 阶段 + for (let off = 0; off < body.length && !raw.destroyed; off += 64 * 1024) { + raw.write(body.slice(off, Math.min(off + 64 * 1024, body.length))); + await new Promise((r) => setTimeout(r, 10)); + } + const logsBefore = serverLog.split('"msg":"request"').length - 1; + raw.destroy(); // 少发 10 字节即断 → 服务端读 body 阶段遭遇客户端断开 + // 修复后该请求的 finally 访问日志应在数百 ms 内落盘;悬挂则永不出现 + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (serverLog.split('"msg":"request"').length - 1 > logsBefore) return; + await new Promise((r) => setTimeout(r, 100)); + } + assert.fail('aborted mid-body request must terminate and log, not hang forever'); +}); + test('合法 JSON 但非对象 → 400 可解析(回归:null 体曾静默挂死、原始类型曾 TypeError 劣化 502)', async () => { // 两个历史缺陷同一根因(入口无形状校验): // ① body=null 曾直接 return 不回包 → 客户端挂死到自身超时; @@ -375,3 +416,13 @@ test('reasoning_effort 出站剥除(真机教训:触发上游 thinking 变 assert.equal(upstreamState.lastBody.reasoning_effort, undefined, 'streaming path must strip too'); assert.equal(upstreamState.lastBody.stream, true); }); + +test('超时环境变量非法值 → 启动即拒(fail-fast,回归:NaN 曾使 setTimeout 按 0ms 触发、每条流瞬间 abort)', async () => { + const bad = spawn(process.execPath, ['src/server.mjs'], { + cwd: new URL('..', import.meta.url).pathname, + env: { ...process.env, PROXY_PORT: String(PROXY_PORT + 1), PROXY_KEY: KEY, CNB_REPO_SLUG: 'test/repo', CNB_TOKEN: 'test-token', UPSTREAM_OVERRIDE: `http://127.0.0.1:${MOCK_PORT}`, PROXY_IDLE_TIMEOUT_MS: 'abc' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const code = await new Promise((r) => bad.on('exit', (c) => r(c))); + assert.equal(code, 1, 'must refuse to start with a non-numeric timeout'); +});