Skip to content

chore(security): 用户中心 / chat / 密码 / compose 多处姿态收紧#26

Merged
longsizhuo merged 2 commits intomainfrom
security/p0-hotfix-2026-05-07
May 8, 2026
Merged

chore(security): 用户中心 / chat / 密码 / compose 多处姿态收紧#26
longsizhuo merged 2 commits intomainfrom
security/p0-hotfix-2026-05-07

Conversation

@longsizhuo
Copy link
Copy Markdown
Member

@longsizhuo longsizhuo commented May 8, 2026

背景

2026-05-07 三方代码 review(backend / frontend / ChatBot)发现串成一条的 P0 攻击链与若干独立 P0。本 PR 处理后端侧 5 项,前端侧 1 项见 frontend 仓库同名 PR。

完整不变量清单见新增的 SECURITY.md。所有不变量都在 SecurityInvariantsTests 里有"攻击成功 → CI 红"的回归测试。

改动按不变量分组

INV-001 · superadmin 角色不能通过 API 授予

UserCenterService#updateAuthorizationRESTRICTED_ROLES 黑名单。
AdminUserController#setAdminRole 之前已经手工拒绝 superadmin,但 PUT /users/{id}/authorization 整集替换 roles 完全绕过了那条边界 —— admin 自带 user:center:manage 权限,可以一行 curl 给自己挂上 superadmin

INV-002 · 不允许往他人的 chat 历史写消息

ChatHistoryController#savesaveTurn 之前先 lookupOwner:已绑定 ownerId 的 chat 必须由 owner 本人写。匿名 chat 仍允许匿名继续写,保留"匿名 → 登录迁移"语义。

新增 AccessDeniedBusinessException + GlobalExceptionHandler handler,让"业务归属"类 403 不和 Sa-Token "缺权限/缺角色"场景共用 message 模板。

INV-003 · 密码必须 bcrypt

