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

它只去除一部分路径信息,不能解决:

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. 文件名长度

需要同时考虑:

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}`);

即使路径由服务器生成,字符串拼接仍然容易在未来重构时引入注入。

优先使用:

文件名不是唯一风险,文件内容也可能攻击解析器,因此图片、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. 安全检查清单

16. 练习题

  1. 测试 rm -rf.txt 作为普通 UUID 存储文件的原始名称,解释为什么不会执行命令。
  2. 编写针对 ../../server.ts 的下载名规范化测试。
  3. 增加 Windows 保留文件名检查。
  4. 测试换行、空字节、双向控制字符和超长中文文件名。
  5. 把一段使用 exec() 拼接文件名的代码改成不经过 Shell 的安全调用。
  6. 设计 original_name、download_name、storage_key 三个字段的职责。

官方参考