diff --git a/package.json b/package.json index acf1f24f9c..6fae3e4d49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.53.0", + "version": "2.52.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", diff --git a/src/grok/reset-coupons.ts b/src/grok/reset-coupons.ts index b35afd286a..99733b9e15 100644 --- a/src/grok/reset-coupons.ts +++ b/src/grok/reset-coupons.ts @@ -56,23 +56,34 @@ export function encodeVarint(value: number | bigint): Uint8Array { * Decodes a protobuf varint from bytes at offset. */ export function decodeVarint(bytes: Uint8Array, offset: number): { value: number; bytesRead: number } { + if (!Number.isInteger(offset) || offset < 0 || offset >= bytes.length) { + throw new Error("Invalid protobuf varint offset"); + } + let result = 0; - let shift = 0; let count = 0; while (offset + count < bytes.length) { const b = bytes[offset + count]; - count++; - result |= (b & 0x7f) << shift; - if ((b & 0x80) === 0) break; - shift += 7; - if (shift > 35) { - // For timestamps seconds, JS safe integers suffice. - break; + const part = (b & 0x7f) * (2 ** (7 * count)); + if (!Number.isSafeInteger(part) || result > Number.MAX_SAFE_INTEGER - part) { + throw new Error("Protobuf varint exceeds JavaScript safe integer range"); } + result += part; + count++; + if ((b & 0x80) === 0) return { value: result, bytesRead: count }; } - return { value: result, bytesRead: count }; + throw new Error("Truncated protobuf varint"); +} + +function decodeLength(bytes: Uint8Array, offset: number): { start: number; end: number } { + const { value: length, bytesRead } = decodeVarint(bytes, offset); + const start = offset + bytesRead; + if (!Number.isSafeInteger(length) || length < 0 || length > bytes.length - start) { + throw new Error("Invalid protobuf length-delimited field"); + } + return { start, end: start + length }; } /** @@ -109,8 +120,8 @@ function decodeTimestamp(bytes: Uint8Array): number { offset += bytesRead; if (fieldNum === 1) seconds = value; } else if (wireType === 2) { - const { value: len, bytesRead } = decodeVarint(bytes, offset); - offset += bytesRead + len; + const { end } = decodeLength(bytes, offset); + offset = end; } else { break; } @@ -135,10 +146,9 @@ function decodeConsumerResetToken(bytes: Uint8Array): GrokResetCoupon | null { const wireType = tag & 0x7; if (wireType === 2) { - const { value: len, bytesRead: lenRead } = decodeVarint(bytes, offset); - offset += lenRead; - const sub = bytes.subarray(offset, offset + len); - offset += len; + const { start, end } = decodeLength(bytes, offset); + const sub = bytes.subarray(start, end); + offset = end; if (fieldNum === 10) { tokenId = new TextDecoder("utf-8").decode(sub); @@ -178,10 +188,9 @@ export function decodeGetRemainingResetsResponse(payload: Uint8Array): GrokReset const wireType = tag & 0x7; if (wireType === 2) { - const { value: len, bytesRead: lenRead } = decodeVarint(payload, offset); - offset += lenRead; - const sub = payload.subarray(offset, offset + len); - offset += len; + const { start, end } = decodeLength(payload, offset); + const sub = payload.subarray(start, end); + offset = end; if (fieldNum === 10) { const token = decodeConsumerResetToken(sub); diff --git a/structure/runtime.md b/structure/runtime.md index 365fb04e07..e0d8c8e158 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -146,6 +146,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | +| `src/grok/` | Grok-specific gRPC-Web framing and reset-coupon operations. Provider-controlled protobuf varints must fit JavaScript's safe integer range, and every length-delimited field is rejected unless its declared bytes remain inside the enclosing message. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | diff --git a/tests/providers/xai/grok-reset-coupons.test.ts b/tests/providers/xai/grok-reset-coupons.test.ts index 6214356840..29b5500d27 100644 --- a/tests/providers/xai/grok-reset-coupons.test.ts +++ b/tests/providers/xai/grok-reset-coupons.test.ts @@ -11,6 +11,7 @@ import { import { getGrokRemainingResets, decodeGetRemainingResetsResponse, + decodeVarint, encodeRedeemResetRequest, encodeVarint, GROK_GET_REMAINING_RESETS_ENDPOINT, @@ -135,6 +136,15 @@ describe("grok reset coupons", () => { expect(tokens[0].validityEnd).toBe(new Date(1728788400 * 1000).toISOString()); }); + it("rejects oversized and truncated protobuf lengths without losing parser progress", () => { + const oversizedLength = new Uint8Array([0x52, 0x80, 0x80, 0x80, 0x80, 0x08]); + expect(() => decodeGetRemainingResetsResponse(oversizedLength)).toThrow( + "Invalid protobuf length-delimited field", + ); + + expect(() => decodeVarint(new Uint8Array([0x80]), 0)).toThrow("Truncated protobuf varint"); + }); + it("asserts auth headers and tokenAuth compatibility header on request", async () => { let capturedHeaders: Headers | undefined; let capturedBody: Uint8Array | undefined;