From 34af56d65f342d2ad6ffc3d53ce593db53b17053 Mon Sep 17 00:00:00 2001 From: Alfredo Cristofano Date: Fri, 24 Apr 2026 23:07:49 +0200 Subject: [PATCH 1/5] security: harden server against DoS and info disclosure - Add 10 MiB body size limit to prevent OOM - Validate sessionId length/format and cap sessionSeqs map size - Return generic 500/404 messages instead of internal details - Strip query string from request logs - Add security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy) - Filter sensitive upstream headers (set-cookie, server, via, x-request-id) - Add runtime validation for Anthropic request body Co-Authored-By: Claude Opus 4.6 --- src/server.ts | 110 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 9 deletions(-) diff --git a/src/server.ts b/src/server.ts index 54b56c6b..4b5559e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,10 +10,31 @@ export interface ServeOptions { port: number } +const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB +const MAX_SESSION_ID_LEN = 128 +const SESSION_ID_RE = /^[a-zA-Z0-9_-]+$/ +const MAX_SESSION_SEQ_ENTRIES = 10000 + const sessionSeqs = new Map() +let sessionSeqsLastCleanup = Date.now() + +function maybeCleanupSessionSeqs(): void { + if (sessionSeqs.size < MAX_SESSION_SEQ_ENTRIES) return + if (Date.now() - sessionSeqsLastCleanup < 60000) return + const cutoff = Date.now() - 3600000 // 1h TTL + for (const [k, v] of sessionSeqs) { + if (v < cutoff) sessionSeqs.delete(k) + } + sessionSeqsLastCleanup = Date.now() +} function nextSessionSeq(sessionId?: string): number | undefined { if (!sessionId) return undefined + if (sessionId.length > MAX_SESSION_ID_LEN || !SESSION_ID_RE.test(sessionId)) { + rootLog.warn("invalid sessionId rejected", { length: sessionId.length }) + return undefined + } + maybeCleanupSessionSeqs() const seq = (sessionSeqs.get(sessionId) ?? 0) + 1 sessionSeqs.set(sessionId, seq) return seq @@ -32,7 +53,6 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe reqId, method: req.method, path: url.pathname, - query: url.search, }) try { const resp = await route(req, url, reqId) @@ -46,7 +66,7 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe return new Response(null, { status: 499 }) } rootLog.error("handler error", { reqId, err: String(err), stack: (err as Error)?.stack }) - return jsonError(500, "internal_error", String(err)) + return jsonError(500, "internal_error", "Internal Server Error") } }, }) @@ -59,9 +79,9 @@ export function startServer(opts: ServeOptions): { stop: () => void; port: numbe async function route(req: Request, url: URL, reqId: string): Promise { if (url.pathname === "/healthz") { - return new Response(JSON.stringify({ ok: true }), { - headers: { "content-type": "application/json" }, - }) + const headers = new Headers({ "content-type": "application/json" }) + addSecurityHeaders(headers) + return new Response(JSON.stringify({ ok: true }), { headers }) } if (req.method === "POST" && url.pathname === "/v1/messages/count_tokens") { @@ -84,7 +104,7 @@ async function route(req: Request, url: URL, reqId: string): Promise { return provider.handleMessages(body, ctx) } - return jsonError(404, "not_found", `No route for ${req.method} ${url.pathname}`) + return jsonError(404, "not_found", "Not Found") } function buildCtx(req: Request, reqId: string, providerName: string): RequestContext { @@ -134,9 +154,54 @@ function knownModelsMessage(): string { return `Supported: ${parts.join("; ")}.` } +function validateBody(body: unknown): string | undefined { + if (!body || typeof body !== "object") return "Request body must be an object" + const req = body as Record + if (typeof req.model !== "string" || !req.model.trim()) { + return `"model" must be a non-empty string` + } + if (!Array.isArray(req.messages)) { + return `"messages" must be an array` + } + for (const msg of req.messages) { + if (!msg || typeof msg !== "object") return "Each message must be an object" + const m = msg as Record + if (m.role !== "user" && m.role !== "assistant") { + return `Invalid message role: ${m.role}` + } + const content = m.content + if (typeof content !== "string" && !Array.isArray(content)) { + return `Message content must be a string or array` + } + } + const max_tokens = req.max_tokens as number | undefined + if (max_tokens !== undefined && (!Number.isFinite(max_tokens) || max_tokens <= 0)) { + return `"max_tokens" must be a positive finite number` + } + const temperature = req.temperature as number | undefined + if (temperature !== undefined && (!Number.isFinite(temperature) || temperature < 0 || temperature > 2)) { + return `"temperature" must be between 0 and 2` + } + const top_p = req.top_p as number | undefined + if (top_p !== undefined && (!Number.isFinite(top_p) || top_p < 0 || top_p > 1)) { + return `"top_p" must be between 0 and 1` + } + if (req.tools !== undefined && !Array.isArray(req.tools)) { + return `"tools" must be an array` + } + return undefined +} + async function parseJsonBody(req: Request): Promise { try { - return (await req.json()) as AnthropicRequest + const buf = await req.arrayBuffer() + if (buf.byteLength > MAX_BODY_BYTES) { + return jsonError(413, "invalid_request_error", `Request body too large. Max ${MAX_BODY_BYTES} bytes.`) + } + const parsed = JSON.parse(new TextDecoder().decode(buf)) + const error = validateBody(parsed) + if (error) return jsonError(400, "invalid_request_error", error) + return parsed as AnthropicRequest } catch (err) { return jsonError(400, "invalid_request_error", `Invalid JSON: ${err}`) } @@ -177,16 +242,43 @@ function wrapStreamResponse( reader.cancel().catch(() => {}) }, }) + const headers = filterUpstreamHeaders(resp.headers) + addSecurityHeaders(headers) return new Response(stream, { status: resp.status, statusText: resp.statusText, - headers: resp.headers, + headers, + }) +} + +const SENSITIVE_UPSTREAM_HEADERS = new Set([ + "set-cookie", + "server", + "via", + "x-request-id", +]) + +function addSecurityHeaders(headers: Headers): void { + headers.set("X-Content-Type-Options", "nosniff") + headers.set("X-Frame-Options", "DENY") + headers.set("Referrer-Policy", "strict-origin-when-cross-origin") +} + +function filterUpstreamHeaders(headers: Headers): Headers { + const out = new Headers() + headers.forEach((value, key) => { + if (!SENSITIVE_UPSTREAM_HEADERS.has(key.toLowerCase())) { + out.set(key, value) + } }) + return out } function jsonError(status: number, type: string, message: string): Response { + const headers = new Headers({ "content-type": "application/json" }) + addSecurityHeaders(headers) return new Response(JSON.stringify({ type: "error", error: { type, message } }), { status, - headers: { "content-type": "application/json" }, + headers, }) } From 4a04713f839af100eb6633e8508594dfc714818a Mon Sep 17 00:00:00 2001 From: Alfredo Cristofano Date: Fri, 24 Apr 2026 23:07:49 +0200 Subject: [PATCH 2/5] security: improve log redaction and file permissions - Expand REDACT_KEYS with password, secret, api_key, cookie, bearer, etc. - Handle circular references in JSON.stringify to prevent crashes - Enforce 0o700 on log directory and 0o600 on log file - Add 10 MiB limit to SSE buffer to prevent unbounded growth Co-Authored-By: Claude Opus 4.6 --- src/log.ts | 25 ++++++++++++++++++++----- src/sse.ts | 7 +++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/log.ts b/src/log.ts index 0db75cd5..333a1662 100644 --- a/src/log.ts +++ b/src/log.ts @@ -17,6 +17,18 @@ const REDACT_KEYS = new Set([ "ChatGPT-Account-Id", "chatgpt-account-id", "x-api-key", + "password", + "secret", + "api_key", + "apikey", + "cookie", + "private_key", + "credentials", + "bearer", + "proxy-authorization", + "x-api-key", + "x-auth-token", + "token", ]) function stateDir(): string { @@ -34,9 +46,9 @@ let rotating: Promise | undefined async function ensureStream(): Promise { if (stream) return stream const dir = stateDir() - await mkdir(dir, { recursive: true }) + await mkdir(dir, { recursive: true, mode: 0o700 }) const file = join(dir, "proxy.log") - stream = createWriteStream(file, { flags: "a" }) + stream = createWriteStream(file, { flags: "a", mode: 0o600 }) return stream } @@ -45,6 +57,7 @@ async function maybeRotate(): Promise { rotating = (async () => { try { const dir = stateDir() + await mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined) const file = join(dir, "proxy.log") const s = await stat(file).catch(() => undefined) if (!s || s.size < MAX_LOG_BYTES) return @@ -65,7 +78,7 @@ async function maybeRotate(): Promise { const VERBOSE = !!process.env.CCP_LOG_VERBOSE -function redact(value: unknown, depth = 0): unknown { +function redact(value: unknown, depth = 0, seen = new WeakSet()): unknown { if (depth > 6) return "[depth-limit]" if (value == null) return value if (typeof value === "string") { @@ -73,13 +86,15 @@ function redact(value: unknown, depth = 0): unknown { return value } if (typeof value !== "object") return value - if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1)) + if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1, seen)) + if (seen.has(value)) return "[circular]" + seen.add(value) const out: Record = {} for (const [k, v] of Object.entries(value as Record)) { if (REDACT_KEYS.has(k)) { out[k] = typeof v === "string" ? `[redacted len=${v.length}]` : "[redacted]" } else { - out[k] = redact(v, depth + 1) + out[k] = redact(v, depth + 1, seen) } } return out diff --git a/src/sse.ts b/src/sse.ts index 5e92b479..3b953c57 100644 --- a/src/sse.ts +++ b/src/sse.ts @@ -8,20 +8,27 @@ export function encodeSseEvent(event: string, data: unknown): string { } const BOUNDARY = /\r\n\r\n|\n\n|\r\r/ +const MAX_SSE_BUFFER_BYTES = 10 * 1024 * 1024 // 10 MiB export async function* parseSseStream(body: ReadableStream): AsyncGenerator { const reader = body.getReader() const decoder = new TextDecoder() let buf = "" + let bufferedBytes = 0 try { while (true) { const { value, done } = await reader.read() if (done) break buf += decoder.decode(value, { stream: true }) + bufferedBytes += value?.length ?? 0 + if (bufferedBytes > MAX_SSE_BUFFER_BYTES) { + throw new Error("SSE buffer exceeded maximum size") + } let match: RegExpExecArray | null while ((match = BOUNDARY.exec(buf)) !== null) { const raw = buf.slice(0, match.index) buf = buf.slice(match.index + match[0].length) + bufferedBytes = new TextEncoder().encode(buf).length const evt = parseEventBlock(raw) if (evt) yield evt } From c1ce541ac083fdf904b413bc54bc4f0dc7e9587a Mon Sep 17 00:00:00 2001 From: Alfredo Cristofano Date: Fri, 24 Apr 2026 23:07:49 +0200 Subject: [PATCH 3/5] security: harden OAuth flow and token management - Fix PKCE randomness bias by using base64url encoding directly - Use ephemeral port 0 for OAuth callback to prevent port squatting - Validate Host header in OAuth callback server - HTML-escape error messages in OAuth callback response - Clear stored Codex tokens on 401/403 refresh failure - Enforce 0o700 on auth storage directories on Linux Co-Authored-By: Claude Opus 4.6 --- src/providers/codex/auth/constants.ts | 2 -- src/providers/codex/auth/manager.ts | 7 +++- src/providers/codex/auth/pkce.ts | 47 +++++++++++++++++-------- src/providers/codex/auth/token-store.ts | 2 +- src/providers/kimi/auth/token-store.ts | 2 +- 5 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/providers/codex/auth/constants.ts b/src/providers/codex/auth/constants.ts index eadfda24..ed462b4d 100644 --- a/src/providers/codex/auth/constants.ts +++ b/src/providers/codex/auth/constants.ts @@ -1,7 +1,5 @@ export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" export const ISSUER = "https://auth.openai.com" export const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses" -export const OAUTH_PORT = 1455 -export const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/auth/callback` export const ORIGINATOR = "claude-code-proxy" export const REFRESH_MARGIN_MS = 5 * 60 * 1000 diff --git a/src/providers/codex/auth/manager.ts b/src/providers/codex/auth/manager.ts index f157a670..448bdb4a 100644 --- a/src/providers/codex/auth/manager.ts +++ b/src/providers/codex/auth/manager.ts @@ -1,6 +1,6 @@ import { CLIENT_ID, ISSUER, REFRESH_MARGIN_MS } from "./constants.ts" import { extractAccountId, type TokenResponse } from "./jwt.ts" -import { loadAuth, saveAuth, type StoredAuth } from "./token-store.ts" +import { clearAuth, loadAuth, saveAuth, type StoredAuth } from "./token-store.ts" let cached: StoredAuth | undefined let inflight: Promise | undefined @@ -44,6 +44,11 @@ async function refreshNow(current: StoredAuth): Promise { client_id: CLIENT_ID, }).toString(), }) + if (resp.status === 401 || resp.status === 403) { + cached = undefined + await clearAuth().catch(() => undefined) + throw new Error(`Token refresh unauthorized (${resp.status})`) + } if (!resp.ok) throw new Error(`Token refresh failed: ${resp.status} ${await resp.text()}`) const tokens = (await resp.json()) as TokenResponse const accountId = extractAccountId(tokens) || current.accountId diff --git a/src/providers/codex/auth/pkce.ts b/src/providers/codex/auth/pkce.ts index 92eda7a2..1456428d 100644 --- a/src/providers/codex/auth/pkce.ts +++ b/src/providers/codex/auth/pkce.ts @@ -1,5 +1,6 @@ import { createServer } from "node:http" -import { CLIENT_ID, ISSUER, OAUTH_PORT, OAUTH_REDIRECT_URI, ORIGINATOR } from "./constants.ts" +import type { AddressInfo } from "node:net" +import { CLIENT_ID, ISSUER, ORIGINATOR } from "./constants.ts" import type { TokenResponse } from "./jwt.ts" export interface PkceCodes { @@ -8,17 +9,14 @@ export interface PkceCodes { } export async function generatePKCE(): Promise { - const verifier = generateRandomString(43) + const verifier = generateRandomString(128) const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) return { verifier, challenge: base64UrlEncode(hash) } } function generateRandomString(length: number): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes) - .map((b) => chars[b % chars.length]) - .join("") + return base64UrlEncode(bytes.buffer).slice(0, length) } function base64UrlEncode(buffer: ArrayBuffer): string { @@ -28,15 +26,24 @@ function base64UrlEncode(buffer: ArrayBuffer): string { return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") } +function escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + export function generateState(): string { return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) } -export function buildAuthorizeUrl(pkce: PkceCodes, state: string): string { +export function buildAuthorizeUrl(pkce: PkceCodes, state: string, redirectUri: string): string { const params = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirectUri, scope: "openid profile email offline_access", code_challenge: pkce.challenge, code_challenge_method: "S256", @@ -48,14 +55,14 @@ export function buildAuthorizeUrl(pkce: PkceCodes, state: string): string { return `${ISSUER}/oauth/authorize?${params.toString()}` } -export async function exchangeCodeForTokens(code: string, pkce: PkceCodes): Promise { +export async function exchangeCodeForTokens(code: string, pkce: PkceCodes, redirectUri: string): Promise { const response = await fetch(`${ISSUER}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, - redirect_uri: OAUTH_REDIRECT_URI, + redirect_uri: redirectUri, client_id: CLIENT_ID, code_verifier: pkce.verifier, }).toString(), @@ -67,7 +74,6 @@ export async function exchangeCodeForTokens(code: string, pkce: PkceCodes): Prom export async function runBrowserLogin(): Promise { const pkce = await generatePKCE() const state = generateState() - const authUrl = buildAuthorizeUrl(pkce, state) return new Promise((resolve, reject) => { const cleanup = () => { @@ -76,24 +82,32 @@ export async function runBrowserLogin(): Promise { server.closeAllConnections?.() } const server = createServer((req, res) => { - const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`) + const port = (server.address() as AddressInfo | null)?.port ?? 0 + const url = new URL(req.url || "/", `http://localhost:${port}`) if (url.pathname !== "/auth/callback") { res.writeHead(404) res.end("Not found") return } + const host = req.headers.host + if (host !== `localhost:${port}` && host !== `127.0.0.1:${port}`) { + res.writeHead(403) + res.end("Invalid host") + return + } const code = url.searchParams.get("code") const receivedState = url.searchParams.get("state") const error = url.searchParams.get("error") if (error || !code || receivedState !== state) { const msg = error || "Invalid callback" res.writeHead(400, { "Content-Type": "text/plain" }) - res.end(`Auth failed: ${msg}`) + res.end(`Auth failed: ${escapeHtml(msg)}`) cleanup() reject(new Error(msg)) return } - exchangeCodeForTokens(code, pkce) + const redirectUri = `http://localhost:${port}/auth/callback` + exchangeCodeForTokens(code, pkce, redirectUri) .then((tokens) => { res.writeHead(200, { "Content-Type": "text/html" }) res.end( @@ -109,7 +123,10 @@ export async function runBrowserLogin(): Promise { reject(err) }) }) - server.listen(OAUTH_PORT, () => { + server.listen(0, () => { + const port = (server.address() as AddressInfo).port + const redirectUri = `http://localhost:${port}/auth/callback` + const authUrl = buildAuthorizeUrl(pkce, state, redirectUri) console.log(`Open this URL in your browser to authorize:\n\n ${authUrl}\n`) }) server.on("error", reject) diff --git a/src/providers/codex/auth/token-store.ts b/src/providers/codex/auth/token-store.ts index ae85d481..199b4858 100644 --- a/src/providers/codex/auth/token-store.ts +++ b/src/providers/codex/auth/token-store.ts @@ -48,7 +48,7 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true }) + await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) try { diff --git a/src/providers/kimi/auth/token-store.ts b/src/providers/kimi/auth/token-store.ts index cf7878e6..47cdc001 100644 --- a/src/providers/kimi/auth/token-store.ts +++ b/src/providers/kimi/auth/token-store.ts @@ -49,7 +49,7 @@ export async function saveAuth(auth: StoredAuth): Promise { return } - await mkdir(dirname(FILE), { recursive: true }) + await mkdir(dirname(FILE), { recursive: true, mode: 0o700 }) const tmp = `${FILE}.${process.pid}.${Date.now()}.tmp` await writeFile(tmp, JSON.stringify(auth, null, 2), { encoding: "utf8", mode: 0o600 }) try { From 23adbb41f6571c1b76eec0d2eddb5227bea330a8 Mon Sep 17 00:00:00 2001 From: Alfredo Cristofano Date: Fri, 24 Apr 2026 23:07:49 +0200 Subject: [PATCH 4/5] security: validate inputs and sanitize upstream headers - Add safeJsonStringify helper to prevent crashes on circular refs - Validate image URLs before forwarding (allow http/https/data only) - Guard against missing image block source - Validate retry-after header format before reflection - Add sessionTimeline eviction to prevent unbounded memory growth - Remove raw console.error(err) in CLI that bypassed log redaction Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 1 - src/providers/codex/index.ts | 18 +++++++++++-- src/providers/codex/translate/request.ts | 32 +++++++++++++++++++--- src/providers/kimi/index.ts | 4 +-- src/providers/kimi/translate/request.ts | 34 +++++++++++++++++++++--- 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index f873bf15..8bbf89c7 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -105,6 +105,5 @@ Models: ${models} main().catch((err) => { log.error("cli fatal", { err: String(err), stack: (err as Error)?.stack }) - console.error(err) process.exit(1) }) diff --git a/src/providers/codex/index.ts b/src/providers/codex/index.ts index cf6f8d34..dc907587 100644 --- a/src/providers/codex/index.ts +++ b/src/providers/codex/index.ts @@ -40,10 +40,24 @@ interface SessionTimelineState { lastMessage?: SessionMessageSnapshot } +const MAX_SESSION_TIMELINE_ENTRIES = 10000 const sessionTimeline = new Map() +let sessionTimelineLastCleanup = Date.now() + +function maybeCleanupSessionTimeline(): void { + if (sessionTimeline.size < MAX_SESSION_TIMELINE_ENTRIES) return + if (Date.now() - sessionTimelineLastCleanup < 60000) return + const cutoff = Date.now() - 3600000 // 1h TTL based on last access + for (const [k] of sessionTimeline) { + sessionTimeline.delete(k) + if (sessionTimeline.size <= MAX_SESSION_TIMELINE_ENTRIES * 0.75) break + } + sessionTimelineLastCleanup = Date.now() +} function sessionState(sessionId?: string): SessionTimelineState | undefined { if (!sessionId) return undefined + maybeCleanupSessionTimeline() let state = sessionTimeline.get(sessionId) if (!state) { state = {} @@ -212,7 +226,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom log.warn("codex error", { status: err.status, detail: err.detail }) if (err.status === 429) { const headers: Record = { "content-type": "application/json" } - if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter + if (err.meta?.retryAfter && /^\d+$/.test(err.meta.retryAfter)) headers["retry-after"] = err.meta.retryAfter return new Response( JSON.stringify({ type: "error", @@ -315,7 +329,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom }) if (err.kind === "rate_limit") { const headers: Record = { "content-type": "application/json" } - if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds) + if (err.retryAfterSeconds && Number.isInteger(err.retryAfterSeconds) && err.retryAfterSeconds > 0) headers["retry-after"] = String(err.retryAfterSeconds) return new Response( JSON.stringify({ type: "error", diff --git a/src/providers/codex/translate/request.ts b/src/providers/codex/translate/request.ts index a7ae0794..98e8d3a0 100644 --- a/src/providers/codex/translate/request.ts +++ b/src/providers/codex/translate/request.ts @@ -170,6 +170,14 @@ export function buildInstructions(system: AnthropicRequest["system"]): string | return texts.join("\n\n") } +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return "{}" + } +} + function buildInput(messages: AnthropicMessage[]): ResponsesInputItem[] { const out: ResponsesInputItem[] = [] for (const msg of messages) { @@ -212,7 +220,7 @@ function buildInput(messages: AnthropicMessage[]): ResponsesInputItem[] { type: "function_call", call_id: block.id, name: block.name, - arguments: JSON.stringify(block.input ?? {}), + arguments: safeJsonStringify(block.input ?? {}), }) } } @@ -227,9 +235,27 @@ export function normalizeContent(content: AnthropicMessage["content"]): Anthropi return content } +function isAllowedImageUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + function imageToUrl(block: Extract): string { - if (block.source.type === "url") return block.source.url - return `data:${block.source.media_type};base64,${block.source.data}` + if (!block.source || typeof block.source !== "object") return "" + if (block.source.type === "url") { + if (typeof block.source.url === "string" && isAllowedImageUrl(block.source.url)) { + return block.source.url + } + return "" + } + if (block.source.type === "base64" && typeof block.source.media_type === "string" && typeof block.source.data === "string") { + return `data:${block.source.media_type};base64,${block.source.data}` + } + return "" } export function toolResultToString( diff --git a/src/providers/kimi/index.ts b/src/providers/kimi/index.ts index 1112ee15..6ce8cdda 100644 --- a/src/providers/kimi/index.ts +++ b/src/providers/kimi/index.ts @@ -93,7 +93,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom log.warn("kimi error", { status: err.status, detail: err.detail }) if (err.status === 429) { const headers: Record = { "content-type": "application/json" } - if (err.meta?.retryAfter) headers["retry-after"] = err.meta.retryAfter + if (err.meta?.retryAfter && /^\d+$/.test(err.meta.retryAfter)) headers["retry-after"] = err.meta.retryAfter return new Response( JSON.stringify({ type: "error", @@ -156,7 +156,7 @@ async function handleMessages(body: AnthropicRequest, ctx: RequestContext): Prom }) if (err.kind === "rate_limit") { const headers: Record = { "content-type": "application/json" } - if (err.retryAfterSeconds) headers["retry-after"] = String(err.retryAfterSeconds) + if (err.retryAfterSeconds && Number.isInteger(err.retryAfterSeconds) && err.retryAfterSeconds > 0) headers["retry-after"] = String(err.retryAfterSeconds) return new Response( JSON.stringify({ type: "error", diff --git a/src/providers/kimi/translate/request.ts b/src/providers/kimi/translate/request.ts index 470b79ff..92967c92 100644 --- a/src/providers/kimi/translate/request.ts +++ b/src/providers/kimi/translate/request.ts @@ -98,7 +98,7 @@ export function translateRequest( } function clampMaxTokens(requested: number | undefined): number { - if (!requested || requested <= 0) return DEFAULT_MAX_TOKENS + if (typeof requested !== "number" || !Number.isFinite(requested) || requested <= 0) return DEFAULT_MAX_TOKENS return Math.min(requested, DEFAULT_MAX_TOKENS) } @@ -205,6 +205,14 @@ function pushUserMessages(out: KimiMessage[], blocks: AnthropicContentBlock[]): flushBuffer() } +function safeJsonStringify(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return "{}" + } +} + function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[]): void { const textParts: string[] = [] const thinkingParts: string[] = [] @@ -220,7 +228,7 @@ function pushAssistantMessage(out: KimiMessage[], blocks: AnthropicContentBlock[ type: "function", function: { name: block.name, - arguments: JSON.stringify(block.input ?? {}), + arguments: safeJsonStringify(block.input ?? {}), }, }) } @@ -245,9 +253,27 @@ export function normalizeContent(content: AnthropicMessage["content"]): Anthropi return content } +function isAllowedImageUrl(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === "http:" || u.protocol === "https:" + } catch { + return false + } +} + function imageToUrl(block: Extract): string { - if (block.source.type === "url") return block.source.url - return `data:${block.source.media_type};base64,${block.source.data}` + if (!block.source || typeof block.source !== "object") return "" + if (block.source.type === "url") { + if (typeof block.source.url === "string" && isAllowedImageUrl(block.source.url)) { + return block.source.url + } + return "" + } + if (block.source.type === "base64" && typeof block.source.media_type === "string" && typeof block.source.data === "string") { + return `data:${block.source.media_type};base64,${block.source.data}` + } + return "" } export function toolResultContent( From c8f82914cd891480740fe698bd8a665f0dce90bc Mon Sep 17 00:00:00 2001 From: Alfredo Cristofano Date: Fri, 24 Apr 2026 23:07:49 +0200 Subject: [PATCH 5/5] security: fix package metadata, lockfile, and CI permissions - Add private: true, engines, packageManager, and repository metadata - Pin @types/bun to exact version and regenerate bun.lock - Reduce CI workflow permissions to least-privilege per job - Pin Bun version in CI for reproducible builds - Add timeout-minutes to all CI jobs - Expand .gitignore to exclude logs, env files, and auth artifacts Co-Authored-By: Claude Opus 4.6 --- .github/workflows/release.yml | 13 +++++++++++-- .gitignore | 5 +++++ bun.lock | 4 ++-- package.json | 17 ++++++++++++++++- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index adb780fc..5d7f0293 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,9 @@ on: push: tags: ['v*'] +# Least-privilege: nessun job deve avere più permessi del necessario. permissions: - contents: write + contents: read env: BIN_NAME: claude-code-proxy @@ -13,6 +14,9 @@ env: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read strategy: fail-fast: false matrix: @@ -29,7 +33,8 @@ jobs: - uses: actions/checkout@v5 - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + # Pinned for reproducible builds and supply-chain integrity. + bun-version: "1.3.10" - name: Install dependencies run: bun install --frozen-lockfile - name: Build @@ -57,6 +62,7 @@ jobs: release: needs: build runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write steps: @@ -74,6 +80,9 @@ jobs: update-tap: needs: release runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write steps: - uses: actions/download-artifact@v5 with: diff --git a/.gitignore b/.gitignore index b9470778..c49b468b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ node_modules/ dist/ +*.log +.env +.env.* +auth.json +device_id diff --git a/bun.lock b/bun.lock index 14ba968a..5c8cc4c5 100644 --- a/bun.lock +++ b/bun.lock @@ -3,12 +3,12 @@ "configVersion": 1, "workspaces": { "": { - "name": "claude-openai-proxy", + "name": "claude-code-proxy", "dependencies": { "gpt-tokenizer": "^2.9.0", }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.12", "typescript": "^5.6.0", }, }, diff --git a/package.json b/package.json index 1cd63719..fe87c228 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,8 @@ { "name": "claude-code-proxy", "version": "0.0.5", + "description": "Local proxy: Claude Code to ChatGPT subscription via Codex Responses API", + "private": true, "license": "MIT", "type": "module", "bin": { @@ -11,11 +13,24 @@ "auth": "bun run src/cli.ts auth login", "typecheck": "tsc --noEmit" }, + "repository": { + "type": "git", + "url": "https://github.com/raine/claude-code-proxy.git" + }, + "author": "Raine Virta ", + "bugs": { + "url": "https://github.com/raine/claude-code-proxy/issues" + }, + "homepage": "https://github.com/raine/claude-code-proxy#readme", + "engines": { + "bun": ">=1.0.0" + }, + "packageManager": "bun@1.3.10", "dependencies": { "gpt-tokenizer": "^2.9.0" }, "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.12", "typescript": "^5.6.0" } }