附件下载的安全边界:路径白名单、NAS 哨兵文件与 Redis Lua 一次性令牌

标签: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/passwdstartsWith 才有意义。

顺序颠倒就是漏判。这条几乎是最常见的路径穿越漏洞成因。

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 上存在。挂载掉线时,本地空目录里没有它,于是直接抛异常,写入被拦住。故障从"静默写错地方"变成"启动时就报错"。

三个细节做得对:

  1. 哨兵本身也要查是不是软链------否则攻击者(或误操作)放一个软链指向别处的同名文件,检查就形同虚设。
  2. NOFOLLOW_LINKS 判定为普通文件,不接受目录、设备文件等。
  3. 校验发生在每次解析存储根时,而不是只在启动时查一次。挂载可能在运行期掉线,只查一次挡不住。

配套的还有一条:外置目录配置必须是绝对路径、且本身不能是软链

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

GETDEL 之间,另一个并发请求也可能读到同一条记录------同一令牌被消费两次。典型 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 上解压,中间隔着一次静默的路径穿越。

相关推荐
YatHinLay2 小时前
Spring Boot + Calcite 实现跨库查询
java
酷炫码神2 小时前
MosMos 创意效果与能力边界展示
java
Wang's Blog2 小时前
Java 接入Redis: 使用Jedis操作Redis
java·服务器·redis
事圆则缓2 小时前
Java 8 Lambda、Stream、Optional 与 Android 边界:从回调语法到运行时兼容
android·java
shehuiyuelaiyuehao2 小时前
算法50,分治归并,数组中的逆序对
java·算法
烽学长2 小时前
(附源码)基于Springboot+vue的图书阅读分享系统的设计与实现
java·spring boot·后端
Ivanqhz2 小时前
数据搬运是性能关键
java·服务器·网络·人工智能·深度学习
青山木3 小时前
RocketMQ 入门到原理(三):消息存储原理
java·后端·中间件·架构·rocketmq
MayBaymax3 小时前
Spring AI Alibaba Graph 实战:客服工单智能处理
java·ai·ai编程