{"record":{"id":"1c5612750bb18148","repo":"can1357/oh-my-pi","slug":"archive-entry-escapes-extraction-directory-arch","errorCode":null,"errorMessage":"Archive entry escapes extraction directory: ${archivePath}","messagePattern":"Archive entry escapes extraction directory: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"packages/stats/src/server.ts","lineNumber":70,"sourceCode":"\nfunction sanitizeArchivePath(archivePath: string): string | null {\n\tconst normalized = archivePath.replaceAll(\"\\\\\", \"/\").replace(/^\\.\\//, \"\");\n\tif (!normalized || normalized === \".\") return null;\n\tif (normalized.includes(\"..\") || path.isAbsolute(normalized)) return null;\n\treturn normalized;\n}\n\nasync function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {\n\tconst archive = new Bun.Archive(archiveBytes);\n\tconst files = await archive.files();\n\tconst extractRoot = path.resolve(outputDir);\n\n\tfor (const [archivePath, file] of files) {\n\t\tconst sanitizedPath = sanitizeArchivePath(archivePath);\n\t\tif (!sanitizedPath) continue;\n\t\tconst destinationPath = path.resolve(extractRoot, sanitizedPath);\n\t\tif (!destinationPath.startsWith(extractRoot + path.sep)) {\n\t\t\tthrow new Error(`Archive entry escapes extraction directory: ${archivePath}`);\n\t\t}\n\t\tawait Bun.write(destinationPath, file);\n\t}\n}\n\nasync function getEmbeddedClientDir(): Promise<string> {\n\tif (!USE_EMBEDDED_CLIENT) return STATIC_DIR;\n\tif (embeddedClientDirPromise) return embeddedClientDirPromise;\n\n\tif (!EMBEDDED_CLIENT_ARCHIVE) {\n\t\tthrow new Error(\n\t\t\t\"Embedded stats client bundle missing. Rebuild the omp binary or npm bundle with embedded stats assets.\",\n\t\t);\n\t}\n\n\tembeddedClientDirPromise = (async () => {\n\t\tconst bundleHash = Bun.hash(EMBEDDED_CLIENT_ARCHIVE).toString(16);\n\t\tconst outputDir = path.join(EMBEDDED_CLIENT_DIR_ROOT, bundleHash);","sourceCodeStart":52,"sourceCodeEnd":88,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/stats/src/server.ts#L52-L88","documentation":"extractEmbeddedClientArchive() sanitizes each archive entry path and resolves it under the extraction root; if the resolved destination does not remain inside extractRoot, the entry is a zip-slip/path-traversal attempt and the whole extraction is aborted with this error. It protects the filesystem from a maliciously crafted embedded client archive writing outside its directory.","triggerScenarios":"An embedded client archive contains an entry whose path (after sanitizeArchivePath) still escapes the extract root — absolute paths, ../ traversal sequences, or symlinks resolving outside — when getEmbeddedClientDir first extracts the bundle (binary or npm bundle startup).","commonSituations":"A tampered or corrupted binary where the embedded archive was replaced; a build regression producing malformed archive entry names; extracting on a platform where path.resolve/separators make an edge-case name escape; testing with a hand-built archive containing traversal entries.","solutions":["Rebuild the omp binary/npm bundle from a trusted source so the embedded archive is regenerated intact","Verify the archive entries: any path containing ../ or absolute prefixes indicates a corrupted/tampered bundle","Ensure sanitizeArchivePath is applied before resolve and the startsWith(extractRoot + path.sep) check runs on the resolved path","If a legitimate entry triggers it, fix the entry name at build time (no leading separators, no traversal) rather than weakening the check"],"exampleFix":"// before (build step producing a bad entry)\nentries.set('/../../etc/passwd-content', file);\n// after\nentries.set('assets/index.js', file); // relative, normalized path inside the archive root","handlingStrategy":"validation","validationCode":"for (const [archivePath] of files) {\n  const dest = path.resolve(extractRoot, sanitizeArchivePath(archivePath));\n  if (!dest.startsWith(extractRoot + path.sep)) {\n    throw new Error(`Refusing unsafe archive entry before extraction: ${archivePath}`);\n  }\n}","typeGuard":"function isSafeArchivePath(extractRoot: string, archivePath: string): boolean {\n  const dest = path.resolve(extractRoot, sanitizeArchivePath(archivePath));\n  return dest.startsWith(extractRoot + path.sep);\n}","tryCatchPattern":"try {\n  const dir = await getEmbeddedClientDir();\n} catch (err) {\n  if (err.message.startsWith('Archive entry escapes')) {\n    console.error('Embedded client archive is corrupted or tampered with — rebuild the omp binary from a trusted source.');\n    process.exit(1); // do not serve from a partially extracted, untrusted archive\n  }\n  throw err;\n}","preventionTips":["Never weaken the sanitizeArchivePath + startsWith(extractRoot + path.sep) checks","Rebuild binaries from trusted sources; treat this error as evidence of tampering/corruption","Add build-time tests asserting archive entries contain no traversal or absolute paths","Keep archive entry names relative and normalized at bundle time"],"tags":["security","path-traversal","zip-slip","archive"],"backgroundTag":"zip-slip-path-traversal","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}