Guard Archive
Archive content-safety guard — refuses hostile archive metadata BEFORE files touch the filesystem. Validates an operator-supplied entry list (the framework ships no pure-JS unzip / untar parser per the no-deps rule) plus an optional magic-byte inspection on raw bytes. Operators enumerate entries via their archive library (built-in zlib, OS tar / unzip CLI, vendored mupdf-of-archives) and pass [{ name, size, compressedSize, isSymlink, isHardlink, linkTarget, isDirectory, isEncrypted, attrs }, ...] to validateEntries.
Zip-slip / path-traversal: entry names containing .. segments, leading / or \\, or Windows drive-letter prefixes (C:\\) are refused under every profile. Composes b.guardFilename for the full leaf-safety catalog (null-byte, Windows reserved names, NTFS ADS, RTLO bidi, overlong UTF-8, shell-exec extensions, double- extension). Tracks the 2025-2026 CVE class: CVE-2025-3445 (mholt/archiver), CVE-2025-32779 (EDDI), CVE-2025-62156 (Argo Workflows), CVE-2025-66945 (Zdir Pro), CVE-2025-45582 (GNU Tar two-step symlink bypass), CVE-2025-11001 / 11002 (7-Zip RCE), CVE-2025-4138 / 4517 (Python tarfile), CVE-2025-10854 (txtai), CVE-2025-12060 (Keras), CVE-2026-26960 (node-tar hardlink-via- symlink chain).
Symlink / hardlink escape: entries whose linkTarget contains .. or is absolute are refused. strict rejects symlinks AND hardlinks outright; balanced permits in-root symlinks and rejects hardlinks (CVE-2026-26960 class); permissive audits both.
Decompression amplification: per-entry compressedSize/size ratio cap defaults 100:1 (strict) / 100:1 (balanced) / 1000:1 (permissive). Aggregate ratio across all entries also capped (maxAggregateRatio). Entry-count cap (maxEntries), per-entry size cap (maxEntryBytes), total uncompressed cap (maxTotalBytes).
NTFS ADS, overlong UTF-8, leaf-bidi: routed through b.guardFilename on every entry name with pathSeparatorsPolicy: "allow" (archive entries legitimately use / as separator).
Nested archives: entries with archive extensions (.zip, .tar.gz, .7z, .rar, .zst, ...) refused under strict (maxNestedDepth: 0); audited under balanced (depth 2) / permissive (depth 4) so the operator can recurse.
Duplicate-name + case-insensitive collision detection — the second entry with the same name silently overwrites on extraction (refused); case-insensitive collisions on Windows / HFS+ / APFS-non-case- sensitive volumes (audited / refused per profile).
inspectMagic(buffer) returns { format, magic } for ZIP / GZIP / BZIP2 / XZ / 7Z / RAR4 / RAR5 / LZMA / ZSTD / TAR (the latter via the "ustar" magic at offset 257). checkExtractionPath(name, root) provides a single-entry boolean for callers that already enumerate.
Profiles strict / balanced / permissive and compliance postures hipaa / pci-dss / gdpr / soc2 overlay on the profile baseline.
b.guardArchive.inspectMagic(buffer) #
Read the first bytes of buffer and return { format, magic } when the buffer matches a known archive-format signature (zip / gzip / bzip2 / xz / 7z / rar4 / rar5 / lzma / zstd / tar). TAR is detected via the "ustar" magic at offset 257 within the first 512-byte header block. Returns null on unrecognized input or non-Buffer / empty input. Pure inspection — never mutates the buffer or throws.
Operators compare the detected format against the declared Content-Type / extension to surface format-claim mismatches before routing the bytes to a parser.
var zipBytes = Buffer.from([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00]);
var hit = b.guardArchive.inspectMagic(zipBytes);
hit.format; // → "zip"
var noise = Buffer.from([0x00, 0x01, 0x02, 0x03]);
b.guardArchive.inspectMagic(noise); // → null
b.guardArchive.checkExtractionPath(entryName, extractionRoot) #
Single-entry boolean check: returns { ok, reason } for a candidate (entryName, extractionRoot) pair. Refuses entries whose name contains a .. component (zip slip — CVE-2025-3445 class), is an absolute path (leading /, \\, or C:\\ drive-letter prefix), carries a null byte, or is empty. The framework cannot resolve path.resolve(extractionRoot, entryName) without a node:path coupling that the gate keeps portable; the operator's extraction code is expected to additionally call path.resolve and confirm the result starts with path.resolve(extractionRoot).
Use when the operator already enumerates archive entries and wants a per-call boolean rather than running the full validateEntries issue list.
b.guardArchive.checkExtractionPath("docs/readme.txt", "/var/extract").ok;
// → true
var bad = b.guardArchive.checkExtractionPath("../etc/passwd", "/var/extract");
bad.ok; // → false
bad.reason; // → "entry name contains .. component (zip slip)"
b.guardArchive.validateEntries(entries, opts) #
{
profile: "strict"|"balanced"|"permissive",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
traversalPolicy: "reject"|"audit"|"allow",
absolutePathPolicy: "reject"|"audit"|"allow",
symlinkPolicy: "reject"|"audit"|"allow",
hardlinkPolicy: "reject"|"audit"|"allow",
nestedArchivePolicy: "reject"|"audit"|"allow",
duplicateNamePolicy: "reject"|"audit"|"allow",
caseInsensitiveCollisionPolicy: "reject"|"audit"|"allow",
encryptionPolicy: "reject"|"audit"|"allow",
sparseEntryPolicy: "reject"|"audit"|"allow",
filenameProfile: "balanced"|"strict"|"permissive",
maxEntries: number, // strict 100, balanced 10000, permissive 100000
maxTotalBytes: number, // strict 100 MiB, balanced 1 GiB, permissive 10 GiB
maxEntryBytes: number, // strict 50 MiB, balanced 500 MiB, permissive 2 GiB
maxCompressionRatio: number, // strict / balanced 100, permissive 1000
maxAggregateRatio: number, // strict 200, balanced 1000, permissive 10000
maxNestedDepth: number, // strict 0, balanced 2, permissive 4
}
Inspect an operator-supplied entries array (one entry per archive member: { name, size, compressedSize, isSymlink, isHardlink, linkTarget, isDirectory, isEncrypted, attrs }) and return { ok, issues }. Issues carry { kind, severity, ruleId, location, snippet } with severity "warn" / "high" / "critical". Detected: zip-slip, absolute path, symlink / hardlink escape, compression-ratio bombs (per-entry + aggregate), per-entry size cap, total-size cap, entry-count cap, nested-archive entries, duplicate names, case-insensitive collisions, encryption-claim mismatch, sparse-tar entries, plus the full b.guardFilename leaf-safety catalog re-attached with archive-context locations. Pure inspection — never mutates input or throws on hostile entries.
var rv = b.guardArchive.validateEntries([
{ name: "docs/readme.txt", size: 1000, compressedSize: 500 },
{ name: "../etc/passwd", size: 100, compressedSize: 50 },
], { profile: "strict" });
rv.ok; // → false
rv.issues[0].kind; // → "zip-slip"
rv.issues[0].severity; // → "critical"
// Compression-ratio bomb — 50 MiB uncompressed from 50 KiB compressed
// is 1000:1, far above the 100:1 strict cap.
var bomb = b.guardArchive.validateEntries([
{ name: "bomb.bin", size: 52428800, compressedSize: 51200 },
], { profile: "strict" });
bomb.issues.some(function (i) { return i.kind === "compression-ratio-bomb"; });
// → true
b.guardArchive.gate(opts) #
{
profile: "strict"|"balanced"|"permissive",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
name: string,
...: any validateEntries opt
}
Build a b.gateContract gate suitable for b.fileUpload({ contentSafety: { "application/zip": gate } }) or b.staticServe. Operators pass ctx.entries (the enumerated entry list from their archive library) — when only ctx.bytes is supplied, the gate runs inspectMagic to confirm the format and refuses with a "no-entry-list" issue directing the operator to enumerate entries explicitly (the framework ships no parser for any archive format).
Action chain: serve (no issues) → audit-only (warn-only) → refuse (any critical/high). Archive content has no safe sanitization — there is no sanitize action in the chain.
var archiveGate = b.guardArchive.gate({ profile: "strict" });
var verdict = await archiveGate.check({
entries: [
{ name: "docs/readme.txt", size: 1000, compressedSize: 500 },
{ name: "../etc/passwd", size: 100, compressedSize: 50 },
],
});
verdict.action; // → "refuse"
// Bytes-only call without an entry list — operator must enumerate.
var zipBytes = Buffer.from([0x50, 0x4B, 0x03, 0x04, 0x14, 0x00]);
var v2 = await archiveGate.check({ bytes: zipBytes });
v2.action; // → "refuse"
v2.issues[0].kind; // → "no-entry-list"
b.guardArchive.buildProfile(opts) #
{
extends: "strict"|"balanced"|"permissive", // base profile name(s)
}
Resolve a named profile against the guard's PROFILES catalog and return the merged options bag. Operators introspecting the active caps (without calling validateEntries / gate) use this. Throws GuardArchiveError("archive.bad-profile") on unknown name.
var resolved = b.guardArchive.buildProfile({ extends: "strict" });
resolved.maxEntries; // → 100
resolved.symlinkPolicy; // → "reject"
resolved.maxCompressionRatio; // → 100
b.guardArchive.compliancePosture(name) #
Return the option overlay for a named compliance posture ("hipaa" / "pci-dss" / "gdpr" / "soc2"). Composes over a base profile to harden defaults per regulatory regime. Throws GuardArchiveError("archive.bad-posture") on unknown name.
var posture = b.guardArchive.compliancePosture("hipaa");
posture.symlinkPolicy; // → "reject"
posture.hardlinkPolicy; // → "reject"
posture.forensicSnippetBytes; // → 256
b.guardArchive.loadRulePack(pack) #
Register an operator-supplied rule pack with the guard-archive registry. The pack is identified by pack.id (non-empty string) and stored for later inspection / dispatch by gates that opt in via opts.rulePackId. Returns the pack object unchanged on success; throws GuardArchiveError("archive.bad-opt") when pack is missing or pack.id is not a non-empty string.
var pack = b.guardArchive.loadRulePack({
id: "kb-2026-archive",
extraReservedNames: ["system32"],
rules: [
{ id: "no-windows-system", severity: "critical",
reason: "entry name targets Windows system directory" },
],
});
pack.id; // → "kb-2026-archive"
b.guardArchive.inspect(adapter, opts?) #
{
profile: "strict" | "balanced" | "permissive" | "hipaa" | ...,
format: "zip" (v0.12.7 — tar v0.12.8, gz v0.12.9),
audit: b.audit,
}
Bridge primitive: runs b.archive.read.zip(adapter).inspect() to enumerate the entry list (no decompression), then hands the list to validateEntries for the full posture-aware gate. Returns { entries, issues, decisions } so the caller decides whether to proceed.
Operators using the lower-level read primitive directly call this to combine the metadata pass with the guard pass; b.safeArchive. extract does the same composition inline under the hood.
var adapter = b.archive.adapters.fs("/var/uploads/payload.zip");
var summary = await b.guardArchive.inspect(adapter, { profile: "strict" });
if (summary.issues.length > 0) refuse(summary.issues);
b.guardArchive.zipBombPolicy(opts) #
{
maxEntries: 65535,
maxEntryDecompressedBytes: 128 * MiB,
maxTotalDecompressedBytes: 4 * GiB,
maxExpansionRatio: 100,
}
Policy-object builder for decompression-bomb caps. Operators declare the cap set once + reuse it across b.archive.read.zip / b.safeArchive.extract call sites. Defaults match the cap shape in lib/archive-read.js DEFAULT_BOMB_POLICY.
var policy = b.guardArchive.zipBombPolicy({
maxTotalDecompressedBytes: 256 * 1024 * 1024,
maxExpansionRatio: 50,
});
await b.safeArchive.extract({ source, destination, bombPolicy: policy });
b.guardArchive.entryTypePolicy(opts) #
{
symlinks: false,
hardlinks: false,
devices: false,
fifos: false,
sockets: false,
}
Policy-object builder for entry-type allowlist. Defaults refuse every "interesting" entry type (symlink / hardlink / device / fifo / socket); operators opt in per-type and route through the additional realpath-on-target check in b.guardFilename. verifyExtractionPath.
Symlinks + hardlinks under default settings are refused unconditionally — CVE-2025-11001 / 11002 / 26960 class.
var policy = b.guardArchive.entryTypePolicy({ symlinks: true });
await b.safeArchive.extract({ source, destination, entryTypePolicy: policy });
b.guardArchive.tarEntryPolicy(opts) #
{
symlinks: false,
hardlinks: false,
devices: false,
fifos: false,
sockets: false,
}
Tar-specific entry-type policy. Same shape as entryTypePolicy but explicitly named for tar's typeflag vocabulary (1=hardlink, 2=symlink, 3=char-device, 4=block-device, 6=FIFO, 7=contiguous- file) so call sites read clearly when the operator's intent is tar-specific. Defaults refuse every dangerous typeflag. Operators opting symlinks / hardlinks in get the link target routed through b.guardFilename.verifyExtractionPath's realpath-on-target check (defends CVE-2026-23745 / 24842 node-tar path-resolution divergence class).
var policy = b.guardArchive.tarEntryPolicy({ symlinks: true });
await b.safeArchive.extract({
source, destination, entryTypePolicy: policy,
allowDangerous: { symlinks: true },
});
Last updated 2026-08-08T16:39:49.652Z by seeder.