PasswordService 切到 BCryptPasswordEncoder(cost=10)

  • matches 双路径:bcrypt 原生 + legacy SHA-256 dual-mode 兼容
  • AuthService#login 成功后判断 isLegacyHash,触发 lazy upgrade 就地迁移(失败不阻断登录)
  • schema.sql / docker/init-db/init.sql / test-schema.sql 三处 seed 同步重写为 bcrypt 形式(明文与之前一致:Admin@123456 / Alice@123456 / Audit@123456

新增 spring-security-crypto 依赖(单独 artifact,不引入完整 Spring Security 的 Filter chain)。

INV-004 · user_follows 表必须随 schema 一并建立

FollowService 用的 user_follows(follower_id, followee_id, created_at) 之前在 schema.sqlinit.sql 都没建。新部署 / 新 Neon 库一访问 /api/user-center/follows/... 必 500。本 PR 加表 + idx_user_follows_followee 索引。

INV-005 · compose 不暴露公网 PG 端口、不留弱密码默认值

postgres 端口部分历史已经收回 loopback,本 PR 把 ${POSTGRES_PASSWORD:-change_me} / ${PGPASSWORD:-change_me} / ${PGADMIN_PASSWORD:-change_me} 三处弱密码 fallback 改成 :? 强制 .env 显式提供,并加测试防回退。

顺手修

PR #18discord-bridge seed 后 JdbcUserAccountRepositoryTests / UserCenterControllerIntegrationTestshasSize(3) 没跟着改,main 上一直红。本 PR 一并改成 4。

测试

./mvnw test
# Tests run: 212, Failures: 0, Errors: 0, Skipped: 0

新增的 SecurityInvariantsTests 12 条全是"测试代码本身就是攻击脚本,断言被拒"的形式,未来谁删了校验、改坏了 seed、回退 compose 端口,CI 立即红。

Test plan

  • ./mvnw test 全套 212 通过
  • SecurityInvariantsTests 单独跑通过(12/12)
  • Reviewer:本地 set -a && . ./.env && set +a && ./mvnw spring-boot:run 起后端,按 PR 描述用 curl 复现攻击路径,验证现在被拒
  • Reviewer:检查 SECURITY.md 五条不变量的描述是否准确,能否作为长期防回归参考

按 SECURITY.md 登记五条不变量并加 SecurityInvariantsTests 回归网:

- INV-001 UserCenterService.updateAuthorization 加 RESTRICTED_ROLES 黑名单,
  阻断走 PUT /users/{id}/authorization 整集替换 roles 绕过 AdminUserController
  superadmin 保护边界的路径
- INV-002 ChatHistoryController 加 chat 归属校验:已绑定 owner 的 chat 不再
  允许他人写入;同时新增 AccessDeniedBusinessException 让业务归属类 403 与
  Sa-Token 缺权限/缺角色场景的 message 解耦
- INV-003 PasswordService 切到 BCryptPasswordEncoder(cost=10),保留 dual-mode
  让历史 SHA-256 用户能登录,AuthService 在登录成功后做 lazy upgrade 就地
  迁移;schema.sql / init.sql / test-schema.sql 三处 seed 同步重写为 bcrypt
- INV-004 user_follows 表与 idx_user_follows_followee 索引加进 schema.sql /
  init.sql / test-schema.sql,FollowService 引用的 relation 之前在新部署上
  全部 500
- INV-005 docker-compose.yml 把 :-change_me 弱密码默认值改成 :? 强制 .env 提供,
  避免漏配 env 时仍以弱密码起服务

顺手修:discord-bridge seed 自 PR #18 起就在 test-schema.sql 里,但
JdbcUserAccountRepositoryTests / UserCenterControllerIntegrationTests 的
hasSize(3) 没跟着更新,main 上一直红——这次一并改成 4。

测试:./mvnw test 全套 212 / 212;新增 SecurityInvariantsTests 12 条覆盖
五条不变量的"攻击成功就 fail"探测器。
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 以“安全不变量(INV-001~005)”为主线,对用户中心授权、Chat 写入归属、口令哈希、DB schema 漂移、以及 docker-compose 默认弱配置进行了收紧,并引入回归测试与 SECURITY.md 作为长期防回退基线。

Changes:

  • 将密码哈希切换为 bcrypt(含 legacy SHA-256 兼容 + 登录成功后 lazy upgrade),并同步所有 seed/初始化脚本与测试覆盖。
  • 对 ChatHistory 写入增加 owner 归属校验路径,并新增“业务归属型 403”异常类型与全局处理器。
  • 补齐 user_follows 表与索引 DDL,并通过 SecurityInvariantsTests + SECURITY.md 固化不变量与 docker-compose 配置约束。

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/test/resources/test-schema.sql 测试库 seed 改为 bcrypt,并补齐 user_follows 表以对齐生产 schema。
src/test/java/com/involutionhell/backend/usercenter/service/PasswordServiceTests.java 更新 PasswordService 单测以覆盖 bcrypt 输出、salt 随机性与 legacy SHA-256 兼容。
src/test/java/com/involutionhell/backend/usercenter/service/AuthServiceTests.java 适配 AuthService 依赖注入变更(新增仓库依赖的 mock)。
src/test/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepositoryTests.java 修复 seed 用户数量断言(discord-bridge)。
src/test/java/com/involutionhell/backend/usercenter/controller/UserCenterControllerIntegrationTests.java 修复 /users 列表 seed 数量断言(discord-bridge)。
src/test/java/com/involutionhell/backend/security/SecurityInvariantsTests.java 新增安全不变量回归测试(INV-001~005)。
src/main/resources/schema.sql 生产 schema seed 改为 bcrypt,并新增 user_follows 表与索引。
src/main/java/com/involutionhell/backend/usercenter/service/UserCenterService.java 在授权更新接口中拦截受限角色(superadmin)。
src/main/java/com/involutionhell/backend/usercenter/service/PasswordService.java PasswordService 改为 bcrypt(dual-mode matches + legacy 判定)。
src/main/java/com/involutionhell/backend/usercenter/service/AuthService.java 登录成功后对 legacy SHA-256 口令进行 lazy upgrade 到 bcrypt。
src/main/java/com/involutionhell/backend/usercenter/repository/UserAccountRepository.java 新增 updatePasswordHash 用于 lazy upgrade 写回。
src/main/java/com/involutionhell/backend/usercenter/repository/JdbcUserAccountRepository.java 实现 updatePasswordHash
src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java 新增业务归属型 403 异常处理。
src/main/java/com/involutionhell/backend/common/error/AccessDeniedBusinessException.java 新增业务归属访问拒绝异常类型。
src/main/java/com/involutionhell/backend/chat/repository/JdbcChatHistoryRepository.java 新增 lookupOwner 查询 chat 归属信息。
src/main/java/com/involutionhell/backend/chat/repository/ChatOwner.java 新增 ChatOwner 载体区分“不存在/匿名/已绑定 owner”。
src/main/java/com/involutionhell/backend/chat/repository/ChatHistoryRepository.java 增加 lookupOwner 接口用于写入前归属校验。
src/main/java/com/involutionhell/backend/chat/controller/ChatHistoryController.java 写入前增加 owner 校验并在越权时返回业务归属型 403。
SECURITY.md 新增不变量文档(INV-001~005)并与测试建立映射。
pom.xml 引入 spring-security-crypto 以使用 BCryptPasswordEncoder。
docker/init-db/init.sql 初始化脚本 seed 改为 bcrypt,并补齐 user_follows DDL。
docker-compose.yml compose 收紧:去除弱密码 fallback,要求显式提供 env,并强调 PG 端口 loopback 绑定。

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread SECURITY.md Outdated
- `docker/init-db/init.sql`(同步 DDL)
- `src/test/resources/test-schema.sql`(测试同步)
- `usercenter/follows/FollowService`(SQL 调用方)
- **测试**:`SecurityInvariantsTests#user_follows表存在且follow_unfollow能闭环`
Comment on lines +46 to +50
-- 关注关系(user_follows)—— 与 schema.sql 保持一致;H2 PostgreSQL MODE 接受
CREATE TABLE IF NOT EXISTS user_follows (
follower_id BIGINT NOT NULL,
followee_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
Comment on lines +304 to +308
// 简化版扫描:postgres ports 段后面紧跟的 mapping 行必须 127.0.0.1: 开头
Pattern unsafePattern = Pattern.compile(
"(?m)^\\s*-\\s*\"(0\\.0\\.0\\.0:|\\d+:)5432:5432\"");
Assertions.assertThat(unsafePattern.matcher(content).find())
.as("docker-compose.yml 检测到 postgres 端口暴露公网形式(如 \"5432:5432\" 或 \"0.0.0.0:5432:5432\")")
Comment on lines +49 to +53
// INV-002:归属校验。已绑定 owner 的 chat 必须由 owner 本人写。
// 这一步必须在 saveTurn 之前——saveTurn 内部用 ON CONFLICT upsert,
// 一旦执行就会插入 Message 行,事后回滚得靠 @Transactional,宁可前置拦截。
Optional<ChatOwner> existing = chatHistoryRepository.lookupOwner(req.chatId());
if (existing.isPresent() && !existing.get().isAnonymous()) {
log.info("已就地升级用户 {} 的密码哈希(legacy → bcrypt)", userAccount.username());
} catch (Exception e) {
// lazy upgrade 失败不阻断登录——记日志即可,下次登录会再次尝试
log.warn("用户 {} 密码哈希升级失败:{}", userAccount.username(), e.getMessage());
Comment on lines +55 to +59
// INV-003 lazy upgrade:把老 SHA-256 hash 升级为 bcrypt(用同一明文重新 hash)
if (passwordService.isLegacyHash(userAccount.passwordHash())) {
try {
userAccountRepository.updatePasswordHash(
userAccount.id(),
@longsizhuo longsizhuo added the bug Something isn't working label May 8, 2026
PR #26 Copilot review 7 条评论里 6 条本 commit 处理,第 7 条(chat
saveTurn 的 TOCTOU window)单独跟踪 follow-up,本 PR scope 不修。

修复点:

1. SECURITY.md INV-004 引用的测试方法名跟实际代码对不上——文档本来写
   `user_follows表存在且follow_unfollow能闭环`,实际是 `user_follows表存
   在且字段可读写`,按 Copilot 反馈对齐文档。

2. test-schema.sql 之前只建了 user_follows 表但没建 idx_user_follows_followee
   索引,跟生产 schema.sql 漂移;补上 CREATE INDEX 让"索引被误删"也能被 CI
   抓到(即便表本身仍存在)。

3. INV-005a compose 端口扫描原来只盯 "5432:5432" / "0.0.0.0:5432:5432"
   两种带引号写法,漏过无引号 5432:5432、IPv6 [::]:5432、host port 改成
   15432 但容器仍 5432 等其它形式。改成扫描所有 host:5432 mapping,凡是
   host part 不以 127.0.0.1: 起头的全算违规。

4. AuthService lazy upgrade 失败的 log.warn 之前只传 e.getMessage(),丢
   stack trace;改成把异常对象作为最后一个参数,保留完整堆栈用于排查
   死锁/连接池/权限根因,"不阻断登录"行为不变。

5. UserCenterService.updateAuthorization 之前只检查 RESTRICTED_ROLES,
   没拒 null/blank 元素——roles 里塞 null 会让 repository 的 String.join
   抛 NPE 走 500 兜底。增加 null/blank 校验返回 400,permissions 同样补齐。

6. 新增 3 条 AuthServiceTests 覆盖 INV-003 lazy upgrade 路径:
   - legacy hash 匹配后触发 updatePasswordHash 写回
   - updatePasswordHash 抛异常时不阻断登录
   - bcrypt hash 路径不触发 lazy upgrade

测试:./mvnw test 全套 215 / 215(之前 212 + 新增 3 条)。

未在本 PR 修:

- ChatHistoryController#save 的 lookupOwner + saveTurn 两步 TOCTOU window:
  利用窗口需要 victim/attacker 毫秒级并发写同一 UUID chatId,最坏后果
  一条 message 错挂到 victim chat。完整修法是把逻辑下沉到 service 层
  @transactional + 改 saveTurn SQL 加 owner WHERE,工作量超出本 hotfix
  scope,单独 follow-up 跟踪。
@longsizhuo
Copy link
Copy Markdown
Member Author

Copilot CR 反馈处理小结

7 条评论里 6 条已在 commit fb5033b 处理(fix(security): Copilot CR 反馈 6 处),新增 3 条 AuthServiceTests 覆盖 INV-003 lazy upgrade 路径,全套测试 212 → 215 通过。

TOCTOU 一条本 PR 不修

ChatHistoryController#save 的 lookupOwner + saveTurn 两步存在 TOCTOU window

确认是真问题,但评估后单独 follow-up 跟踪而不是本 PR 修。理由:

  • 利用条件极苛刻:受害者与攻击者必须毫秒级并发同一 UUID chatId(chatId 是 crypto.randomUUID 生成,需 victim 主动 leak)
  • 最坏后果是一条 user message 错挂到 victim chat(不是会话接管、不是数据破坏),与 INV-002 阻止的"任意越权写入"严重程度差一个数量级
  • 完整修法需要把 lookupOwner + saveTurn 下沉到 service 层 @Transactional + 改 saveTurn SQL 加 owner WHERE 子句(或 SELECT ... FOR UPDATE),属于事务边界重构,超出本 hotfix scope

会单独建 issue 跟踪根治方案。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants