actualbudget/actual · critical · UnsafeZipError

Unsafe zip entry name: ${name}

Error message

Unsafe zip entry name: ${name}

What it means

assertSafeEntryName defends against zip-slip and related path-traversal attacks. It rejects entry names containing '..' traversal sequences, null bytes, backslashes, Windows drive prefixes (C:), absolute paths, or other unsafe patterns, throwing an UnsafeZipError with zipReason 'unsafe-entry-name'.

Source

Thrown at packages/loot-core/src/server/util/zip.ts:30

  readonly meta: UnsafeZipMeta;

  constructor(message: string, meta: UnsafeZipMeta) {
    super(message);
    this.meta = meta;
  }
}

function assertSafeEntryName(name: string) {
  const isTraversal = name.split('/').some(segment => segment === '..');

  if (
    name.includes('\0') ||
    name.includes('\\') ||
    /^[a-zA-Z]:/.test(name) ||
    name.startsWith('/') ||
    isTraversal
  ) {
    throw new UnsafeZipError(`Unsafe zip entry name: ${name}`, {
      zipReason: 'unsafe-entry-name',
      entryName: name,
    });
  }
}

type SafeUnzipOptions = {
  maxArchiveSize?: number;
  maxEntrySize?: number;
  maxTotalUncompressedSize?: number;
};

export function safeUnzip(
  data: Uint8Array,
  {
    maxArchiveSize = MAX_ZIP_SIZE,
    maxEntrySize = MAX_ZIP_SIZE,
    maxTotalUncompressedSize = MAX_ZIP_SIZE,

View on GitHub (pinned to d4334cb6e6)

Solutions

  1. This is a security guard — do not bypass it. Inspect the archive: the error's entryName field names the offending entry.
  2. Obtain the zip from a trusted source or re-create it with clean, relative, forward-slash entry names.
  3. Scan incoming archives with a security tool before importing if untrusted uploads are expected.
  4. Ensure any zip-producing code you control uses normalized relative paths for entries.

Example fix

// before
const files = safeUnzip(untrustedBuffer); // throws on malicious names
// after
let files;
try {
  files = safeUnzip(untrustedBuffer);
} catch (e) {
  if (e instanceof UnsafeZipError && e.zipReason === 'unsafe-entry-name') {
    throw new Error(`Rejected malicious archive entry: ${e.entryName}`);
  }
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Inspect entry names before extraction
const preview = unzipSync(buf, { filter: () => false }); // or read central directory
const dangerous = Object.keys(preview).filter(n =>
  n.includes('..') || n.startsWith('/') || /^[a-zA-Z]:/.test(n) || n.includes('\\') || n.includes('\0'));
if (dangerous.length) throw new Error(`Unsafe entries: ${dangerous.join(', ')}`);

Type guard

function isSafeEntryName(name: string): boolean {
  return !name.includes('..') && !name.includes('\0') && !name.includes('\\') &&
    !/^[a-zA-Z]:/.test(name) && !name.startsWith('/');
}

Try / catch

try {
  const entries = safeUnzip(buf);
} catch (e) {
  if (e instanceof UnsafeZipError && e.zipReason === 'unsafe-entry-name') {
    rejectUpload(`Archive rejected: unsafe entry "${e.entryName}"`);
  } else { throw e; }
}

Prevention

When it happens

Trigger: Extracting a zip whose entry names include '../' sequences, absolute paths ('/etc/passwd'), Windows paths ('C:\\evil'), null bytes, or backslash separators — typically a maliciously crafted archive uploaded by a user.

Common situations: Importing backup zips from untrusted sources; attacker-supplied archives in a public server deployment attempting path traversal to write outside the extraction directory.

Related errors


AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29). Data as JSON: /api/errors/83d36824a45fc460. Report an issue: GitHub.