多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JavaWeb文件上传下载防泄漏实战:加密存储与动态令牌设计

JavaWeb文件上传下载防泄漏实战:加密存储与动态令牌设计 做企业文件系统这些年我体会最深的一点是功能能不能跑起来从来不是问题问题是从上传到下载这条链路里文件怎么才不漏出去。很多开发同学对防泄漏的理解还停留在登录了才能下载结果文件真实落盘路径一暴露整个目录被脚本拖走。这种事我在排查安全问题的时候见过不止一次。这篇文章把我自己在 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?fileId123攻击者把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文件开头有%PDFPNG图片开头有固定字节。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写入Redisvalue存成JSON包括用户ID、文件ID、创建时间、过期时间同时设置10分钟过期。10分钟对普通文件足够大文件如果用户下载得慢可以在前端快过期时重新申请。canAccess的判断逻辑也要说清楚先查用户自身部门再解析文件记录的allow_dept_codes如果文件允许部门清单为空就默认仅上传者部门可见否则必须命中清单。注意判断条件里的空值边界都要按禁止处理任何一处为空就不放行别写成为空就全部放行这是权限设计里最危险的写法。3.5 下载接口的实现下载接口只做一件事凭令牌取文件。它不关心请求的fileId不关心路径只信Redis里的令牌记录。GetMapping(/api/file/download) public ResponseEntitybyte[] 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配合流式解密边读磁盘密文边解密边写响应流内存占用基本恒定。示例代码为了可读性用了ResponseEntitybyte[]读者自己落地时务必换成流式实现。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。如果前端技术栈限制只能放URLURL里的令牌有效期压到五分钟以内用后即焚。Redis里令牌的value同时存用户ID和文件ID每次下载时校验请求者身份是不是令牌绑定本人。高危文件下载前启用二次验证比如动态口令或审批流用业务手段降低泄露风险。4.4 浏览器缓存带来的文件残留现象文件下载后被浏览器悄悄缓存用户退出系统别人使用同一台电脑还能在缓存目录里找到文件内容。原因就是下载响应头缺了缓存控制。这个问题特别隐蔽下载操作看起来成功PDF阅读器在后台写了一份缓存到磁盘用户毫无感知。排查方法打开浏览器开发者工具的网络面板点开下载请求检查响应头里有没有Cache-Control: no-store。没有就赶快补。顺带一提Content-Type也要显式设置别让浏览器去做MIME嗅探。虽然Content-Disposition: attachment已经强制另存为但显式设置类型可以避免被解析器误判对老版本浏览器的兼容性也好一些。4.5 数据库文件记录与磁盘文件不一致现象数据库里文件记录存在但磁盘文件已经被删除或者磁盘文件还在数据库记录被清理导致日志审计里文件ID关联不上。这个问题最常见的触发场景是运维手工清理旧文件时只删磁盘不清数据库。建议做一个每日定时任务扫描比对数据库记录与磁盘文件存在性两边不一致的列出清单人工处理。文件哈希字段也可以利用起来定期抽样比对磁盘文件哈希和数据库hash值防止文件被偷偷篡改。最后说两句实在话把这套文件上传下载防泄漏机制完整落地之后我最大的体会是安全不是某一个中间件、某一个过滤器能解决的它是层层叠加形成的一个整体。你可能觉得自己系统不大、用户量不高、文件不算特别敏感就跳过加密、跳过令牌机制可一旦发生一次文件泄露代价往往是整个系统推倒重来口碑和信任也跟着赔进去。还有一个细节想提醒同行文件下载一定要把成功和被拒绝都记进日志。被拒绝的访问往往比成功访问更有价值因为那是有人正在试探的痕迹。我习惯每天早上扫一遍昨日被拒绝的记录能发现不少异常IP和异常账号比等出了事再翻日志划算得多。最后再分享一个小技巧系统上线之前自己开一个无痕窗口用一个完全没有权限的测试账号把所有上传下载接口挨个锤一遍不管能不能猜到参数都试一遍。这个动作花不了多长时间但能把前面说的绝大多数漏洞提前暴露出来。安全攻防就是一场猜谜游戏你能想到的别人大概率也能想到提前一步堵上心里才踏实。
返回列表