diff --git a/src/lib/oauth/utils/claudeAuthZipExtract.ts b/src/lib/oauth/utils/claudeAuthZipExtract.ts index 8133851014..3de5ff1dfe 100644 --- a/src/lib/oauth/utils/claudeAuthZipExtract.ts +++ b/src/lib/oauth/utils/claudeAuthZipExtract.ts @@ -1,89 +1,5 @@ -import path from "path"; -import { unzipSync, type Unzipped } from "fflate"; - -export interface ExtractedZipFile { - name: string; - content: string; -} - -export interface ExtractZipOptions { - maxFiles?: number; - maxFileSizeBytes?: number; - maxTotalSizeBytes?: number; -} - -const DEFAULT_MAX_FILES = 50; -const DEFAULT_MAX_FILE_SIZE = 256 * 1024; -const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024; - -function isSafeEntryName(name: string): boolean { - if (!name.toLowerCase().endsWith(".json")) return false; - if (name.includes("..")) return false; - if (path.isAbsolute(name)) return false; - if (/[\r\n\0]/.test(name)) return false; - return true; -} - -export function extractClaudeAuthZip( - zipBuffer: Buffer, - options: ExtractZipOptions = {} -): ExtractedZipFile[] { - const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; - const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE; - const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL; - - let unzipped: Unzipped; - try { - unzipped = unzipSync(new Uint8Array(zipBuffer)); - } catch { - throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP"); - } - - const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined); - const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json")); - - if (jsonEntries.length === 0) { - throw new Error("ZIP archive contains no .json files"); - } - - if (jsonEntries.length > maxFiles) { - throw new Error( - `ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}` - ); - } - - let totalBytes = 0; - const result: ExtractedZipFile[] = []; - - for (const [entryName, data] of jsonEntries) { - const baseName = path.basename(entryName); - - if (!isSafeEntryName(baseName)) { - throw new Error( - `ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)` - ); - } - - if (!isSafeEntryName(entryName)) { - throw new Error( - `ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)` - ); - } - - if (data.byteLength > maxFileSize) { - throw new Error( - `ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file` - ); - } - - totalBytes += data.byteLength; - if (totalBytes > maxTotal) { - throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`); - } - - const content = new TextDecoder("utf-8").decode(data); - result.push({ name: baseName, content }); - } - - return result; -} +export { + extractJsonZip as extractClaudeAuthZip, + type ExtractedZipFile, + type ExtractZipOptions, +} from "@/lib/oauth/utils/jsonZipExtract"; diff --git a/src/lib/oauth/utils/codexAuthZipExtract.ts b/src/lib/oauth/utils/codexAuthZipExtract.ts index 108c78c746..c55286d3a0 100644 --- a/src/lib/oauth/utils/codexAuthZipExtract.ts +++ b/src/lib/oauth/utils/codexAuthZipExtract.ts @@ -1,89 +1,5 @@ -import path from "path"; -import { unzipSync, type Unzipped } from "fflate"; - -export interface ExtractedZipFile { - name: string; - content: string; -} - -export interface ExtractZipOptions { - maxFiles?: number; - maxFileSizeBytes?: number; - maxTotalSizeBytes?: number; -} - -const DEFAULT_MAX_FILES = 50; -const DEFAULT_MAX_FILE_SIZE = 256 * 1024; -const DEFAULT_MAX_TOTAL = 10 * 1024 * 1024; - -function isSafeEntryName(name: string): boolean { - if (!name.toLowerCase().endsWith(".json")) return false; - if (name.includes("..")) return false; - if (path.isAbsolute(name)) return false; - if (/[\r\n\0]/.test(name)) return false; - return true; -} - -export function extractCodexAuthZip( - zipBuffer: Buffer, - options: ExtractZipOptions = {} -): ExtractedZipFile[] { - const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; - const maxFileSize = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE; - const maxTotal = options.maxTotalSizeBytes ?? DEFAULT_MAX_TOTAL; - - let unzipped: Unzipped; - try { - unzipped = unzipSync(new Uint8Array(zipBuffer)); - } catch { - throw new Error("Could not parse ZIP archive — file may be corrupt or not a valid ZIP"); - } - - const entries = Object.entries(unzipped).filter(([, data]) => data !== undefined); - const jsonEntries = entries.filter(([name]) => name.toLowerCase().endsWith(".json")); - - if (jsonEntries.length === 0) { - throw new Error("ZIP archive contains no .json files"); - } - - if (jsonEntries.length > maxFiles) { - throw new Error( - `ZIP archive contains ${jsonEntries.length} .json files — max allowed is ${maxFiles}` - ); - } - - let totalBytes = 0; - const result: ExtractedZipFile[] = []; - - for (const [entryName, data] of jsonEntries) { - const baseName = path.basename(entryName); - - if (!isSafeEntryName(baseName)) { - throw new Error( - `ZIP entry "${baseName}" has an unsafe filename (must be a .json file without path traversal)` - ); - } - - if (!isSafeEntryName(entryName)) { - throw new Error( - `ZIP entry path "${entryName}" is unsafe (no "..", absolute paths, or control characters allowed)` - ); - } - - if (data.byteLength > maxFileSize) { - throw new Error( - `ZIP entry "${baseName}" is ${data.byteLength} bytes — exceeds ${maxFileSize} byte limit per file` - ); - } - - totalBytes += data.byteLength; - if (totalBytes > maxTotal) { - throw new Error(`ZIP archive total uncompressed size exceeds ${maxTotal} byte limit`); - } - - const content = new TextDecoder("utf-8").decode(data); - result.push({ name: baseName, content }); - } - - return result; -} +export { + extractJsonZip as extractCodexAuthZip, + type ExtractedZipFile, + type ExtractZipOptions, +} from "@/lib/oauth/utils/jsonZipExtract"; diff --git a/tests/unit/codexAuthZipExtract.test.ts b/tests/unit/codexAuthZipExtract.test.ts index 261e3de056..1d3fbae1c5 100644 --- a/tests/unit/codexAuthZipExtract.test.ts +++ b/tests/unit/codexAuthZipExtract.test.ts @@ -1,25 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { zipSync, strToU8 } from "fflate"; - -// Mirror the safety logic from codexAuthZipExtract.ts so we can test without -// importing the module (which is fine since it has no external DB deps, but -// we test the pure logic to keep the test surface clear). - -interface ExtractedZipFile { - name: string; - content: string; -} - -interface ExtractZipOptions { - maxFiles?: number; - maxFileSizeBytes?: number; - maxTotalSizeBytes?: number; -} - -// Local re-implementation of the exported function to exercise it without -// importing Node-only code in the test runner. import { extractCodexAuthZip } from "../../src/lib/oauth/utils/codexAuthZipExtract.ts"; +import { extractClaudeAuthZip } from "../../src/lib/oauth/utils/claudeAuthZipExtract.ts"; +import { extractJsonZip } from "../../src/lib/oauth/utils/jsonZipExtract.ts"; // ──── Helpers ───────────────────────────────────────────────────────────────── @@ -47,6 +31,17 @@ test("extractCodexAuthZip: happy path — returns all .json entries", () => { assert.deepEqual(names, ["auth-a.json", "auth-b.json", "auth-c.json"]); }); +test("auth ZIP extractors share the generic JSON ZIP behavior", () => { + const zip = makeZip({ + "auth-a.json": VALID_AUTH, + "nested/auth-b.json": VALID_AUTH, + "README.md": "# Readme", + }); + + assert.deepEqual(extractCodexAuthZip(zip), extractJsonZip(zip)); + assert.deepEqual(extractClaudeAuthZip(zip), extractJsonZip(zip)); +}); + test("extractCodexAuthZip: ignores non-.json entries", () => { const zip = makeZip({ "auth-a.json": VALID_AUTH,