后端接口的资源归属与文件安全

接口已经要求登录,不代表用户只能访问自己的数据。只要路径里出现订单 ID、地址 ID、凭证 ID,就需要防止用户替换 ID 后读取或修改别人的资源。这类检查属于对象级权限,不能只靠角色权限解决。
1. 当前用户只能来自安全上下文
用户端查询条件不要接受可自由修改的 wxUserId:
Long currentUserId = SecurityUtils.getUserId();query.setWxUserId(currentUserId);请求体即使带了用户 ID,也应忽略或覆盖。前端字段适合表达用户想做什么,不适合作为“用户是谁”的证据。
2. 先查资源,再检查归属
以修改地址为例:
读取 addressId 对应记录 -> 检查记录存在且未删除 -> 比较记录 wxUserId 与当前 userId -> 只更新允许修改的字段凭证读取要先从 proofId 找到 orderId,再检查订单归属。间接资源同样不能因为没有直接的用户字段就跳过校验。
3. 列表和详情都要隔离
详情接口容易想到归属校验,列表接口更容易遗漏。如果 Mapper 接收的是通用查询实体,前端可能通过参数覆盖用户条件。
更稳妥的做法是在 Service 中强制设置当前用户,并对用户端建立语义明确的方法,例如:
selectUserDeliveryOrderList(query)selectCurrentUserDeliveryProofVoList(orderId)selectUserNotificationList(query)方法名本身提醒实现者必须处理用户边界。
4. 最小字段写入
实体类经常包含状态、删除标记、审核字段和管理员备注。直接把请求实体交给 Mapper,可能让普通用户修改不该修改的数据。
应新建保存对象并显式复制白名单字段:
HyUserAddress save = new HyUserAddress();save.setWxUserId(currentUserId);save.setReceiverName(request.getReceiverName());save.setReceiverPhone(request.getReceiverPhone());save.setLongitude(request.getLongitude());save.setLatitude(request.getLatitude());读取时同样使用 VO 隐藏 storagePath、密钥、内部状态等字段。
5. 文件路径不是下载凭证
数据库里的本地路径只能由后端解释。文件解析时至少完成:
- 只接受系统资源前缀;
- 使用配置的 profile 目录作为根;
- 对根目录和目标路径执行绝对化、规范化;
- 确认目标仍以根目录开头;
- 确认目标是普通文件;
- 根据资源归属通过鉴权接口输出文件流。
target.startsWith(root) 是阻止 ../ 路径穿越的关键,但它不能替代前面的订单归属校验。
6. 错误信息也要克制
对无权访问和资源不存在返回过于不同的细节,可能帮助攻击者枚举有效 ID。业务上可以统一提示“资源不存在或无权访问”,服务端日志再保留具体原因。
文件响应还应使用可靠的 Content-Type,默认回退为 application/octet-stream,文件名需要正确编码,避免响应头被特殊字符破坏。
7. 一个通用的资源归属检查
订单归属校验可以集中成明确的私有方法:
private void checkUserOrderOwner( HyDeliveryOrder order, Long wxUserId, String message) { if (order == null || "2".equals(order.getDelFlag())) { throw new ServiceException("订单不存在"); } if (!wxUserId.equals(order.getWxUserId())) { throw new ServiceException(message); }}调用方必须先从数据库读取订单,不能用请求体里的 wxUserId 自己构造一个实体再校验。删除标记也在这里统一判断,逻辑删除后的资源对用户来说等同不存在。
如果希望避免泄露订单 ID 是否真实存在,可以把两个分支都返回“订单不存在或无权访问”,服务端日志再区分原因。
8. 间接资源需要沿关系检查
凭证记录本身没有微信用户 ID,因此检查路径是:
private void checkProofOwner(Long proofId) { HyDeliveryProof proof = proofMapper.selectById(proofId); if (proof == null) { throw new ServiceException("配送凭证不存在"); } HyDeliveryOrder order = orderMapper.selectById( proof.getOrderId()); checkUserOrderOwner( order, SecurityUtils.getUserId(), "只能访问当前登录用户的订单凭证");}异常反馈、支付记录和订单历史也属于间接资源。只验证它们存在还不够,必须沿外键找到拥有者。
为了减少每次两次查询,也可以在 Mapper 中使用 join 同时返回归属信息,但 Service 的语义仍应是“验证当前用户拥有该资源”。
9. DTO 白名单比黑名单可靠
黑名单思路是接收整个实体,再把危险字段设为空:
request.setStatus(null);request.setDelFlag(null);request.setWxUserId(currentUserId);mapper.update(request);以后实体新增 verifyStatus,开发者若忘记加入黑名单,就会再次产生越权字段。白名单则只复制允许字段:
HyUserAddress update = new HyUserAddress();update.setAddressId(request.getAddressId());update.setWxUserId(currentUserId);update.setReceiverName(request.getReceiverName());update.setReceiverPhone(request.getReceiverPhone());update.setVesselName(request.getVesselName());update.setMmsi(request.getMmsi());update.setLongitude(request.getLongitude());update.setLatitude(request.getLatitude());未来新增后台字段时,它默认不会进入写入路径。对于大型项目,可以使用专用 DTO 与映射器减少重复,但映射规则仍应是显式白名单。
10. 文件读取的完整检查顺序
public FileStreamVo getCurrentUserDeliveryProofImage( Long proofId) { HyDeliveryProof proof = getExistingProof(proofId); checkCurrentUserOrderOwner(proof.getOrderId()); return ProtectedFileUtils.resolveProfileFile( proof.getStoragePath());}这三步不能倒置:
- 使用业务 ID 查到数据库记录;
- 根据记录关系判断当前用户是否有权限;
- 只有通过权限后才解析服务器文件路径。
如果先允许客户端提交任意路径给 resolveProfileFile,即使目录穿越检查正确,也会把文件系统结构变成公开接口的一部分。
11. Path 规范化代码拆解
Path root = Paths.get(SystemConfig.getProfile()) .toAbsolutePath() .normalize();Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) { throw new ServiceException("文件路径非法");}if (!Files.isRegularFile(target)) { throw new ServiceException("文件不存在");}toAbsolutePath 消除工作目录差异,normalize 去掉 . 并解析 ..,resolve 把业务相对路径限制在根目录之下。最后再比较规范化后的 Path 层级。
Files.isRegularFile 同时排除目录和大部分特殊文件。若部署环境允许符号链接,还需要根据安全要求决定是否调用 toRealPath 并再次比较真实路径,避免链接指向根目录之外。
12. 文件响应头分别解决什么
response.setContentType(file.getContentType());response.setHeader("Cache-Control", "private, max-age=300");response.setHeader( "Content-Disposition", "inline; filename=\"" + encodedName + "\"");Files.copy(file.getPath(), response.getOutputStream());Content-Type告诉客户端如何解释字节;private阻止代理和公共 CDN 缓存私有订单图片;max-age=300允许当前用户客户端短时复用;inline表示优先预览,而不是强制下载;- 文件名编码避免中文或特殊字符破坏响应头。
高敏感文件还可以使用 Cache-Control: no-store。选择缓存策略时要同时考虑隐私和小程序重复加载体验。
13. 对象级权限的测试清单
每类资源至少验证以下场景:
当前用户 + 自己的有效资源 -> 成功当前用户 + 其他用户资源 -> 拒绝当前用户 + 已逻辑删除资源 -> 拒绝未登录请求 -> 在安全过滤器处拒绝普通用户 + 管理端接口 -> 权限拒绝管理员 + 鉴权文件接口 -> 按管理端规则读取包含 ../ 的异常存储路径 -> 路径解析拒绝数据库记录存在但磁盘文件不存在 -> 返回文件不存在这些用例比只验证“上传成功、下载成功”更能发现真正的安全边界问题。
14. 小结
登录解决身份,资源归属解决对象权限,字段白名单解决过度写入,VO 解决过度返回,路径规范化解决文件系统边界。任何一层缺失,都可能让一个看似普通的 CRUD 接口越过用户数据边界。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





























