Guard Filename
Filename content-safety primitive (KIND="filename"). Validates user-supplied filenames before they reach disk, network paths, or Content-Disposition headers. Standalone primitive — does NOT register into b.guardAll's content-type-routed dispatch (no canonical mime / ext); operators wire it directly via b.fileUpload({ filenameSafety: gate }) and similar host opts.
Path-traversal defense: .. / ../ / ..\\, percent-encoded %2e%2e, double-encoded %252e%252e, and UTF-8 overlong sequences 0xC0 0xAE (for .) and 0xC0 0xAF (for /) ALWAYS throw — no profile downgrades the refusal. Threat catalog grounded in OWASP Path Traversal + WSTG file-inclusion testing guides; CWE-22 / 23 / 35 / 73 / 78 / 434 / 36; PortSwigger File-path-traversal series (null-byte bypass + extension validation); Memento-RTLO + RTL-Spiegel filename-spoofing reports (CVE-2021-42574 in filename context); Kevin Boone overlong UTF-8 write-up.
Universal-throw security floor: null-byte truncation (file.txt\x00.exe), NTFS alternate data streams (file.txt:hidden.exe), UNC paths (\\server\share\file and //host/share/file), and overlong UTF-8 byte sequences ALL throw GuardFilenameError regardless of profile — there is no sanitize-action that repairs these classes. Windows reserved device names (CON / PRN / AUX / NUL / COM1-9 / LPT1-9 / CLOCK$ / CONFIG$) refuse under strict and balanced (even with extensions — CON.txt collides with the device).
Unicode hygiene: BIDI / RTLO refuses at every profile (Memento- RTLO Photo01Bygpj.SCR displays as Photo01ByRCS.jpg while the OS opens .SCR). Zero-width and invisible-formatting strip under balanced/permissive, refuse under strict. Homoglyph (Cyrillic / Greek / fullwidth Latin mixed with ASCII letters) refuses under strict, audits under balanced/permissive.
Extension policy: operator-supplied extensionAllowlist catches double-extension bypass (file.jpg.exe lands at the last .exe and refuses). Shell-shortcut / executable extensions (.lnk / .url / .desktop / .scr / .bat / .cmd / .com / .pif / .vbs / .js / .jse / .wsf / .wsh / .ps1 / .psm1 / .app / .deb / .rpm / .msi and the broader native-binary family) refuse under strict, audit under balanced/permissive.
Length caps: 64 bytes (strict), 255 bytes (balanced/permissive). Path separators in the leaf refuse under strict/balanced; permissive opts in to multi-component paths via pathSeparatorsPolicy: "audit" and maxComponents > 1.
Profiles: strict / balanced / permissive. Compliance postures: hipaa / pci-dss / gdpr / soc2. Threat-detection regex literals composed programmatically from numeric codepoint range tables (lib/codepoint-class); source file never embeds attack characters.
b.guardFilename.validate(input, opts?) #
{
profile: "strict"|"balanced"|"permissive",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
bidiPolicy: "reject"|"strip"|"allow",
controlPolicy: "reject"|"strip"|"allow",
nullBytePolicy: "reject", // always reject
zeroWidthPolicy: "reject"|"strip"|"allow",
homoglyphPolicy: "reject"|"audit"|"allow",
traversalPolicy: "reject", // always reject
reservedCharPolicy: "reject"|"strip"|"allow",
reservedNamePolicy: "reject"|"audit"|"allow",
adsPolicy: "reject", // always reject
leadingTrailingPolicy: "reject"|"strip"|"allow",
shellExecExtPolicy: "reject"|"audit"|"allow",
pathSeparatorsPolicy: "reject"|"audit"|"allow",
unicodeNormalization: "NFC"|null,
requireAscii: boolean,
extensionAllowlist: string[]|null,
requireSingleDot: boolean,
maxBytes: number, // leaf-name byte cap
maxComponents: number, // path-component count
}
Inspect a filename (string or Buffer) and return { ok, issues }. Each issue carries { kind, severity, ruleId, location, snippet } with severity in "warn"|"high"|"critical". Detected: path-traversal raw and percent-encoded, null-byte truncation, NTFS ADS, UNC path, overlong UTF-8, Windows reserved-name, reserved character, leading/trailing whitespace + trailing dot, BIDI / control / zero-width / homoglyph, non-ASCII (when requireAscii), length cap, multi-dot violation, extension allowlist miss, double- extension with executable last segment, shell-shortcut extension. Pure inspection — never throws.
var rv = b.guardFilename.validate("../etc/passwd", { profile: "strict" });
rv.ok; // → false
rv.issues.some(function (i) { return i.kind === "path-traversal"; }); // → true
var ok = b.guardFilename.validate("report-2026-Q1.txt", { profile: "strict" });
ok.ok; // → true
b.guardFilename.sanitize(input, opts?) #
{
profile: "strict"|"balanced"|"permissive",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
mode: "enforce"|"strip",
audit: { safeEmit: function }, // optional sink for strip mode
unicodeNormalization: "NFC"|null,
maxBytes: number,
}
Best-effort cleanup of a filename. Two modes: "enforce" (default; applies the profile's strip/reject policies and throws on unsanitizable refusals) and "strip" (operator-friendly Content-Disposition path — replaces control / bidi / zero-width codepoints with _ and applies a security floor).
The security floor ALWAYS throws regardless of mode/profile: path-traversal raw and percent-encoded, null-byte, NTFS alternate data streams, UNC paths, overlong UTF-8 sequences, and post-strip length-cap violation. These classes are unrepairable — silently fixing them would mask the attack signal an audit log needs.
var safe = b.guardFilename.sanitize("My File.txt", { profile: "balanced" });
safe; // → "My File.txt"
// Path traversal ALWAYS throws — never sanitizable.
try {
b.guardFilename.sanitize("../etc/passwd", { profile: "permissive" });
} catch (e) {
e.code; // → "filename.traversal"
}
b.guardFilename.gate(opts?) #
{
profile: "strict"|"balanced"|"permissive",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
name: string, // gate identity for audit / observability
}
Build a b.gateContract gate that consumes ctx.filename (or ctx.name). Action chain: serve (no filename or clean) → audit-only (warn-only issues) → sanitize (critical/high but every reject-policy off — strip-eligible classes only) → refuse (any reject-policy active or sanitize fails). Path-traversal / null-byte / NTFS-ADS / UNC / overlong-UTF-8 always cause refuse — there is no sanitize action for those classes.
var fnGate = b.guardFilename.gate({ profile: "strict" });
var verdict = await fnGate.check({ filename: "../etc/passwd" });
verdict.action; // → "refuse"
var ok = await fnGate.check({ filename: "report.txt" });
ok.action; // → "serve"
b.guardFilename.verifyExtractionPath(entryName, extractionRoot, opts?) #
{
followSymlinks: boolean, // default false — symlink in the
// resolved path refuses unless set
reservedNamePolicy: string, // "allow" opts out of the Windows
// reserved-device-name segment check
adsPolicy: string, // "allow" opts out of the NTFS-ADS check
leadingTrailingPolicy: string, // "allow" opts out of the trailing-dot /
// leading-or-trailing-whitespace check
}
Dual-check extraction path safety: string-check (refuses .., leading / / \\, drive-letter prefix, null byte, PATH_MAX overflow) followed by fs.realpath agreement check (the resolved path on disk must land inside the realpath of the extraction root). Returns the resolved absolute path on success; throws GuardFilenameError on any refusal.
Per-segment Windows-extraction hazards are refused too — these are within-root write-target redirections / collisions that the containment + realpath checks structurally cannot see, so they need a name-level check the disk validate / sanitize paths already carry: a Windows reserved device name (CON / NUL / COM1 / …, which resolves to the device), NTFS alternate-data-stream syntax (name:stream, which writes a hidden stream of the base file), and a trailing dot / leading-or-trailing whitespace (secret.txt., which Windows strips so the entry overwrites an existing sibling). The checks are platform-unconditional — the verifier may run on Linux while extraction happens on Windows — and each has an opt-out for Linux-only targets (reservedNamePolicy / adsPolicy / leadingTrailingPolicy: "allow"), mirroring validate.
Out of this primitive's scope (single-entry, name-only): 8.3 short-name aliasing (PROGRA~1), case-insensitive cross-entry collision (Readme.txt vs README.TXT on a case-preserving FS), and archive symlink/hardlink ENTRY-target validation. The first two are cross-entry properties and the third needs the entry's declared link target, which this function never sees — they belong to the extract orchestrator (b.archive.read.zip.extract / b.safeArchive), which owns the case-folded seen-set and the link-target gate.
Companion to b.guardArchive.checkExtractionPath (the string-only portable gate the guard-archive primitive keeps fs-free for use as a posture cascade member). verifyExtractionPath deliberately couples to node:fs — the deeper realpath check defends the CVE-2025-4517 PATH_MAX TOCTOU class where the operator's path resolution and the kernel's diverge silently past PATH_MAX.
b.archive.read.zip.extract composes this on every entry; operators extracting via the safeArchive orchestrator never call it directly. Operators rolling their own extract loop call it per entry.
var resolved = b.guardFilename.verifyExtractionPath(
"docs/readme.txt",
"/var/quarantine"
);
// → "/var/quarantine/docs/readme.txt"
// ../ refuses
b.guardFilename.verifyExtractionPath("../etc/passwd", "/var/quarantine");
// throws GuardFilenameError("filename.extraction-traversal")
// PATH_MAX-overflow refuses BEFORE realpath truncation hits
b.guardFilename.verifyExtractionPath(longName, "/var/quarantine");
// throws GuardFilenameError("filename.extraction-path-max")
b.guardFilename.compliancePosture(name) #
Look up a compliance-posture overlay by name (one of "hipaa" / "pci-dss" / "gdpr" / "soc2"). Returns a fresh clone of the posture overlay so the caller may mutate it freely without disturbing the shared table. Throws GuardFilenameError with code "filename.bad-posture" when the name is not one this guard maps. Wired by gateContract.defineGuard through gateContract.lookupCompliancePosture, so the clone semantics and error code are identical across every guard in the family.
var posture = b.guardFilename.compliancePosture("hipaa");
posture; // → overlay clone (mutable)
try {
b.guardFilename.compliancePosture("not-a-regime");
} catch (e) {
e.code; // → "filename.bad-posture"
}
b.guardFilename.buildProfile(opts) #
{
extends: string|string[], // base profile name(s) to compose
...: any guard key, // inline override of resolved keys
}
Compose a derived profile from one or more named bases plus inline overrides, resolving names through this guard's own PROFILES table. opts.extends is a base profile name ("strict" / "balanced" / "permissive") or an array of names — later entries shadow earlier ones, and inline opts keys win last. Wired by gateContract.defineGuard through gateContract.makeProfileBuilder, so operator-defined profiles stay traceable to a baseline instead of a hand-typed dictionary.
var custom = b.guardFilename.buildProfile({ extends: "strict" });
custom; // → composed profile object
b.guardFilename.loadRulePack(pack) #
Register an operator-supplied rule pack with this guard's rule-pack registry. The pack is identified by pack.id (a non-empty string) and stored for later dispatch by gates that opt in via opts.rulePackId. Returns the pack unchanged on success; throws GuardFilenameError with code "filename.bad-opt" when pack is missing or pack.id is not a non-empty string. Wired by gateContract.defineGuard through gateContract.makeRulePackLoader, so storage shape and validation are identical across the family.
var pack = b.guardFilename.loadRulePack({ id: "tenant-policy", rules: [] });
pack.id; // → "tenant-policy"
b.guardFilename.resolveOpts(opts?) #
{
profile: string, // one of PROFILES; default this guard's default
compliancePosture: string, // overlay one of hipaa/pci-dss/gdpr/soc2
}
Resolve caller opts against this guard's PROFILES + compliance-posture overlays into the fully-defaulted option set the guard runs on — the same resolution validate / sanitize / gate apply internally. Wired by gateContract.defineGuard from the guard's binding config (profiles / postures / defaults / error class), so a guard's bespoke gate calls resolveOpts instead of re-declaring the per-guard resolver wrapper. Throws GuardFilenameError with code "filename.bad-opt" / "filename.bad-posture" on an unknown profile or posture name.
var resolved = b.guardFilename.resolveOpts({ profile: "strict" });
resolved.profile; // → "strict"
Last updated 2026-08-08T16:39:49.652Z by seeder.