Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 5 additions & 89 deletions src/lib/oauth/utils/claudeAuthZipExtract.ts
Original file line number Diff line number Diff line change
@@ -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";
94 changes: 5 additions & 89 deletions src/lib/oauth/utils/codexAuthZipExtract.ts
Original file line number Diff line number Diff line change
@@ -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";
31 changes: 13 additions & 18 deletions tests/unit/codexAuthZipExtract.test.ts
Original file line number Diff line number Diff line change
@@ -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 ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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,
Expand Down
Loading