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

1868 字
9 分钟
后端接口的资源归属与文件安全

接口已经要求登录,不代表用户只能访问自己的数据。只要路径里出现订单 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. 文件路径不是下载凭证#

数据库里的本地路径只能由后端解释。文件解析时至少完成:

  1. 只接受系统资源前缀;
  2. 使用配置的 profile 目录作为根;
  3. 对根目录和目标路径执行绝对化、规范化;
  4. 确认目标仍以根目录开头;
  5. 确认目标是普通文件;
  6. 根据资源归属通过鉴权接口输出文件流。

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());
}

这三步不能倒置:

  1. 使用业务 ID 查到数据库记录;
  2. 根据记录关系判断当前用户是否有权限;
  3. 只有通过权限后才解析服务器文件路径。

如果先允许客户端提交任意路径给 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 接口越过用户数据边界。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
后端接口的资源归属与文件安全
https://firefly.cuteleaf.cn/posts/backend-resource-ownership-file-security/
作者
Yore
发布于
2026-07-28
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Yore
Hello, I'm Yore.
公告
欢迎来到Yore的博客!本站基于原创作者夏叶的博客网站模版创作。
分类
标签
最新动态
站点统计
文章
51
分类
2
标签
124
总字数
94,115
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
EdgeOne Pages
博客版本
Firefly v6.15.0
文章许可
CC BY-NC-SA 4.0