13 KiB
简历文件归属与文件名修复(2026-10-19 验收更新)
分支
fix/mysql57-module-migrations-20261009,基线 HEAD1cbadc3e18a786ef642b0d329adde6b173c7b2ac。 本文是简历安全与文件名兼容的参考说明。关联批次已完成冻结快照的本地后端验收;尚未提交、推送、发布或完成线上复验。下述规则不是线上已生效的承诺。
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. 调用链梳理
- 普通资料修改:
PUT /profile/me→ProfileController.updateMyProfile→ProfileService.updateProfile(@Transactional,逐字段非空合并 + 保存 + 登记评分刷新)。 - 简历上传:
POST /profile/me/resume→uploadResume(锁资料行 → 存新文件 → 注册提交后删旧文件 → 保存 → 提交后删旧文件;失败时清理新文件)。 - 简历下载:
GET /profile/me/resume→getResume→storage.read(resumeUrl)。 - 简历删除:
DELETE /profile/me/resume→deleteResume(先chatResumeLifecycle.purgeForUser撤销聊天快照 → 清空资料字段 → 提交后删文件)。 - 简历版本:
ResumeVersionService.createVersion/updateVersion/deleteVersion/setDefault,ResumeExportService/ResumeExportWorker导出。版本行有userId且requireOwnActiveVersion()已做「本人版本」校验;file_storage_path同样由store(userId, file)生成,不接受任何客户端引用。 - 快照复用:
- 聊天简历卡片:
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不提交非 nullresumeUrl;简历修改使用受控简历上传/版本流程,不能通过个人资料写入文件引用。 - 请求身份由当前有效令牌解析,不能用客户端手机号、角色字段或用户 ID 代替授权;换账号后须使用新令牌并隔离/清除旧账号缓存。
- 对 400/403/404 按实际接口结果提示,不把权限拒绝降级为展示缓存简历,不通过本地文件名正确推断后端响应头已正确。
- 上传名称传正常文件名即可,后端仅兼容明确的历史/客户端 UTF-8 编码,不要求客户端手写 multipart body,也不允许任意多次 URL 解码。
- 交付的是本地验证规则;前端工程师需待对应版本发布后,在授权的测试账号下复验上传、本人下载、企业授权下载及账号切换。
详细汇总:D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json。本文未包含账号口令、令牌、真实简历或生产数据库配置。