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; 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:

  1. Trim whitespace. An empty result is unusable.
  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. An empty result, ".", or ".." is unusable.
  6. 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.
  7. Make the final name device-safe. 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 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 .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 helpers — legacy directory-segment sanitizers and safePathSegmentHashedV2 for untrusted install IDs.
  • root() — the boundary you'll write into after sanitizing.