can1357/oh-my-pi · error · ArchiveError

Cannot write unsafe ZIP member path '${inputName}'

Error message

Cannot write unsafe ZIP member path '${inputName}'

What it means

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.

Source

Thrown at packages/utils/src/ar/zip.ts:627

/** Encode deterministic stored/deflated ZIP bytes, emitting ZIP64 end records when the entry count requires them. */
export async function encodeZip(members: Iterable<readonly [string, Uint8Array]>): Promise<Uint8Array> {
	try {
		const localParts: Uint8Array[] = [];
		const centralParts: Uint8Array[] = [];
		let localSize = 0;
		let centralSize = 0;
		let count = 0;
		for (const [inputName, data] of members) {
			const portableName = inputName.replace(/\\/g, "/");
			const normalizedName = normalizeArchiveEntryPath(portableName);
			if (
				!normalizedName ||
				normalizedName !== portableName.replace(/^\.\//, "") ||
				portableName.startsWith("/") ||
				/^[A-Za-z]:/.test(portableName) ||
				portableName.includes("\0")
			) {
				throw new ArchiveError(`Cannot write unsafe ZIP member path '${inputName}'`);
			}
			const name = normalizedName;
			const nameBytes = TEXT_ENCODER.encode(name);
			if (nameBytes.byteLength > U16_MAX) throw new ArchiveError(`ZIP member path '${name}' is too long to write`);
			if (data.byteLength >= U32_MAX) throw new ArchiveError(`ZIP member '${name}' is too large to write`);
			const deflated = data.byteLength === 0 ? undefined : zlib.deflateRawSync(data);
			const payload = deflated && deflated.byteLength < data.byteLength ? deflated : data;
			const method = payload === data ? 0 : 8;
			if (payload.byteLength >= U32_MAX || localSize >= U32_MAX) {
				throw new ArchiveError("ZIP archive is too large to write member offsets safely");
			}
			const checksum = crc32(data);
			const local = new Uint8Array(30 + nameBytes.byteLength);
			writeUInt32LE(local, 0, LOCAL_HEADER_SIGNATURE);
			writeUInt16LE(local, 4, 20);
			writeUInt16LE(local, 6, UTF8_FLAG);
			writeUInt16LE(local, 8, method);
			writeUInt16LE(local, 10, 0);

View on GitHub (pinned to 9690622007)

Solutions

  1. Normalize the path and strip unsafe components before writing: resolve to a relative path without '..' segments
  2. Convert backslashes to forward slashes and drop drive letters / leading slashes
  3. Reject or rename members containing NUL bytes
  4. Sanitize user-supplied filenames with an allowlist (alphanumerics, dash, dot, slash)

Example fix

// before
writeZip([{ name: userInput + '/' + file, data }]);
// after
const safe = path.posix.normalize(userInput.replace(/\\/g, '/')).replace(/^(\.\.\/)+/, '').replace(/^\.\//, '');
if (!safe || safe.startsWith('/') || safe.includes('\\0')) throw new Error('unsafe name');
writeZip([{ name: safe, data }]);
Defensive patterns

Strategy: validation

Validate before calling

function isSafeZipName(name: string): boolean {
  const portable = name.replaceAll('\\', '/');
  const normalized = path.posix.normalize(portable).replace(/^\.\//, '');
  return Boolean(normalized) &&
    normalized === portable.replace(/^\.\//, '') &&
    !portable.startsWith('/') &&
    !/^[A-Za-z]:/.test(portable) &&
    !portable.includes('\0');
}

Type guard

function isSafeZipName(name: string): boolean {
  const portable = name.replaceAll('\\', '/');
  const normalized = path.posix.normalize(portable).replace(/^\.\//, '');
  return normalized.length > 0 && normalized === portable.replace(/^\.\//, '') &&
    !portable.startsWith('/') && !/^[A-Za-z]:/.test(portable) && !portable.includes('\0');
}

Try / catch

try {
  await writeZip(members);
} catch (err) {
  if (err instanceof ArchiveError && err.message.startsWith('Cannot write unsafe ZIP member path')) {
    throw new Error(`Rejected unsafe member name (possible path traversal): ${err.message}`, { cause: err });
  }
  throw err;
}

Prevention

When it happens

Trigger: 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).

Common situations: 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.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/ab46dff668847019. Report an issue: GitHub.