You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

13 KiB

简历文件归属与文件名修复(2026-10-19 验收更新)

分支 fix/mysql57-module-migrations-20261009,基线 HEAD 1cbadc3e18a786ef642b0d329adde6b173c7b2ac。 本文是简历安全与文件名兼容的参考说明。关联批次已完成冻结快照的本地后端验收;尚未提交、推送、发布或完成线上复验。下述规则不是线上已生效的承诺。

1. 漏洞事实

环节 修复前行为
存储键 LocalResumeStorageService.store(userId, file) 生成 "{userId}_{UUID8}{ext}",第一个下划线之前的数字即所有者
客户端可控引用 StudentProfileUpdateRequest.resumeUrl(仅 @Size(max=500))可被客户端任意填写
落库 ProfileService.updateProfile() 直接 profile.setResumeUrl(req.getResumeUrl())
读取 ProfileService.getResume() 按资料引用直接 storage.read(...);LocalResumeStorageService.read() 只校验路径越界与文件存在
删除 / 替换 deleteResume() / uploadResume() 同样按该引用处理旧文件

结论:B 只要把自己的资料引用写成 A 的存储键,就能用自己的下载接口读到 A 的简历文件(独立探针已复现)。

2. 调用链梳理

  1. 普通资料修改:PUT /profile/me → ProfileController.updateMyProfile → ProfileService.updateProfile(@Transactional,逐字段非空合并 + 保存 + 登记评分刷新)。
  2. 简历上传:POST /profile/me/resume → uploadResume(锁资料行 → 存新文件 → 注册提交后删旧文件 → 保存 → 提交后删旧文件;失败时清理新文件)。
  3. 简历下载:GET /profile/me/resume → getResume → storage.read(resumeUrl)。
  4. 简历删除:DELETE /profile/me/resume → deleteResume(先 chatResumeLifecycle.purgeForUser 撤销聊天快照 → 清空资料字段 → 提交后删文件)。
  5. 简历版本:ResumeVersionService.createVersion/updateVersion/deleteVersion/setDefault,ResumeExportService/ResumeExportWorker 导出。版本行有 userId 且 requireOwnActiveVersion() 已做「本人版本」校验;file_storage_path 同样由 store(userId, file) 生成,不接受任何客户端引用。
  6. 快照复用:
    • 聊天简历卡片:ChatResumeService.send 在读本人资料引用前验证源文件键归属,再将字节写入 chat_resume_snapshots;下载继续使用快照授权,不直接读资料引用。
    • 企业下载:CompanyResumeService.load 先由 CompanyResumeAccessPolicy 决定投递关系、隐私及企业授权,再验证源文件键属于该决策中的求职者(不是 HR),最后才读取。

身份与校验分工:身份一律来自已验证 JWT/SecurityContext(CurrentUserAccessor),无请求参数入口;UserRoleGuard 只做角色校验、不做归属校验。

「事务提交前删除旧文件」问题:不存在。uploadResume 的旧文件删除注册在 afterCommit(失败时清理本次新文件);deleteResume 的文件删除同样 deleteFileAfterCommit;ResumeVersionService 亦然。

3. 修复方案

3.1 新增归属校验器(profile/storage/ResumeOwnershipVerifier.java,NEW)

  • 解析规则:取第一个下划线之前的十进制数字作为所有者;与 userId 按数值精确比较(禁止 startsWith,避免 1 与 10 前缀混淆)。
  • 失败关闭:null/空白、无下划线、前缀非纯数字、下划线后为空、含 / \ :(路径穿越/绝对路径/盘符)、含 .. → 一律视为「无法证明归属」并拒绝。
  • assertOwnedBy() 抛出 ErrorCode.FORBIDDEN(HTTP 403),消息为固定文案,不回显存储键、磁盘路径、文件名或他人标识。
  • 只做「键字符串归属解析」,不介入 ResumeStorageService.read()——企业按投递关系下载、投递/聊天快照等合法授权流程保持原样通过。

