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
- This is a security guard — do not bypass it. Inspect the archive: the error's entryName field names the offending entry.
- Obtain the zip from a trusted source or re-create it with clean, relative, forward-slash entry names.
- Scan incoming archives with a security tool before importing if untrusted uploads are expected.
- 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
- Never bypass or widen this check on untrusted archives
- Only import zips from trusted sources; treat user uploads as hostile
- Ensure zip-producing code writes normalized relative paths with forward slashes
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
- Invalid budget id "${id}". Check the id of your budget in th
- Zip archive contains a duplicate entry: ${file.name}
- Invalid font-family value for "${property}": function calls
- Invalid font src: only data: URIs are allowed in @font-face.
- Theme CSS contains forbidden at-rules (@import, @media, @key
AI-assisted analysis of actualbudget/actual@d4334cb6e6 (2026-08-29).
Data as JSON: /api/errors/83d36824a45fc460.
Report an issue: GitHub.