Path & filename

Filenames

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; pair with safeDirName when you need stricter directory-name handling.

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

In order:

  1. Trim whitespace. If the result is empty, return fallbackName.
  2. Strip path components. Apply path.posix.basename then path.win32.basename so neither foo/bar.txt nor foo\bar.txt survives — only the final segment remains.
  3. Strip non-portable characters. C0/C1 controls (0x000x1f, 0x7f0x9f) and the Windows-invalid set < > : " / \\ | ? * are removed on every platform.
  4. Trim again.
  5. If the result is empty, ".", or "..", return fallbackName.
  6. Suffix Windows reserved basenames. Compare the part before the first . case-insensitively with the Windows device-name set, including CON, 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, preserving the original case and extension on every platform.
  7. 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.

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("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 .config stays hidden on POSIX systems).
  • Trim trailing dots. Surrounding spaces are removed by the documented trim
  • steps, but Windows-normalized dot/space spellings can still alias; reject or rewrite them when Windows portability or cross-platform migration matters.

  • 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.

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 helperssafeDirName, safePathSegmentHashed for directory-segment sanitization.
  • root() — the boundary you'll write into after sanitizing.