3.2 禁止普通资料接口修改简历引用(ProfileService.updateProfile)

  • 补齐 STUDENT 角色校验(此前 PUT /profile/me 无角色校验,COMPANY/ADMIN 可创建/篡改学生资料)。
  • req.getResumeUrl() != null(含空串)→ BusinessException(BAD_REQUEST) → HTTP 400,且校验发生在任何字段赋值/保存之前,整次拒绝、无部分更新。
  • 缺省或显式 null → 不改变原简历引用(保持兼容)。
  • 说明:选择「保留 DTO 字段 + 服务层显式判断」而非 @JsonIgnore——@JsonIgnore 会让「未提供」与「显式传了非法值」都映射为 null,无法区分;本方案用 != null 精确区分:未提供/显式 null 合法放行,任何提供的值一律拒绝。
  • 服务层保留该限制,内部调用同样无法绕过;简历引用只能由受控上传/版本/既有业务流程生成。

3.3 本人简历路径的归属校验(ProfileService)

操作 校验位置 失败语义
下载 getResume 角色 → 资料存在 → resumeUrl 非空 → 归属校验 → 才 storage.read 403,不读文件
删除 deleteResume 角色 → 资料行锁定 → 归属校验 → 才 purgeForUser/清空字段/登记删文件 403,资料、聊天快照、文件均不变,不删除他人文件
替换 uploadResume 角色 → 校验新文件 → 锁定资料 → 旧引用归属校验 → 才 store(新文件) 403,不改资料、不删他人文件、不留新文件

getResume 未创建资料 / resumeUrl 为空 / 合法引用但文件缺失 → 沿用既有 PROFILE_NOT_FOUND(404)。

3.4 历史污染引用的后续读取

  • ChatResumeService.send 在读源文件和生成聊天快照前调用归属校验器,拒绝借本人资料中的他人引用复制简历。
  • CompanyResumeService.load 在既有企业访问授权通过后,对 decision.studentUserId() 做源文件归属校验;不能用当前 HR 的 ID 校验候选人文件。
  • 保持 ResumeStorageService 与 LocalResumeStorageService 实现不变,避免将「必须本人读取」下沉到存储层而破坏合法 HR 下载。
  • 不同来源使用不同授权:源文件归属校验不能代替聊天成员、快照撤销或企业投递关系校验。

3.5 中文上传文件名与下载响应

  • 共享 ResumeFilenameSanitizer 对可明确识别的含非 ASCII 字节 %HH 序列做一次严格 UTF-8 解码,修复中文名称被再次编码为 %25E5... 的问题。
  • 字面 + 保持为 +,不使用会把它变成空格的 URLDecoder。
  • 纯 ASCII 百分号序列(例如字面 %20、%25)存在歧义,保守保留;不能理解为任意百分号串都会解码。
  • 新上传按解码后的名称执行长度及路径/控制字符检查,非法编码或危险名称拒绝为 400;不递归解码嵌套的非 ASCII 百分号内容。
  • 历史文件下载时尽力归一化并净化名称,不重写历史记录、不改存储键、不改文件字节;不能安全解码时保留后净化,而非猜测内容。
  • 当前隔离 HTTP 用例覆盖本人和合法企业下载的中文文件名响应;尚未完成真实浏览器/前端目标版本的端到端验收。

4. 错误码与接口兼容

  • 普通资料写入非法 resumeUrl → HTTP 400 / code 1001(沿用统一参数错误结构,未新增错误码)。
  • 已登录用户访问不属于自己的资源 → 403 / code 2002(选择理由:与项目既有语义一致——ResumeVersionService.requireOwnActiveVersion 对他人版本返回 FORBIDDEN,CompanyResumeAccessPolicy 对越权企业同样返回 FORBIDDEN;且在日志中可与企业下载等正常授权区分)。
  • 接口形态未变:PUT /profile/me 的响应结构、GET /profile/me 的响应字段(含 resumeUrl)保持原样,未新增/删除字段;PUT /profile/me 的 Swagger @Operation 描述已补充「resumeUrl 只读」约束。
  • 未修改共享的 docs/openapi-rc.json(该文件已被其他批次改动,避免混入本次修复)。

5. 兼容性边界

  • 历史键:当前本地存储实现生成 userId_UUID8.ext。可证明归属且格式合法的键继续使用;不能证明归属的历史引用失败关闭。未审计线上全部历史记录,不宣称所有历史键均符合该格式。
  • 版本文件:resume_versions.file_storage_path 由同一 store() 生成、格式一致;版本操作另有「版本行所属用户」校验,本轮未改动,合法版本/默认简历/导出行为不回退。
  • 其他存储类型:目前只有本地实现。若将来引入对象存储等新键格式,必须同步扩展 ResumeOwnershipVerifier 的解析规则,否则会失败关闭。
  • 历史污染引用:本轮只做安全拒绝,不自动删除、重绑、迁移或修复历史记录;也未新增任何联网排查工具。

