chore(duplication): share auth zip extractors (#5475)

This commit is contained in:
Jan Leon
2026-06-30 02:58:18 +02:00
committed by GitHub
parent 09fa9905f2
commit bbd9a776db
3 changed files with 23 additions and 196 deletions

View File

@@ -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";

View File

@@ -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";

View File

@@ -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,