功能说明
sanitizeFilename 把用户上传的原始文件名转换为可安全存储的名称,去掉目录部分、控制字符、Windows 保留字符与首尾点号,避开 CON、NUL、COM1 等保留设备名,并限制总长度。它适合上传接口、对象存储键生成和批量导入。返回的只是文件名,不包含目录,调用方应自行拼接受控目录。
关键代码
输入先做 NFC 归一化,避免同形不同编码的文件名绕过去重。按正反斜杠取最后一段,替换非法字符与空白,去除首尾点和空格。扩展名最多保留十六个字符,主体按最大长度减去扩展名后的预算截断。若主体命中保留名则加下划线前缀。所有路径分隔符不会出现在结果中,因此不能借文件名穿越目录。
使用注意
清洗不能替代扩展名白名单与内容类型检测,攻击者仍可上传可执行脚本或双扩展名文件,落盘后应禁用执行权限并通过独立域名提供下载。保留名规则针对 Windows,跨平台部署仍需统一策略。Unicode 归一化可能改变原文件名,若需保留展示名应另存元数据字段。截断可能造成同名冲突,应追加随机标识。
验证方式
测试 ../../etc/passwd、C:\temp\file.txt、带空字节、控制字符、冒号与问号的文件名,确认路径分隔符消失。测试 CON、nul.txt、COM1.log 会被加前缀。验证连续空格折叠、首尾点去除、空文件名回退、超长文件名截断以及扩展名保留。最后测试非法 replacement、零长度上限与非字符串输入的处理。
代码示例
const RESERVED_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
export interface SanitizeOptions {
maxLength?: number;
replacement?: string;
fallback?: string;
}
export function sanitizeFilename(input: string, options: SanitizeOptions = {}): string {
const { maxLength = 120, replacement = "_", fallback = "file" } = options;
if (!Number.isInteger(maxLength) || maxLength < 1) {
throw new RangeError("maxLength must be a positive integer");
}
if (!replacement || /[\\/:*?"<>|]/.test(replacement)) {
throw new TypeError("replacement contains forbidden characters");
}
const normalized = String(input).normalize("NFC").replace(/[\u0000-\u001f\u007f]/g, "");
const base = normalized.split(/[\\/]/).pop() ?? "";
let name = base
.replace(/[<>:"|?*]/g, replacement)
.replace(/\s+/g, " ")
.trim()
.replace(/^\.+/, "")
.replace(/\.+$/, "");
if (!name) return fallback;
const dot = name.lastIndexOf(".");
const extension = dot > 0 ? name.slice(dot).slice(0, 16) : "";
let stem = dot > 0 ? name.slice(0, dot) : name;
if (RESERVED_NAMES.test(stem)) stem = "_" + stem;
const budget = Math.max(1, maxLength - extension.length);
stem = stem.slice(0, budget).trim();
if (!stem) stem = fallback;
name = stem + extension;
return name;
}