几乎每一个允许用户上传文件的系统,都会面临同一个问题:这些文件应该放在哪里,用户又如何访问它们。最省事的做法是把上传目录直接暴露在 Web 根目录下,文件 URL 形如 /uploads/2024/report.pdf,任何拿到链接的人都能访问。这种做法在内部工具里或许问题不大,但一旦涉及隐私文件、付费内容或者企业内部资料,就等于把门锁拆了。这篇文章就来聊聊如何安全地控制用户对上传文件的直接 URL 访问。

直接暴露文件 URL 存在哪些安全隐患
第一种风险是越权访问。很多系统的文件命名依赖用户 ID、时间戳或自增数字,攻击者只要拿到一个合法 URL,就可以通过遍历 ID 的方式批量拉取其他用户的文件。比如文件路径是 /uploads/1001/avatar.jpg,把 1001 换成 1002、1003 往往就能看到别人的资料,这属于典型的 IDOR(不安全的直接对象引用)漏洞。
第二种风险是链接扩散失控。直链一旦被转发到群聊、论坛或被搜索引擎收录,原作者就彻底失去了对文件的控制。即使后台删除了文件记录,缓存、爬虫和第三方转载依然可能保留副本。此外,如果文件目录还开启了目录列表(Nginx 默认关闭,但 Apache 的 mod_autoindex 开启后很常见),攻击者甚至不需要猜测 URL,直接浏览整个目录即可。
第三种风险是带宽盗刷。图片、视频等静态资源最容易被外站引用,大量流量消耗却不能带来任何价值。虽然防盗链能缓解一部分问题,但它解决不了授权粒度的问题:防盗链只能判断请求来源,无法判断“这个用户是否有权看这个文件”。
方案一:通过后端接口做权限校验后转发文件
最通用、可控性最强的方案,是不让 Web 服务器直接暴露上传目录,所有文件访问都经过后端接口。接口先校验当前登录用户的身份和权限,通过后再把文件内容返回给客户端。以常见的 Node.js 场景为例:
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
app.get('/files/:fileId', async (req, res) => {
// 1. 校验登录态
const user = req.session.user;
if (!user) return res.status(401).json({ error: '未登录' });
// 2. 查询文件归属,切勿直接拼接用户传入的路径
const file = await db.query('SELECT * FROM files WHERE id = ?', [req.params.fileId]);
if (!file) return res.status(404).json({ error: '文件不存在' });
// 3. 权限判断:仅文件所有者或被授权用户可访问
if (file.ownerId !== user.id && !user.isAdmin) {
return res.status(403).json({ error: '无权访问' });
}
// 4. 安全地拼接真实路径,防止路径穿越
const realPath = path.join(SAFE_UPLOAD_DIR, file.storedName);
if (!realPath.startsWith(SAFE_UPLOAD_DIR)) {
return res.status(400).json({ error: '非法路径' });
}
res.setHeader('Content-Type', file.mimeType);
fs.createReadStream(realPath).pipe(res);
});这个方案有几个必须注意的细节。第一,权限判断要基于数据库中的记录而不是文件名本身,用户只能传文件 ID,真实存储名由服务端查库获得。第二,拼接路径后要校验结果是否仍在上传目录内,防止类似 ../../etc/passwd 的路径穿越攻击。第三,用流式读取而不是 readFileSync 一次性载入内存,避免大文件把进程拖垮。
缺点也很明显:所有文件流量都经过应用进程,性能和并发能力会受到影响。如果文件量大、访问频繁,建议在接口前面加一层 CDN 或者改成下面要讲的签名 URL 方案。
方案二:使用带过期时间的签名 URL
如果文件存储在对象存储(如阿里云 OSS、AWS S3)上,签名 URL 是最优雅的解法。服务端根据文件的私有读写权限,动态生成一个带签名和过期时间的临时链接,例如有效期 5 分钟。拿到链接的用户可以在有效期内正常访问,过期后链接立即失效,即使链接被转发,过了时间窗口也无法使用。
import oss2
auth = oss2.Auth('AccessKeyId', 'AccessKeySecret')
bucket = oss2.Bucket(auth, 'https://oss-cn-hangzhou.aliyuncs.com', 'my-bucket')
# 生成 300 秒有效的临时访问链接
url = bucket.sign_url('GET', 'reports/2024/secret.pdf', 300)
print(url)
# 输出形如:
# https://my-bucket.oss-cn-hangzhou.aliyuncs.com/reports/2024/secret.pdf?Expires=1718000000&OSSAccessKeyId=xxx&Signature=yyy签名 URL 的核心价值在于把权限判断和文件传输解耦:判断“谁能看”仍由应用服务完成,而实际的文件下载由存储服务的高性能节点承担。签名本身依赖密钥计算,客户端无法伪造,也无法通过修改过期参数延长有效期,因为任何参数变动都会导致签名校验失败。
使用时要设置合理的有效期,太长起不到保护作用,太短则可能让下载大文件的用户中途链接失效。一般文档类 5 到 10 分钟即可,视频等大文件可以适当放宽到 30 分钟。另外要注意,签名 URL 只能保证“链接本身”受控,如果用户在有效期内下载后自行传播文件,那已经不是技术能解决的问题了,必要时可以配合水印、加密等手段。
方案三:Nginx 内部重定向与防盗链配置
对于自建服务器存储文件的情况,可以把上传目录配置为只允许内部访问。Nginx 提供 internal 指令,被它标记的路径无法被外部请求直接命中,只能通过 X-Accel-Redirect 由后端触发:
server {
listen 80;
# 外部不可直接访问的受保护目录
location /protected_files/ {
internal;
alias /data/upload/;
}
location /files/ {
proxy_pass http://127.0.0.1:3000;
# 后端权限校验通过后,返回 X-Accel-Redirect 头
# Nginx 接管文件传输,性能远高于 Node 亲自读文件
}
}后端接口完成权限校验后,只需返回一个响应头,例如 X-Accel-Redirect: /protected_files/xxx.pdf,Nginx 就会自动把文件内容发给用户,传输效率接近纯静态文件,权限逻辑仍完全掌握在应用手里。这是性能与安全兼顾的经典做法。
在此基础上,还可以叠加防盗链和基础加固。通过 valid_referers 拒绝来源异常的请求,通过 add_header Cache-Control "private, no-store" 阻止浏览器和代理缓存私有文件,同时在响应头里加上 X-Content-Type-Options: nosniff,防止恶意上传的文件被浏览器当作脚本执行。
方案对比与选型建议
三种方案并非互斥,而是各有侧重。接口校验转发实现简单、权限粒度最细,适合中小规模系统;签名 URL 性能最好,天然适配对象存储和 CDN,适合文件量大、访问频繁的场景;Nginx 内部重定向则介于两者之间,保留自建存储的同时获得接近静态文件的性能。
无论选择哪种方案,有几条底线不能突破:上传目录永远不要放在 Web 根目录下直接暴露;文件的真实存储名与对外标识分离,最好用随机 UUID 重命名;上传时校验并限制文件类型;关闭目录列表功能。安全从来不是一个开关,而是把每一层防线都做扎实,直链的风险自然就被化解了。