做企业文件系统这些年,我体会最深的一点是:功能能不能跑起来从来不是问题,问题是从上传到下载这条链路里,文件怎么才不漏出去。很多开发同学对防泄漏的理解,还停留在"登录了才能下载",结果文件真实落盘路径一暴露,整个目录被脚本拖走。这种事我在排查安全问题的时候见过不止一次。
这篇文章把我自己在 JavaWeb 体系里落地文件上传下载防泄漏的完整思路整理出来,包括为什么登录不够、文件怎么加密存、下载链接怎么做到动态失效、操作日志怎么回溯,最后给一套基于 Spring Boot + MySQL + Redis 的可直接参考的代码骨架。适合正在做内部资料库、企业网盘、政务档案系统等项目的后端开发,也想给现有系统补安全短板的人看。这套机制的通用性很强,只要你的系统里有"用户传文件"和"用户下文件"这两个动作,下面每一种做法都值得对着自己代码检查一遍。我不堆概念,尽量拿踩过的坑说话。
1. 内容整体设计与思路拆解
1.1 防泄漏机制到底在防什么
防泄漏不能只理解为"防止黑客拖库"。真正做起来要拆成四个目标:谁知道上传了、谁能下载、下载之后能不能追溯、系统本身能不能扛住枚举和越权。
- 谁上传:上传者身份必须明确,匿名投递要禁掉,上传入口在服务端要有频率控制。
- 谁能下:下载请求到达文件读取这一步之前,必须经过授权校验,任何绕过路径都该被拦截。
- 下了带不走:文件即使被下载到本地,内容本身也是密文存储的,拿到的字节没有解密条件就没有价值。同时还要通过水印、唯一标识等手段保证可溯源。
- 带走追得回:所有上传、申请令牌、下载成功、下载失败的动作都有完整审计,文件真的流出时可以在分钟级之内定位到人、IP、时间和文件ID。
这四句话总结起来容易,落地很麻烦。为什么麻烦?因为不能靠一个拦截器、一个过滤器就把所有问题兜住,每一层都有各自的职责,漏一层就可能全盘失守。
1.2 传统方案的三个误区
第一个误区是"登录等于授权"。登录只解决身份问题,不解决权限问题。你登录了,只能说明你是系统里的合法用户,不代表你有权限下载某个部门、某个级别的资料。很多系统把登录Session当万能钥匙,文件读接口只做了登录校验,首页能读、列表能读、下载也能读,这等于所有文件对登录用户全开放。内部系统用户量虽然不大,但权限一旦模糊,组织架构调整或者员工离职交接后,无权限访问就成了常态。
第二个误区是"文件名混淆就是加密"。把文件重命名为UUID、塞进深层目录,确实能防路径猜测,但文件内容还是明文。只要用户在浏览器里打开过PDF或者图片,浏览器本地缓存、临时目录、预览插件都会留下副本,攻击者拿到本地缓存就能还原完整文件。防泄漏要防的是"内容泄露",不是"路径泄露"。
第三个误区是"日志只是记录,不重要"。很多团队把日志当成运维排障工具,不把它当安全基建。实际上防泄漏的核心是追溯能力:文件真的流出了,如果没有访问日志、没有令牌记录、没有IP记录,你只能知道"文件可能被人拷走了",连从哪个入口出去的都说不清,后续处理和追责都没有依据。
1.3 分层防控的整体架构
我的做法是五层结构,每一层单独看都有弱点,叠起来才能挡住大多数真实攻击路径:
第一层认证授权层,负责识别用户身份、校验角色和资源权限关系。 第二层传输层,全站HTTPS,防止链路被中间人嗅探。 第三层存储层,文件内容走AES加密,文件名随机化,存储目录在应用目录之外。 第四层下载控制层,下载必须持短期一次性令牌,令牌绑定用户和文件,使用一次立即作废。 第五层审计追溯层,所有关键操作落库,成功和被拒绝的都记。
后面的实操部分会围绕这五层展开,你会看到它们不是独立的,而是互相咬合的关系。上传时记录的归属关系决定了下载时的授权判断,下载时生成的令牌决定了审计链路能不能串起来。
2. 核心细节解析与实操要点
2.1 认证与授权:源头控制
认证这块,我在Spring Boot项目里用过Spring Security、Shiro,也用过最简单的Session加拦截器方案。对于内部文件系统,选什么框架不是最重要的事,重要的是授权粒度必须到文件资源,而不是只到角色。
举一个真实场景:两个部门之间往往有文件隔离要求。如果文件表里没有部门字段,权限就只能做到"谁登录都能下载",那文件范围就失控了。基础设计至少要三张表:用户表、文件表、权限关联表。权限关联表可以简单到只存一条"某用户组可以访问某目录/某分类下的文件"的记录。
注意:文件资源授权千万不要做成"路径前缀匹配"。用户请求
/download?path=/files/2024/q1/xxx.pdf,你检验前缀以/files/2024开头就放行,攻击者只要把参数改成/download?path=/files/2024/../../etc/passwd就能穿越出去。这类漏洞在审计中极其常见,后面我会给出具体的规避实现。
2.2 文件加密与存储隔离
文件加密我强烈推荐AES-256-GCM,原因有三个:一是GCM模式带认证标签,密文被篡改时可以检测出来;二是性能比非对称加密好一个量级,适合大文件流式处理;三是JDK内置支持,不需要额外引入加密服务。
AES密钥不能放在配置文件中写死。代码仓库一旦泄露,密钥跟着泄露,加密就等于白做。我的做法是密钥放入环境变量或者专门的密钥管理服务,程序启动时读取。测试环境用固定密钥可以,生产环境必须从外部注入。另外最好做密钥版本号字段,换密钥时旧文件用旧版本密钥解密,新文件用新密钥,避免一次性迁移所有历史文件的上线压力。
存储隔离的意思是:用户上传的文件不能落在Web应用的classpath下面。有人贪图方便把文件写到resources/upload目录,打成jar包之后这个目录就在classpath里,别人一旦通过Spring Boot静态资源映射猜到路径,文件直接就能下载。正确做法是放到应用外部独立目录,比如Linux下的/data/files,用绝对路径读取,应用部署目录被人扫到也不怕。
2.3 动态令牌与临时链接
下载链接不能是永久的,更不能是固定格式。我见过系统生成下载地址/download?fileId=123,攻击者把fileId从1遍历到十万,整个系统的机密附件全被拖走。这不是臆想,是真实发生过的审计案例。
正确做法是:用户点击下载时,先调一个"申请令牌"接口,服务端生成一个随机的、短期有效的令牌,存到Redis,设置五分钟或十分钟过期,标注哪个用户、哪个文件、过期时间、用过没有。下载接口不接收fileId,只接收令牌,通过令牌反查文件记录,再校验权限,校验通过后立刻销毁令牌。
令牌设计的几个容易翻车的地方:
- 令牌必须是服务端随机字符串,不能用fileId做可逆加密后的固定值,否则就等于把fileId变了个编码继续裸奔。
- 令牌要绑定用户ID和会话信息,不能任何人都能用,否则别人截获一次令牌就能下载。
- 过期时间要短,但也不能太短。大文件断点续传场景下,如果用户中途暂停了一分钟,令牌过期就会导致下载失败,体验极差。
- 令牌被使用后要立即失效,防止重放。同一个令牌带着同一个请求发两遍,第二遍必须返回403。
2.4 操作审计与异常告警
审计日志至少要记五件事:操作人、操作时间、来源IP、文件标识、操作结果。如果是下载成功,再补一条令牌ID或请求ID,这样能把一次点击和整个会话、整个操作链路串联起来。后面做安全溯源时,是"张三在10点12分从10.20.30.40下载了文件ID 2093",而不是"有人下载了文件"。
日志存储选型要看访问量。内部系统一天几千条日志,MySQL完全够用。访问量大的建议入Elasticsearch或者MongoDB。但不管存哪里,日志最好加防篡改设计。简单做法是在日志表里加一个prev_hash字段,每一条日志的校验值都由上一条日志的校验值和当前记录内容联合计算,如果有人改了中间的记录,后面的哈希就对不上。
另一个容易被忽视的点是上传接口防滥用。上传接口一旦暴露,攻击者可以用脚本批量灌文件,几分钟内把磁盘塞满,系统直接不可用。我给上传接口加了一个基于Redis的计数器,一分钟内一个用户最多上传固定次数,超出直接拒绝,同时记一条失败日志。
3. 实操过程与核心环节实现
3.1 环境准备与项目结构
演示项目用Spring Boot 2.7 + MySQL 8.0 + Redis,持久层MyBatis-Plus,构建工具Maven。用IDEA运行就是标准Spring Boot应用,直接在Run Configuration里指定主类即可。如果你在看各种JavaWeb完整案例学习的,下面这套表结构和代码可以直接往里套。
推荐的项目分包如下:
src/main/java/com/example/fileshield/ ├── controller │ ├── FileController.java │ └── AuthController.java ├── service │ ├── FileService.java │ └── TokenService.java ├── interceptor │ └── FileAccessInterceptor.java ├── entity │ ├── SysUser.java │ └── FileRecord.java └── config └── WebConfig.java3.2 数据库表设计
核心三张表:用户表、文件记录表、访问日志表。用户表里加dept_code部门代码,文件表里加allow_dept_codes允许访问的部门清单,两者配合做数据隔离。
用户表:
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role_code VARCHAR(50) NOT NULL, dept_code VARCHAR(50) NOT NULL, status TINYINT DEFAULT 1 );文件记录表:
CREATE TABLE file_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, original_name VARCHAR(255) NOT NULL, store_name VARCHAR(64) NOT NULL UNIQUE, file_path VARCHAR(255) NOT NULL, file_hash VARCHAR(64) NOT NULL, file_size BIGINT NOT NULL, upload_user_id BIGINT NOT NULL, dept_code VARCHAR(50) NOT NULL, allow_dept_codes VARCHAR(255), encrypt_key_version VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );store_name是UUID,对外不可见;file_path是服务端磁盘路径;file_hash是文件SHA-256值,做完整性和去重;allow_dept_codes可以为空,为空表示仅限上传者所在部门访问。
访问日志表:
CREATE TABLE access_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, username VARCHAR(50) NOT NULL, file_id BIGINT, action_type VARCHAR(20) NOT NULL, ip_address VARCHAR(45) NOT NULL, access_token VARCHAR(32), success_flag TINYINT DEFAULT 0, fail_reason VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );3.3 上传接口的实现
上传接口要做四件事:校验身份、校验文件类型和大小、随机命名、加密落盘。
@PostMapping("/api/file/upload") public Result upload(@RequestParam("file") MultipartFile file, HttpServletRequest request) { SysUser user = (SysUser) request.getAttribute("loginUser"); if (user == null) { return Result.error(401, "未登录"); } // 1. 校验文件扩展名白名单 String ext = StringUtils.getFilenameExtension(file.getOriginalFilename()); if (!allowExtSet.contains(ext)) { return Result.error(403, "文件类型不允许上传"); } // 2. 校验文件大小,默认上限100MB if (file.getSize() > 100L * 1024 * 1024) { return Result.error(403, "文件超出大小限制"); } // 3. 生成随机存储名 String storeName = UUID.randomUUID().toString().replace("-", "") + "." + ext; String dir = fileStorageDir + "/" + DateUtils.format(new Date(), "yyyy/MM"); File dirFile = new File(dir); if (!dirFile.exists()) { dirFile.mkdirs(); } String targetPath = dir + File.separator + storeName; // 4. 加密后写盘 byte[] encrypted = FileCipher.encrypt(file.getBytes(), secretKey); Files.write(Paths.get(targetPath), encrypted); // 5. 计算文件hash,落库 String fileHash = DigestUtils.sha256Hex(file.getBytes()); FileRecord record = buildRecord(file, user, storeName, targetPath, fileHash); fileRecordMapper.insert(record); // 6. 写审计日志 accessLogService.log(user.getId(), user.getUsername(), record.getId(), "UPLOAD", request.getRemoteAddr(), true, null); return Result.success(FileVo.from(record)); }这里有几个细节必须强调:
文件类型不能只看扩展名。攻击者可以把可执行文件后缀改成.pdf传上来,如果只校验扩展名,等于白给。至少要做Magic Number识别,PDF文件开头有%PDF,PNG图片开头有固定字节。Apache Tika做类型识别很稳,生产环境强烈建议接上,识别完之后把真实类型也存到库里。
大文件千万不要一次性getBytes()读进内存再加密,很容易把应用内存吃爆。我上面的示例代码为了篇幅简写了,生产环境要改成file.transferTo()落盘,然后逐块读文件流、逐块加密写入目标文件,边读边写。具体实现会用FileInputStream加CipherOutputStream,后面有时间单独拆一篇讲大文件流式加密。
3.4 下载前的令牌申请
下载这块我分成两个接口:申请令牌接口和真正下载接口。用户点下载按钮时,前端先调申请令牌接口,拿到令牌之后再用一个隐藏的<a>标签去请求真实下载地址。
申请令牌接口:
@GetMapping("/api/file/apply-token") public Result applyToken(@RequestParam Long fileId, HttpServletRequest request) { SysUser user = (SysUser) request.getAttribute("loginUser"); FileRecord record = fileRecordMapper.selectById(fileId); if (record == null || !canAccess(user, record)) { accessLogService.log(user.getId(), user.getUsername(), fileId, "APPLY_TOKEN", request.getRemoteAddr(), false, "无权限访问"); return Result.error(403, "没有权限下载该文件"); } String token = tokenService.createToken(user.getId(), fileId, 10); accessLogService.log(user.getId(), user.getUsername(), fileId, "APPLY_TOKEN", request.getRemoteAddr(), true, null); return Result.success(token); }createToken方法内部逻辑是这样的:生成32位随机字符串,以token为key写入Redis,value存成JSON,包括用户ID、文件ID、创建时间、过期时间,同时设置10分钟过期。10分钟对普通文件足够,大文件如果用户下载得慢,可以在前端快过期时重新申请。
canAccess的判断逻辑也要说清楚:先查用户自身部门,再解析文件记录的allow_dept_codes,如果文件允许部门清单为空,就默认仅上传者部门可见,否则必须命中清单。注意判断条件里的空值边界都要按"禁止"处理,任何一处为空就不放行,别写成"为空就全部放行",这是权限设计里最危险的写法。
3.5 下载接口的实现
下载接口只做一件事:凭令牌取文件。它不关心请求的fileId,不关心路径,只信Redis里的令牌记录。
@GetMapping("/api/file/download") public ResponseEntity<byte[]> download(@RequestParam String token) { TokenInfo info = tokenService.parse(token); if (info == null) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } FileRecord record = fileRecordMapper.selectById(info.getFileId()); if (record == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } // 解密还原文件内容 byte[] content = FileCipher.decrypt( Files.readAllBytes(Paths.get(record.getFilePath())), secretKey); // 标记令牌已使用,防重放 tokenService.invalidate(token); // 禁止浏览器缓存 HttpHeaders headers = new HttpHeaders(); headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + URLEncoder.encode(record.getOriginalName(), StandardCharsets.UTF_8.name()) + "\""); headers.add(HttpHeaders.CACHE_CONTROL, "no-store, no-cache, must-revalidate"); headers.add(HttpHeaders.PRAGMA, "no-cache"); // 写下载成功日志 accessLogService.log(info.getUserId(), ..., record.getId(), "DOWNLOAD", getClientIp(), true, null); return new ResponseEntity<>(content, headers, HttpStatus.OK); }这个实现有几个关键点:
TokenService.invalidate一定不能省。不销毁令牌,用户把下载地址发给别人,人家粘贴到浏览器就能再下。- 响应头必须加
Cache-Control: no-store。PDF和图片在浏览器里有自己的缓存机制,不加这个头,文件会被缓存到本地,别人没有登录态也有可能从缓存目录里翻出内容。 - 大文件不要把整个文件读进
byte[]返回,会撑爆内存。项目里我用的StreamingResponseBody,配合流式解密,边读磁盘密文边解密边写响应流,内存占用基本恒定。示例代码为了可读性用了ResponseEntity<byte[]>,读者自己落地时务必换成流式实现。
3.6 访问控制与拦截器配置
拦截器负责登录校验和部分token校验,注册到/api/file/**上。
@Component public class FileAccessInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.setStatus(401); return false; } return true; } }注册:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(fileAccessInterceptor) .addPathPatterns("/api/file/**"); } }关于token校验,我在真实项目里得到过一个教训:不要在拦截器里对下载接口做token校验,然后就在业务方法里直接用了。拦截器无法感知业务方法是否成功执行完,也就没法保证"使用一次"这个约束。令牌失效必须放在下载业务方法内部,和文件读取在同一事务边界里完成,避免出现校验和消费两步之间的间隙。
还有一个容易被忽略的点:拦截器要放行/api/file/apply-token吗?不能放行,申请令牌本身也需要登录态,因为令牌是绑定用户的。所有/api/file/**下面的接口都要过登录校验。
3.7 路径穿越的防御实现
无论接口参数怎么设计,不信任前端传路径是铁律。但万一哪天有历史接口是用path参数读文件的,必须做路径规范化校验。
public static String normalizePath(String baseDir, String targetPath) throws IOException { Path base = Paths.get(baseDir).toRealPath(); Path target = Paths.get(targetPath).toAbsolutePath().normalize(); if (!target.startsWith(base)) { throw new SecurityException("非法路径访问"); } return target.toString(); }用toRealPath()而不是字符串拼接,是因为符号链接可以绕过单纯的前缀判断。攻击者可以先在某个合法目录下创建一个指向/etc的软链接,字符串前缀检查看不出来,但toRealPath()会解析到最后真实路径,再做前缀比较就堵住了。
4. 常见问题与排查技巧实录
4.1 权限绕过问题
现象:普通用户能下载其他部门文件。
排查思路分两步。第一步查列表查询接口,很多系统列表查询和下载校验逻辑是两套代码,列表接口没按部门过滤,把全表文件都列出来,那下载接口的校验再好都没用,攻击者直接遍历列表里的fileId就能批量申请下载。权限判断必须同时落在列表查询和文件下载两个环节。
第二步看canAccess里有没有用陷阱式OR条件。比如判断部门时写成了"部门为空或者部门匹配",空值场景直接把权限放大成了全员可见。所有权限判断的默认值必须设为拒绝,判断顺序要先查用户再查文件再比对。
4.2 大文件上传超时
现象:上传超过500MB文件时请求超时。
原因往往是两层:Tomcat默认上传大小限制很严格,Nginx层也可能限制。Spring Boot里需要单独放宽配置:
spring: servlet: multipart: max-file-size: 1024MB max-request-size: 1024MBNginx反向代理要同时调整:
client_max_body_size 1024m;前端浏览器对ajax请求也有限时,文件稍大就建议分片上传。我实际项目里超过200MB文件的直接走分片,每片8MB,后端合并,前端带进度条。分片的另外一个好处是断点续传做起来顺理成章,网络不稳定时不会前功尽弃。
4.3 令牌泄露与重放
现象:用户截获一次下载地址,在没有登录态的情况下照样能下载成功。
要么是令牌有效期太长,给了别人足够重放时间;要么是令牌绑定的信息太少,没有绑定用户ID和会话;要么是令牌从请求日志里被扒出来了。我遇到过项目里token放在URL里,服务器访问日志会记录完整URL,运维日志系统权限偏低的人就能看到token,这也是一种泄露途径。
我的规避做法:
- 令牌尽量不放URL,放请求头
X-Download-Token。 - 如果前端技术栈限制只能放URL,URL里的令牌有效期压到五分钟以内,用后即焚。
- Redis里令牌的value同时存用户ID和文件ID,每次下载时校验请求者身份是不是令牌绑定本人。
- 高危文件下载前启用二次验证,比如动态口令或审批流,用业务手段降低泄露风险。
4.4 浏览器缓存带来的文件残留
现象:文件下载后被浏览器悄悄缓存,用户退出系统,别人使用同一台电脑还能在缓存目录里找到文件内容。
原因就是下载响应头缺了缓存控制。这个问题特别隐蔽,下载操作看起来成功,PDF阅读器在后台写了一份缓存到磁盘,用户毫无感知。排查方法:打开浏览器开发者工具的网络面板,点开下载请求,检查响应头里有没有Cache-Control: no-store。没有就赶快补。
顺带一提,Content-Type也要显式设置,别让浏览器去做MIME嗅探。虽然Content-Disposition: attachment已经强制另存为,但显式设置类型可以避免被解析器误判,对老版本浏览器的兼容性也好一些。
4.5 数据库文件记录与磁盘文件不一致
现象:数据库里文件记录存在,但磁盘文件已经被删除;或者磁盘文件还在,数据库记录被清理,导致日志审计里文件ID关联不上。
这个问题最常见的触发场景是运维手工清理旧文件时只删磁盘不清数据库。建议做一个每日定时任务,扫描比对数据库记录与磁盘文件存在性,两边不一致的列出清单人工处理。文件哈希字段也可以利用起来,定期抽样比对磁盘文件哈希和数据库hash值,防止文件被偷偷篡改。
最后说两句实在话
把这套文件上传下载防泄漏机制完整落地之后,我最大的体会是:安全不是某一个中间件、某一个过滤器能解决的,它是层层叠加形成的一个整体。你可能觉得自己系统不大、用户量不高、文件不算特别敏感,就跳过加密、跳过令牌机制,可一旦发生一次文件泄露,代价往往是整个系统推倒重来,口碑和信任也跟着赔进去。
还有一个细节想提醒同行:文件下载一定要把"成功"和"被拒绝"都记进日志。被拒绝的访问往往比成功访问更有价值,因为那是有人正在试探的痕迹。我习惯每天早上扫一遍昨日被拒绝的记录,能发现不少异常IP和异常账号,比等出了事再翻日志划算得多。
最后再分享一个小技巧:系统上线之前,自己开一个无痕窗口,用一个完全没有权限的测试账号把所有上传下载接口挨个锤一遍,不管能不能猜到参数,都试一遍。这个动作花不了多长时间,但能把前面说的绝大多数漏洞提前暴露出来。安全攻防就是一场猜谜游戏,你能想到的,别人大概率也能想到,提前一步堵上,心里才踏实。