Filenames
sanitizeUntrustedFileName(name, fallback) reduces a filename string from an untrusted source to one traversal-free path segment. Use it as a thin first pass before storing user-supplied names; use safePathSegmentHashedV2 when mapping untrusted install IDs to separate directory names.
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
await fs.write(`uploads/${safe}`, body);
#Signature
function sanitizeUntrustedFileName(fileName: string, fallbackName: string): string;
#What it does
The primary name goes through this pipeline first:
- Trim whitespace. An empty result is unusable.
- Strip path components. Apply
path.posix.basenamethenpath.win32.basenameso neitherfoo/bar.txtnorfoo\bar.txtsurvives — only the final segment remains. - Strip non-portable characters. C0/C1 controls (
0x00–0x1f,0x7f–0x9f) and the Windows-invalid set< > : " / \\ | ? *are removed on every platform. - Trim again.
- An empty result,
".", or".."is unusable. - Truncate. If the cleaned segment is longer than 200 UTF-16 code units, take up to the first 200 without splitting a valid Unicode surrogate pair.
- Make the final name device-safe. Compare the part before the first
.case-insensitively with the Windows device-name set, includingCON,PRN,AUX,NUL,CLOCK$,CONIN$,CONOUT$,COM1..9,LPT1..9, and their superscript¹,², and³variants. Windows-ignored spaces and dots at the end of that basename do not disguise a device name. A match gains_before its extension on every platform. If the suffix would exceed 200 code units, the unsuffixed tail is shortened first, so truncation cannot recreate a device name.
Only when the primary name is unusable does fallbackName go through the same nonrecursive pipeline. A safe fallback is preserved exactly; path components, controls, reserved device names, and overlong fallback names receive the same treatment as the primary name. If both candidates are unusable, the function returns the fixed safe literal "file".
If truncation itself exposes a reserved-device basename after Windows ignores trailing spaces or dots, the result is shortened once more and receives the same underscore suffix. A name that reaches the sanitization branch therefore remains at most 200 UTF-16 code units and is never a Windows reserved-device alias. Fallback names pass through the same checks before they can be returned.
That's it. The function stays intentionally small: it removes traversal and the most obvious cross-platform device and character hazards, but it is not a complete portable-filename or uniqueness policy.
#Examples
sanitizeUntrustedFileName("notes.txt", "untitled"); // "notes.txt"
sanitizeUntrustedFileName("../../etc/passwd", "upload"); // "passwd"
sanitizeUntrustedFileName("foo\\bar.png", "upload"); // "bar.png"
sanitizeUntrustedFileName("a\u0000b\tc", "upload"); // "abc"
sanitizeUntrustedFileName(" ", "fallback"); // "fallback"
sanitizeUntrustedFileName(".", "fallback"); // "fallback"
sanitizeUntrustedFileName("..", "fallback"); // "fallback"
sanitizeUntrustedFileName("<>", "../../etc/passwd"); // "passwd"
sanitizeUntrustedFileName("<>", "../.."); // "file"
sanitizeUntrustedFileName("CON", "fallback"); // "CON_"
sanitizeUntrustedFileName("nul.txt", "fallback"); // "nul_.txt"
sanitizeUntrustedFileName("aux.c", "fallback"); // "aux_.c"
sanitizeUntrustedFileName("conin$", "fallback"); // "conin$_"
sanitizeUntrustedFileName("a".repeat(300), "x"); // 200-char "aaa..."
#What it does not do
The function is deliberately narrow. It will not:
- Replace leading dots (so a name like
.configstays hidden on POSIX systems). - Trim trailing dots. Surrounding spaces are removed by the documented trim
- Add an extension or change case.
- Validate file content. To enforce an extension allow-list, check after sanitization.
- Deduplicate against existing files. Append a random suffix if you need uniqueness.
steps, but Windows-normalized dot/space spellings can still alias; reject or rewrite them when Windows portability or cross-platform migration matters.
Windows reserved basenames are handled by the default portability pass; callers no longer need to layer a separate reserved-name recipe on top.
#Common patterns
#Make a unique filename
import { sanitizeUntrustedFileName } from "@openclaw/fs-safe/advanced";
import { randomUUID } from "node:crypto";
const base = sanitizeUntrustedFileName(req.body.fileName, "upload");
const unique = `${randomUUID()}-${base}`;
await fs.write(`uploads/${unique}`, body);
#Restrict to a known set of extensions
const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
const ext = path.extname(safe).toLowerCase();
if (![".png", ".jpg", ".webp"].includes(ext)) return reply(400, "unsupported extension");
#Sanitize, then write through a Root
const safe = sanitizeUntrustedFileName(req.body.fileName, "upload");
await fs.write(`uploads/${safe}`, body); // fs is a Root() handle; rejects traversal too
#See also
- Install path helpers — legacy directory-segment sanitizers and
safePathSegmentHashedV2for untrusted install IDs. root()— the boundary you'll write into after sanitizing.