6. 测试与验证(隔离环境合成数据)

最新验收基于 D:/Acceptance/qsp-final-20261019/app 冻结快照,不复用旧副本的测试结论。

  • 归属校验器单测、服务层测试和真实 SecurityFilterChain/Controller 参数绑定的 HTTP 测试全部纳入全量执行。
  • ResumeOwnershipHttpIntegrationTest 现含新增文件名兼容用例;另有 ResumeFilenameEncodingTest、企业下载回归与 HistoricalResumeOwnershipIntegrationTest 覆盖历史污染源及合法授权。
  • 跨账号拒绝路径检查文件、数据库状态及读写调用;中文文件名测试检查下载响应、字节一致以及旧记录不被回写。
  • 全量 clean verify:3464 项、0 失败、0 错误、0 跳过,BUILD SUCCESS。该数是全部后端测试总量,不是简历专项数。
  • 日志:D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log。

历史说明:旧副本曾出现上传返回 500、HTTP 类 7/11 失败;当前快照未复现,根因未证实,不作绝对归因。旧漏洞探针作为诊断历史证据保留,不改弱断言,不将其当作正式源码新增回归计数。

7. 最新 JAR 黑盒与重启验证

  • JAR:D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar。
  • SHA256:60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46。
  • 简历归属真实 HTTP 黑盒 32 项通过;补充的来源归属与文件名黑盒 26 项通过。岗位展示另有 48 项,共 106 项,不混算为简历专项。
  • 重启后简历源文件与聊天快照字节一致、已撤销卡片继续拒绝、非成员继续拒绝,共 4 项;另有岗位持久化 2 项。
  • 本轮应用使用隔离业务端口 18119、管理端口 18120 与独立 MySQL/Redis/RabbitMQ,仅合成账号和 PDF,不访问生产资源。
  • 结果:evidence/ownership-blackbox-result.json、evidence/reader-fix-blackbox-result.json;重启日志:logs/resume-persistence.log,均相对于上述验收目录。
  • 源码与工具身份由 evidence/source-manifest-current.csv、evidence/contract-tool-manifest.csv 和 evidence/jar-sha256.csv 固定;验收结束时漂移为 0。

8. 未完成项与权限边界

  • 尚未提交、推送、Jenkins 发布、线上验证或验收前端账号切换;本地通过不代表线上修复已生效。
  • 历史污染引用只安全拒绝,没有自动清理、重绑或迁移;已生成聊天快照未在线审计,不能宣称线上历史泄露风险全部排除。
  • 个人数据导出的 PersonalDataCollector.safeRead 当前输入来自简历版本的 fileStoragePath,不是此前误写的本人资料 resumeUrl。本轮未证明该路径存在相同漏洞,也未修改该模块;不得仅因同名 read 方法就下漏洞结论。
  • 对象存储等未来键格式必须同时调整归属策略与测试;当前无法证明归属的键继续失败关闭。
  • 评分测试时钟依赖已单独修复并纳入最新全量,未修改生产评分规则;旧 Docker/PATH 与契约失败日志保留为历史记录,不继续标为当前阻断。
  • 前端、受保护文件、历史迁移及底层存储实现未因本批安全修复而修改;本轮服务已停止,测试数据与证据保留,未做 Docker 全局清理。

9. 前端接入要点

  • 普通 PUT /profile/me 不提交非 null resumeUrl;简历修改使用受控简历上传/版本流程,不能通过个人资料写入文件引用。
  • 请求身份由当前有效令牌解析,不能用客户端手机号、角色字段或用户 ID 代替授权;换账号后须使用新令牌并隔离/清除旧账号缓存。
  • 对 400/403/404 按实际接口结果提示,不把权限拒绝降级为展示缓存简历,不通过本地文件名正确推断后端响应头已正确。
  • 上传名称传正常文件名即可,后端仅兼容明确的历史/客户端 UTF-8 编码,不要求客户端手写 multipart body,也不允许任意多次 URL 解码。
  • 交付的是本地验证规则;前端工程师需待对应版本发布后,在授权的测试账号下复验上传、本人下载、企业授权下载及账号切换。

详细汇总:D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json。本文未包含账号口令、令牌、真实简历或生产数据库配置。