04 文件名、路径与危险字符安全
1. rm -rf.txt 会执行命令吗
不会。下面只是一个字符串:
rm -rf.txt
如果它只是作为普通文件名传给安全的文件系统 API,不会自动变成 Shell 命令。
危险来自程序把不可信文件名放入其他解释环境:
Shell 命令
磁盘路径
HTTP Header
HTML
日志终端
SQL
对象存储 key
例如下面的写法危险:
exec(`convert ${file.originalname} output.png`);
如果文件名包含 ;、&、反引号或重定向符号,Shell 可能把它解释成命令结构。
核心规则:
用户文件名只能作为不可信元数据,不能直接成为命令、路径或最终存储名。
2. originalname 的风险
攻击者可以构造:
../../server.ts
..\..\windows\system.ini
/etc/passwd
C:\Windows\system.ini
.env
invoice.pdf.exe
photo.jpg.js
CON
NUL
file�.jpg
normal.jpg\r\nX-Injected: value
即使浏览器通常只发送基础文件名,也不能把浏览器行为当成服务器安全边界。攻击者可以自己构造 HTTP 请求。
3. 路径穿越
危险:
const destination = path.join(
uploadRoot,
file.originalname,
);
如果 originalname 是:
../../server.ts
最终路径可能逃出 uploadRoot。
即使使用 path.basename(),仍然不建议把结果作为最终存储名:
const baseName = path.basename(file.originalname);
它只去除一部分路径信息,不能解决:
- 同名覆盖;
- Windows 保留名称;
- Unicode 混淆;
- 控制字符;
- 超长名称;
- Shell 和 Header 上下文风险。
4. 服务端生成 storage key
推荐:
import { randomUUID } from "node:crypto";
filename(_req, _file, callback) {
callback(null, randomUUID());
}
完成内容检测后,可以根据服务器确认的类型增加扩展名:
uuid.jpg
uuid.png
uuid.webp
不要根据 originalname 直接确定最终扩展名。
5. 原始名只作为元数据
推荐关系:
original_name = 用户提交的名称
download_name = 规范化后用于下载显示的名称
storage_key = 服务器生成的安全名称
数据库可以保留原始名称用于审计,但展示和下载时通常使用安全规范化后的 download_name。
6. 下载名规范化
目标不是生成磁盘路径,而是生成可读且安全的显示名称。
import path from "node:path";
const controlCharacters = /[\u0000-\u001f\u007f]/g;
const windowsReservedCharacters = /[<>:"/\\|?*]/g;
const trailingDotsAndSpaces = /[. ]+$/g;
export function normalizeDownloadName(
originalName: string,
): string {
const baseName = path
.basename(originalName)
.normalize("NFC")
.replace(controlCharacters, "")
.replace(windowsReservedCharacters, "_")
.replace(trailingDotsAndSpaces, "")
.trim();
const limited = Array.from(baseName)
.slice(0, 150)
.join("");
return limited || "download";
}
这只是一个教学版本。正式项目还要处理 Windows 保留名称、团队允许字符集、扩展名策略和重复名称。
不能把规范化函数理解成“文件从此安全”。文件内容仍然需要独立检测。
7. Windows 保留名称
即使带扩展名也可能有问题:
CON
CON.txt
PRN
AUX
NUL
COM1
LPT1
跨平台项目应检测这些基础名称,并替换或加前缀:
_CON.txt
如果物理存储名始终使用 UUID,这类问题主要出现在下载名和导出压缩包内部文件名。
8. Unicode 与控制字符
Unicode 中可能存在:
- 视觉上相同但编码不同的字符;
- 从右向左控制字符;
- 零宽字符;
- 看起来像拉丁字母的其他字符;
- 组合字符。
最基本的处理是统一 Unicode normalization:
name.normalize("NFC")
高安全后台还可以拒绝双向控制字符和不可见格式字符。不要为了“安全”把所有非 ASCII 文件名全部删除,否则会破坏中文等正常名称。
9. 文件名长度
需要同时考虑:
- 数据库字段长度;
- 文件系统单个组件限制;
- UTF-8 字节数;
- HTTP Header 长度;
- 对象存储 key 限制;
- UI 展示。
JavaScript string.length 也不完全等于用户看到的字符数量或 UTF-8 字节数。实践中应设置合理的字符和字节上限。
10. 双扩展名
invoice.pdf.exe
avatar.jpg.php
picture.png.js
不要使用“最后看起来有 .jpg”作为安全判断。即使文件名只有 .jpg,内容仍可能不是 JPEG。
最终类型应来自内容检测和允许列表,最终扩展名由服务端映射:
const extensionByDetectedType = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
} as const;
11. Shell 安全
不推荐:
exec(`ffmpeg -i ${inputPath} ${outputPath}`);
即使路径由服务器生成,字符串拼接仍然容易在未来重构时引入注入。
优先使用:
- Node.js 库 API;
- 不经过 Shell 的进程调用;
- 固定可执行程序和参数数组;
- 服务器生成的路径;
- 超时、资源和输出限制。
文件名不是唯一风险,文件内容也可能攻击解析器,因此图片、PDF、视频处理工具应及时更新并在受限环境运行。
12. Header 与 Content-Disposition
下载时不要自己拼接:
res.setHeader(
"Content-Disposition",
`attachment; filename="${userInput}"`,
);
文件名可能包含引号、换行、非 ASCII 字符。优先使用 Express:
res.download(path, safeDownloadName);
或:
res.attachment(safeDownloadName);
Express 会使用适合 Content-Disposition 的编码逻辑,但调用前仍应限制名称长度和控制字符。
13. 日志安全
攻击者可能通过换行和终端控制字符伪造日志视觉效果。
不要直接拼接:
logger.info(`uploaded ${file.originalname}`);
使用结构化日志,并对不可信文本限制长度:
logger.info({
event: "file_uploaded",
fileId,
originalName: safeLogName,
size: file.size,
});
敏感文件名也可能泄露个人信息,应根据业务决定是否记录。
14. preservePath
Multer 支持:
multer({
preservePath: true,
});
它会保留客户端提交文件名中的完整路径,而不是只保留基础名称。普通 Web 上传不应开启,因为:
- 客户端路径没有业务可信度;
- 会泄露客户端目录信息;
- 增加路径穿越和跨平台处理复杂度。
只有明确处理目录上传、并重新验证每个相对路径片段的专用系统才考虑。
15. 安全检查清单
- storage key 是否由服务器生成?
- 是否从不使用 originalname 构造目录?
- 是否限制文件名字符和长度?
- 是否处理 Windows 保留名?
- 是否规范化 Unicode?
- 是否禁止控制字符和路径分隔符?
- 是否不把文件名拼进 Shell?
- 是否由检测后的类型决定扩展名?
- 是否用 Express API 构造附件响应?
- 是否对日志进行结构化和长度限制?
16. 练习题
- 测试
rm -rf.txt作为普通 UUID 存储文件的原始名称,解释为什么不会执行命令。 - 编写针对
../../server.ts的下载名规范化测试。 - 增加 Windows 保留文件名检查。
- 测试换行、空字节、双向控制字符和超长中文文件名。
- 把一段使用
exec()拼接文件名的代码改成不经过 Shell 的安全调用。 - 设计 original_name、download_name、storage_key 三个字段的职责。