{"record":{"id":"ab46dff668847019","repo":"can1357/oh-my-pi","slug":"cannot-write-unsafe-zip-member-path-inputname","errorCode":null,"errorMessage":"Cannot write unsafe ZIP member path '${inputName}'","messagePattern":"Cannot write unsafe ZIP member path '(.+?)'","errorType":"exception","errorClass":"ArchiveError","httpStatus":null,"severity":"error","filePath":"packages/utils/src/ar/zip.ts","lineNumber":627,"sourceCode":"/** Encode deterministic stored/deflated ZIP bytes, emitting ZIP64 end records when the entry count requires them. */\nexport async function encodeZip(members: Iterable<readonly [string, Uint8Array]>): Promise<Uint8Array> {\n\ttry {\n\t\tconst localParts: Uint8Array[] = [];\n\t\tconst centralParts: Uint8Array[] = [];\n\t\tlet localSize = 0;\n\t\tlet centralSize = 0;\n\t\tlet count = 0;\n\t\tfor (const [inputName, data] of members) {\n\t\t\tconst portableName = inputName.replace(/\\\\/g, \"/\");\n\t\t\tconst normalizedName = normalizeArchiveEntryPath(portableName);\n\t\t\tif (\n\t\t\t\t!normalizedName ||\n\t\t\t\tnormalizedName !== portableName.replace(/^\\.\\//, \"\") ||\n\t\t\t\tportableName.startsWith(\"/\") ||\n\t\t\t\t/^[A-Za-z]:/.test(portableName) ||\n\t\t\t\tportableName.includes(\"\\0\")\n\t\t\t) {\n\t\t\t\tthrow new ArchiveError(`Cannot write unsafe ZIP member path '${inputName}'`);\n\t\t\t}\n\t\t\tconst name = normalizedName;\n\t\t\tconst nameBytes = TEXT_ENCODER.encode(name);\n\t\t\tif (nameBytes.byteLength > U16_MAX) throw new ArchiveError(`ZIP member path '${name}' is too long to write`);\n\t\t\tif (data.byteLength >= U32_MAX) throw new ArchiveError(`ZIP member '${name}' is too large to write`);\n\t\t\tconst deflated = data.byteLength === 0 ? undefined : zlib.deflateRawSync(data);\n\t\t\tconst payload = deflated && deflated.byteLength < data.byteLength ? deflated : data;\n\t\t\tconst method = payload === data ? 0 : 8;\n\t\t\tif (payload.byteLength >= U32_MAX || localSize >= U32_MAX) {\n\t\t\t\tthrow new ArchiveError(\"ZIP archive is too large to write member offsets safely\");\n\t\t\t}\n\t\t\tconst checksum = crc32(data);\n\t\t\tconst local = new Uint8Array(30 + nameBytes.byteLength);\n\t\t\twriteUInt32LE(local, 0, LOCAL_HEADER_SIGNATURE);\n\t\t\twriteUInt16LE(local, 4, 20);\n\t\t\twriteUInt16LE(local, 6, UTF8_FLAG);\n\t\t\twriteUInt16LE(local, 8, method);\n\t\t\twriteUInt16LE(local, 10, 0);","sourceCodeStart":609,"sourceCodeEnd":645,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/utils/src/ar/zip.ts#L609-L645","documentation":"Thrown by the ZIP writer when an input member path is deemed unsafe: it fails normalization (empty, does not round-trip through './'-stripping, escapes via '..', is absolute, is a Windows drive path, or contains NUL). The writer only accepts paths that are safe, relative, portable archive member names.","triggerScenarios":"Calling the zip write/create API with member names like '../secrets.txt', '/etc/passwd', 'C:\\\\foo', 'a\\0b', or names whose normalized form differs from the input (e.g. redundant '../' or './' segments that do not collapse cleanly).","commonSituations":"Archiving files built from untrusted user input; concatenating path segments without normalization; Windows paths passed directly instead of converted to forward-slash relative paths; path traversal attempts in upload pipelines.","solutions":["Normalize the path and strip unsafe components before writing: resolve to a relative path without '..' segments","Convert backslashes to forward slashes and drop drive letters / leading slashes","Reject or rename members containing NUL bytes","Sanitize user-supplied filenames with an allowlist (alphanumerics, dash, dot, slash)"],"exampleFix":"// before\nwriteZip([{ name: userInput + '/' + file, data }]);\n// after\nconst safe = path.posix.normalize(userInput.replace(/\\\\/g, '/')).replace(/^(\\.\\.\\/)+/, '').replace(/^\\.\\//, '');\nif (!safe || safe.startsWith('/') || safe.includes('\\\\0')) throw new Error('unsafe name');\nwriteZip([{ name: safe, data }]);","handlingStrategy":"validation","validationCode":"function isSafeZipName(name: string): boolean {\n  const portable = name.replaceAll('\\\\', '/');\n  const normalized = path.posix.normalize(portable).replace(/^\\.\\//, '');\n  return Boolean(normalized) &&\n    normalized === portable.replace(/^\\.\\//, '') &&\n    !portable.startsWith('/') &&\n    !/^[A-Za-z]:/.test(portable) &&\n    !portable.includes('\\0');\n}","typeGuard":"function isSafeZipName(name: string): boolean {\n  const portable = name.replaceAll('\\\\', '/');\n  const normalized = path.posix.normalize(portable).replace(/^\\.\\//, '');\n  return normalized.length > 0 && normalized === portable.replace(/^\\.\\//, '') &&\n    !portable.startsWith('/') && !/^[A-Za-z]:/.test(portable) && !portable.includes('\\0');\n}","tryCatchPattern":"try {\n  await writeZip(members);\n} catch (err) {\n  if (err instanceof ArchiveError && err.message.startsWith('Cannot write unsafe ZIP member path')) {\n    throw new Error(`Rejected unsafe member name (possible path traversal): ${err.message}`, { cause: err });\n  }\n  throw err;\n}","preventionTips":["Never pass raw user input as member names; normalize first","Convert Windows paths to relative forward-slash form","Strip leading './' and any '..' segments before writing"],"tags":["zip","path-traversal","security","validation"],"backgroundTag":"unsafe-archive-member-path","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}