标签:Web 安全 / 路径穿越 / 符号链接 / Redis Lua / 临时授权
场景:系统要允许用户下载存放在 NAS 上的影像、报告、脑电数据包。文件路径来自数据库,下载地址要发给前端,还要防住路径穿越、软链逃逸,以及最阴的一种故障------共享盘掉线。
说明:文中类名已泛化、路径与业务字段已脱敏,代码结构保持原样。
一、先把要防的东西列全
"给用户一个下载链接"这句话背后,至少有五类攻击面和一类故障:
| # | 威胁 | 典型手法 |
|---|---|---|
| 1 | 路径穿越 | ../../../../etc/passwd |
| 2 | 前缀绕过 | 白名单是 /data/files,攻击者传 /data/files-evil/x |
| 3 | 符号链接逃逸 | 白名单目录里放一个软链指向 /etc |
| 4 | 链接泄露与重放 | 下载 URL 里直接带内部路径 → 被转发、被收藏、被爬 |
| 5 | 越权消费 | A 拿到 B 的下载 URL,或同一 URL 被重复使用 |
| 6 | 共享盘掉线(故障,非攻击) | NAS 没挂上,/mnt/nas/... 变成了服务器本地同名目录,静默写入本地盘 |
第 6 条最容易被忽略,因为它是"不报错却出事"------路径合法、写入成功、日志干净,但数据落到了错误的地方。
下面按四层防线来讲。
二、第一层:路径策略
核心规则只有三条,但每条都有讲究:
java
public Path resolveImportSource(String rootKey, String relativePath) {
Path root = resolveConfiguredDirectory(configuredImportRoot(rootKey), "服务器入站根目录");
Path relative = validateRelativePath(relativePath);
Path candidate = root.resolve(relative).normalize(); // ① 先 normalize
if (!candidate.startsWith(root)) { // ② 再前缀校验
throw new SecurityException("服务器文件路径不在允许的入站目录内");
}
rejectSymbolicLinkSegments(root, candidate); // ③ 逐段查软链
Path real = toExistingPath(candidate);
if (!real.startsWith(root)
|| (!Files.isRegularFile(real, LinkOption.NOFOLLOW_LINKS)
&& !Files.isDirectory(real, LinkOption.NOFOLLOW_LINKS))
|| !Files.isReadable(real)) {
throw new SecurityException("服务器文件路径不是允许读取的普通文件或目录");
}
return real;
}
2.1 为什么 normalize() 必须在 startsWith() 之前
Paths.get("/data/files/../../etc/passwd") 本身不会被任何检查拦住 ------它的字面量确实以 /data/files 开头。必须先用 normalize() 把 .. 折叠掉,得到 /etc/passwd,startsWith 才有意义。
顺序颠倒就是漏判。这条几乎是最常见的路径穿越漏洞成因。
2.2 为什么 startsWith 能挡住前缀绕过
Path.startsWith() 是按路径段 比较的,不是字符串前缀比较。/data/files-evil/x 不会被认为"以 /data/files 开头"。
如果这里手写 path.toString().startsWith(root.toString()),就会漏掉 /data/files-evil 这类绕过。用 Path 而不是 String 做前缀判断,是这个小节的全部意义。
2.3 为什么必须逐段检查符号链接
只检查最终路径是不是软链,是不够的------攻击者可以只让中间某一层是软链:
白名单根 /data/files
└── a/ ← 攻击者创建 a 为软链 → /etc
└── passwd ← 最终路径 /data/files/a/passwd 不是软链
所以要从根目录开始,逐段走下去:
java
private void rejectSymbolicLinkSegments(Path root, Path candidate) {
Path current = root;
for (Path segment : root.relativize(candidate)) {
current = current.resolve(segment);
if (Files.isSymbolicLink(current)) {
throw new SecurityException("服务器文件路径不能包含符号链接");
}
}
}
2.4 一个容易被忘的坑:Windows 盘符路径在 Linux 上不是绝对路径
如果代码判定"绝对路径"用的是 Path.isAbsolute(),那么在 Linux 上跑时,C:\Windows\win.ini 不满足 isAbsolute()(Linux 认为它是普通文件名)。于是它会通过"必须是相对路径"的检查,然后在后续拼接中产生意料之外的结果。
所以这里显式加了一条正则,把 Windows 盘符路径在自己的判据里当作绝对路径拒绝:
java
/** Windows 盘符绝对路径在非 Windows 环境中也必须被识别并拒绝。 */
private static final Pattern WINDOWS_DRIVE_PATH = Pattern.compile("^[A-Za-z]:[\\\\/].*");
private Path validateRelativePath(String relativePath) {
if (!hasText(relativePath)) {
throw new IllegalArgumentException("服务器文件相对路径不能为空");
}
if (WINDOWS_DRIVE_PATH.matcher(relativePath).matches()
|| relativePath.startsWith("\\\\")) {
throw new IllegalArgumentException("服务器文件路径必须使用相对路径");
}
Path relative = Paths.get(relativePath);
if (relative.isAbsolute()) {
throw new IllegalArgumentException("服务器文件路径必须使用相对路径");
}
for (Path segment : relative) {
if ("..".equals(segment.toString())) {
throw new SecurityException("服务器文件路径不能包含父级跳转");
}
}
return relative;
}
注意最后那段逐段拒绝 .. :即便有 normalize() + startsWith() 兜底,在入口处直接拒掉父级跳转仍然值得做------越早拒绝,越少代码需要依赖"后面那层检查还在"。
2.5 只接受干净的 file: URI
数据库里存的是服务端 URI,这里对它的形状要求很严:
java
if (fileUri == null || !"file".equalsIgnoreCase(fileUri.getScheme()) || fileUri.isOpaque()) {
throw new IllegalArgumentException("NAS 存储地址必须使用绝对 file URI");
}
if (hasText(fileUri.getRawAuthority()) || fileUri.getRawQuery() != null
|| fileUri.getRawFragment() != null) {
throw new IllegalArgumentException("NAS 存储地址不能包含主机、查询参数或片段");
}
拒绝 authority、query、fragment,是为了防止 file://evil-host/path 这类形态被下游组件做出意外解释。至于 isOpaque()------file:/data/x 是 opaque 的(没有 //),语义上和绝对路径不同,一并拒掉更干净。
还有一个细节:候选路径不能等于存储根本身。
java
if (!candidate.startsWith(storageRoot) || candidate.equals(storageRoot)) {
throw new SecurityException("NAS 文件不在配置的正式存储目录内");
}
等于根目录意味着"把整个存储根打包下载",显然不该允许。
三、第二层:NAS 哨兵文件
这是我觉得整套设计里最妙的一招,专治第一节里的第 6 条:共享盘掉线。
场景是这样的:NAS 通过挂载点 /mnt/nas 使用。网络抖动导致挂载掉了,但挂载点目录还在(空目录)。此时如果代码往 /mnt/nas/xxx 写文件,不会报错------它会老老实实写在服务器本地磁盘上。等到 NAS 恢复挂载,这些文件被"盖住",看起来像凭空消失;更糟的是,你可能直到需要读它们的时候才发现。
解法是在存储根放一个约定好的哨兵文件,每次使用根目录前先确认它在:
java
/** 正式 NAS 根目录标识,用于阻止共享盘掉线后误写服务器本地同名目录。 */
private static final String STORAGE_ROOT_MARKER = ".nas-root-marker";
public Path resolveStorageRoot() {
Path storageRoot = resolveConfiguredDirectory(
properties.getStorageRoot(), "人工接入存储根目录");
Path marker = storageRoot.resolve(STORAGE_ROOT_MARKER);
if (Files.isSymbolicLink(marker)
|| !Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS)
|| !Files.isReadable(marker)) {
throw new IllegalStateException("NAS 正式存储根标识文件不存在或不可读");
}
return storageRoot;
}
为什么有效:哨兵文件只在 NAS 上存在。挂载掉线时,本地空目录里没有它,于是直接抛异常,写入被拦住。故障从"静默写错地方"变成"启动时就报错"。
三个细节做得对:
- 哨兵本身也要查是不是软链------否则攻击者(或误操作)放一个软链指向别处的同名文件,检查就形同虚设。
- 用
NOFOLLOW_LINKS判定为普通文件,不接受目录、设备文件等。 - 校验发生在每次解析存储根时,而不是只在启动时查一次。挂载可能在运行期掉线,只查一次挡不住。
配套的还有一条:外置目录配置必须是绝对路径、且本身不能是软链。
java
if (!path.isAbsolute()) {
throw new IllegalStateException(businessName + "必须配置为绝对路径");
}
path = path.normalize();
if (Files.isSymbolicLink(path)) {
throw new IllegalStateException(businessName + "不能配置为符号链接");
}
四、第三层:一次性的下载令牌
路径校验解决的是"能不能读这个文件",令牌解决的是"谁在什么时候能读一次"。
4.1 为什么不能把路径放进 URL
如果下载地址长这样:
/download?path=/mnt/nas/eeg/2026/xxx.edf
那么它会带来三个问题:内部目录结构泄露给浏览器历史、代理日志、Referer;路径参数可被篡改(虽然还有校验兜底,但攻击面白白扩大);无法限制"只能下载一次"。
方案是:颁发一个不透明随机令牌,真实路径只存在服务端。
java
/** 每个原令牌使用 256 位安全随机数。 */
private static final int TOKEN_BYTES = 32;
private static final long TOKEN_TTL_SECONDS = 600L; // 10 分钟
private String newToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
4.2 令牌哈希后再进 Redis
这一步容易被跳过,但很值得做:
java
redisTemplate.opsForValue().set(keyPrefix + hashToken(token),
JSON.toJSONString(payload), TOKEN_TTL_SECONDS, TimeUnit.SECONDS);
Redis 里存的是 SHA-256(token),原令牌从不落 Redis。这样即便 Redis 被 dump、被快照泄露,也无法反推出可用的下载地址------和"数据库里存密码哈希而不是明文"是同一个道理。
4.3 用 Lua 保证"校验 + 消费"的原子性
这是第二个关键点。消费逻辑是"确认属主 → 删除记录 → 返回数据"。如果分成三条命令发:
GET key → 读到数据
(判断属主)
DEL key
在 GET 和 DEL 之间,另一个并发请求也可能读到同一条记录------同一令牌被消费两次。典型 TOCTOU(检查时间与使用时间不一致)。
所以整段逻辑压进一个 Lua 脚本,Redis 单线程执行,天然原子:
java
private static final DefaultRedisScript<String> CONSUME_SCRIPT =
new DefaultRedisScript<String>(
"local raw = redis.call('GET', KEYS[1])\n"
+ "if not raw then return nil end\n"
+ "local file = cjson.decode(raw)\n"
+ "if file.owner ~= ARGV[1] then return '"
+ OWNER_MISMATCH + "' end\n"
+ "redis.call('DEL', KEYS[1])\n"
+ "return raw",
String.class);
java
public AuthorizedFile consume(String token, String operator) {
requireText(operator, "当前操作账号不能为空");
String raw = redisTemplate.execute(CONSUME_SCRIPT,
Collections.singletonList(KEY_PREFIX + hashToken(token)), operator.trim());
if (raw == null) {
throw new IllegalStateException("本地下载令牌不存在、已过期或已消费");
}
if (OWNER_MISMATCH.equals(raw)) {
throw new SecurityException("无权消费其他账号的本地下载令牌");
}
return toAuthorizedFile(raw);
}
注意三处设计:
- 属主校验也在脚本里。如果放在 Java 侧判断,就又把原子性拆开了。
- 属主不匹配时返回特殊标记而不是直接删。这样不会因为一次越权尝试就把合法用户的令牌毁掉(否则成了拒绝服务)。
- 一次性与过期共用同一条错误信息。对外不区分"不存在 / 已过期 / 已消费",减少信息泄露;内部靠日志区分即可。
4.4 为什么还要有"可重复读取"的预览令牌
下载用一次性没问题,但视频预览不行 ------浏览器的 <video> 会发多个 Range 请求分段拉取,一次性令牌第二个请求就失效了。
所以要两种令牌:
| 用途 | 消费方式 | 原因 |
|---|---|---|
| 下载 | Lua 原子消费,一次性 | 一次下载一条令牌,防重放 |
| 预览 | 只读不删,10 分钟内可重复 | 支持视频分段读取 |
预览的读取逻辑相应简化,但属主校验一个都不能少:
java
public AuthorizedFile resolvePreview(String token, String operator) {
requireText(operator, "当前操作账号不能为空");
String raw = redisTemplate.opsForValue().get(PREVIEW_KEY_PREFIX + hashToken(token));
if (raw == null) {
throw new IllegalStateException("本地预览令牌不存在或已过期");
}
JSONObject payload = JSON.parseObject(raw);
if (!operator.trim().equals(payload.getString("owner"))) {
throw new SecurityException("无权读取其他账号的本地预览令牌");
}
return toAuthorizedFile(payload);
}
两种令牌用不同的 key 前缀隔离,避免一次性的被当成可重复的用。
五、第四层:消费时的二次校验
颁发令牌时校验过路径,消费时还要再校验一次------因为令牌的有效期是 10 分钟,文件系统状态可能已经变了。
java
AuthorizedFile file = localTokenService.consume(accessToken, currentOperator());
if (Files.isDirectory(file.getPath(), LinkOption.NOFOLLOW_LINKS)) {
response.setContentType("application/zip");
response.setHeader("Content-Disposition", contentDisposition(zipDisplayName(file)));
try (ZipOutputStream output = new ZipOutputStream(response.getOutputStream())) {
streamDirectoryAsZip(file.getPath(), output);
}
return;
}
if (!Files.isRegularFile(file.getPath(), LinkOption.NOFOLLOW_LINKS)) {
throw new SecurityException("本地下载资源必须是普通文件或目录");
}
NOFOLLOW_LINKS 在这里是必须的------如果判定时跟随软链,一个"看起来是普通文件"的软链就能把读取引向任意位置。
5.1 打包 ZIP 时的逐项防护
目录打包是最容易出事的地方,因为是递归读取。三个检查:
java
private void streamDirectoryAsZip(Path directory, ZipOutputStream output) throws IOException {
Path root = directory.toRealPath(LinkOption.NOFOLLOW_LINKS);
try (Stream<Path> paths = Files.walk(root)) {
Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
if (Files.isSymbolicLink(path)) { // ① 目录内也不许有软链
throw new SecurityException("NAS 下载目录不能包含符号链接");
}
String entryName = buildDirectoryZipEntryName(root, path);
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
writeDirectoryZipEntry(output, entryName);
} else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { // ② 白名单类型
writeFileZipEntry(path, entryName, output);
} else {
throw new SecurityException("NAS 下载目录包含不支持的资源类型"); // ③ 其余一律拒绝
}
}
}
}
第 ③ 条是"默认拒绝":只放行普通文件和目录,FIFO、设备文件、socket 全部拒绝。这类特殊文件在打包时可能造成阻塞(读 FIFO 会一直等)或泄露设备内容。
5.2 ZIP 条目名里的反斜杠:一个跨平台的坑
这是我在读代码时才注意到的细节,很值得单独说:
java
private void appendZipPathSegment(StringBuilder entryName, Path segment) {
String segmentName = segment.toString();
if (".".equals(segmentName) || "..".equals(segmentName)
|| segmentName.indexOf('\\') >= 0) {
throw new SecurityException("NAS 下载目录路径段不能包含父级跳转或反斜杠");
}
if (entryName.length() > 0) {
entryName.append('/');
}
entryName.append(segmentName);
}
为什么要拒反斜杠?因为在 Linux 上,反斜杠是合法的文件名字符 。攻击者可以创建一个名为 ..\..\evil.exe 的文件------在 Linux 眼里它就是一个普通文件名,但在 Windows 的解压工具 眼里,反斜杠是路径分隔符,于是解压时发生路径穿越。
这就是所谓的 "Zip Slip" 变体:服务端合法,客户端中招。做文件打包下载时,条目名必须按目标平台(通常是 Windows)的分隔符规则来过滤。
六、预览接口的 Range 支持
视频预览需要正确的字节范围支持,否则无法拖动进度条:
java
long fileLength = Files.size(file.getPath());
ByteRange range = parseRange(rangeHeader, fileLength);
if (range == null) {
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
response.setHeader("Content-Range", "bytes */" + fileLength);
return;
}
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Cache-Control", "private, no-store");
response.setHeader("Content-Length", String.valueOf(range.length()));
if (range.isPartial()) {
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
response.setHeader("Content-Range", "bytes " + range.getStart()
+ "-" + range.getEnd() + "/" + fileLength);
}
streamRange(file, range, response.getOutputStream());
这里有两个安全相关的响应头值得注意:
Accept-Ranges: bytes明确声明支持分段,让浏览器不要整文件重下。Cache-Control: private, no-store------令牌是临时授权,绝对不能被中间缓存或浏览器磁盘缓存留下副本 。private排除共享缓存,no-store排除任何持久化。少了这个头,10 分钟的授权可能变成任意人可读的缓存。
另外,预览只允许浏览器可直接呈现的图片和视频格式,不给任意文件开预览口子:
java
private static final Set<String> PREVIEW_EXTENSIONS = new HashSet<String>(Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp",
"mp4", "webm", "ogg", "ogv", "mov", "m4v"));
并且校验时扩展名与 MIME 双重判定,扩展名缺失时从文件名兜底提取------但不能只信扩展名(用户可以随便改),也不能只信 MIME(数据库里的 MIME 可能是脏数据)。
七、上线检查清单
-
normalize()是否在startsWith()之前执行 - 前缀判断是否用
Path.startsWith而非字符串前缀比较 - 符号链接是否逐段检查(不只查最终路径)
- 是否拒绝了 Windows 盘符路径与 UNC 路径(跨平台场景尤其重要)
- 入口是否逐段拒绝
.. - 外置目录是否强制绝对路径、且禁止配置为软链
- 存储根是否有哨兵文件 校验,且每次使用都校验(不只启动时)
- 下载地址是否不含内部路径,只用不透明令牌
- 令牌是否 256 位随机、是否哈希后入 Redis
- "校验属主 + 删除"是否用 Lua 原子完成
- 一次性下载与可重复预览是否分开(且 key 前缀隔离)
- 消费令牌时是否二次校验 文件类型(
NOFOLLOW_LINKS) - 目录打包是否逐项拒绝软链、特殊文件,并过滤反斜杠与
.. - 预览响应是否带
Cache-Control: private, no-store - 越权消费是否不摧毁合法用户的令牌(校验与删除分离)
八、一句话总结
文件安全不是一个校验函数的事,而是四层叠加:路径策略管"能读哪里",哨兵文件管"存储是否真的在",令牌管"谁在何时能读一次",消费时二次校验管"此刻文件类型是否还合规"。 任何一层单独拿出来都不够------尤其是第四层,因为前三层校验过的状态,在令牌有效期内是可能变化的。
最后提醒一句:跨平台的文件名处理永远比你想的复杂。反斜杠这一条,在 Linux 上写代码、在 Windows 上解压,中间隔着一次静默的路径穿越。