SECURITY_REVIEW_2026-11.md 45 KB

CFC 安全维度审查报告(review 档 · 离线只读)

  • 审查人:关屿(security-reviewer)
  • 审查对象:d:/cfc(cfc-backend / cfc-web / cfc-frontend / cfc-langgraph)
  • 执行档:review 档(离线、只读、未获联网授权)
  • 审查方式:静态代码审计 + 仓库静态检索(git ls-files / git log / grep);未执行任何在线漏洞库扫描、DAST 动态扫描或真实渗透
  • 输出对象:测试架构师主理人(陆谨)→ 由主理人汇编

结论

不通过(安全维度)。 存在 5 项阻断级问题:①88/120 个后台 Controller 完全无鉴权且匿名可达;②admin.skip-captcha=true 使管理员登录验证码校验整体失效;③wechat.test-mode=true(含生产 profile)使验证码固定为 123456、微信手机号可直出,构成任意账号接管;④数据库口令 / JWT 密钥 / 微信 appsecret / 支付私钥 / 京东云 AK-SK / LLM Key 等明文密钥连同微信支付商户私钥证书被提交进 Git(含历史);⑤/api/stats/** 无鉴权且 /api/stats/child/overview 无归属校验,匿名可读全平台经营数据、用户手机号与任意儿童私有数据。安全扫描未实测(离线档未获授权,未运行 SAST/SCA/DAST 工具),本报告全部结论来自静态证据,不断言"系统不存在其他漏洞"。


关键证据(文件:行号)

A. 认证与会话

# 证据 判定
A1 cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java:62-77 — signWith(getSigningKey(), HS256) + parseClaimsJws(token) 正面:alg=none/算法混淆被正确拒绝
A2 JwtConfig.java:21-25 — secret 取自 ${jwt.secret};cfc-backend/src/main/resources/application.yml:68 明文默认密钥;cfc-backend/config/application-prod.yml:17 明文 prod 密钥 阻断 密钥入库
A3 JwtConfig.java:79-91 + application.yml:73(expiration: 2592000000 = 30 天) 无吊销/刷新/黑名单,30 天令牌不可回收
A4 JwtInterceptor.java:37-82 — PUBLIC_PATHS 实为 45 条(任务书"8 个公开路径"与实际不符),含 /api/mcp/**、/api/growth/**、/api/articles/** 全量 白名单远大于预期
A5 JwtInterceptor.java:139-149 — 无 token 且路径不在 LOGIN_REQUIRED_PATHS → 降级为匿名(userId=-1)并放行,非 401 匿名可达面 = 全部未列入 LOGIN_REQUIRED 的端点
A6 JwtInterceptor.java:85-101 — LOGIN_REQUIRED_PATHS 仅 15 条,不含 /api/admin/**、不含 /api/stats/** 匿名可达后台与统计
A7 JwtInterceptor.java:240-241 — 解析失败时 log.warn(... token={}) 打印 token 前 30 字符 token 片段入日志
A8 JwtInterceptor.java:127 — token = request.getParameter("token") token 可走 URL → 落入 access log / Referer
A9 AuthController.java:120-140 — /api/auth/auto-login(裸 openid 换 token)已返回 410 正面 已修复

B. 授权与越权(最严重)

# 证据 判定
B1 cfc-backend/src/main/java/com/etotem/cfc/controller/admin/ 共 120 个 Controller;grep -LE 'RequestAttribute("role")\|PreAuthorize\|AdminRoles\.\|isAdmin' 命中 88 个完全无任何角色校验 阻断
B2 controller/admin/AdminEarningsController.java:33-39 — familyEarningsMapper.selectList(null),无 userId/role 参数 匿名可读全部家庭收益
B3 controller/admin/AdminSystemPromptController.java:33-52(save)、:65-73(delete)、:85-97(rollback)— 无鉴权 匿名可篡改/删除/回滚系统提示词 → AI 行为劫持
B4 controller/stats/StatsController.java:89-90(/dashboard)、:122(/overview)、:191(/revenue)、:225(/membership)、:257(/trend)、:420(/traffic) — 均无 @RequestAttribute("role") 匿名可读平台经营数据
B5 StatsController.java:669-676 — 用户名回落到 u.getPhone(),放进 userDwellList 匿名可拉全站用户手机号(PII 泄露)
B6 StatsController.java:292-294 — /child/overview 仅接 memberId,无 userId/familyId 归属校验,直查 familyMemberMapper.selectById(memberId) + pointsLogMapper + taskService.getAllTasksByChild(memberId) IDOR:任意 memberId 可读他人孩子积分/任务/游戏轨迹
B7 controller/family/FamilyMembersController.java:237-248 → service/FamilyMemberService.java:952-963 — isEditable(Long memberId) 不接受 userId 跨家庭成员存在性/绑定关系探测
B8 config/SecurityConfig.java:30-41 — authorizeRequests().anyRequest().permitAll() + csrf().disable() Spring Security 形同虚设,鉴权 100% 依赖手写代码
B9 config/WebConfig.java:145-179 — FamilyAccessInterceptor 对 /api/admin 整体跳过(FamilyAccessInterceptor.java:42,52-54) 后台端点不受家庭隔离保护
B10 JwtInterceptor.java:37-82 — /api/admin-auth/login、/api/admin-auth/login-by-password 在公开白名单 登录接口本身匿名可达(配合 D2 成灾)

C. 注入与输入校验

# 证据 判定
C1 cfc-backend/src/main/resources/mapper/**.xml 检索 ${ → 0 命中;util/SortUtil.java:35,170-184 用白名单正则 ^[a-zA-Z_][a-zA-Z0-9_]*$ 校验排序列后才 wrapper.last() 正面 SQL 注入面收敛
C2 controller/HealthReportController.java:820-831(/report/upload)— 仅 filename.endsWith(".pdf") OR contentType=="application/pdf",无 %PDF- 魔数校验,随后 pdfParseService.parse(file.getInputStream()) 直接解析 上传即解析,类型可伪造
C3 controller/HealthReportController.java:914-945(/report/upload-only)— 图片分支仅靠 isImageContent(contentType),无服务端白名单 类型可由客户端伪造
C4 service/impl/LocalDiskStorageService.java:66-75 与 :97-105 — 扩展名取自 file.getOriginalFilename() 未剥离路径分隔符,uploadPath.resolve(filename) 未做 normalize() 越界校验 理论路径穿越
C5 后端 无 Jsoup / OWASP Java HTML Sanitizer / AntiSamy(pom.xml 无相关依赖,grep -rni "jsoup\|antisamy\|htmlsanitiz" 0 命中);前端 无 DOMPurify(cfc-web/package.json、cfc-frontend/package.json 均无) 无 HTML 净化
C6 controller/admin/AdminArticleController.java:104、:197 — article.setContent((String) body.get("content")) 原样入库;cfc-web/src/views/admin/review/ArticleAudit.vue:80 — v-html="currentRow.content" 存储型 XSS
C7 cfc-web/src/views/admin/ProxyReportUpload.vue:59、:121(v-html="analysisResult" / v-html="detailReport.aiAnalysis")、cfc-web/src/views/teacher/ProxyReport.vue:75、:158 AI 报告内容(源自用户上传 PDF)未净化即渲染 → XSS
C8 config/SecurityHeadersFilter.java:26-28 — CSP script-src 'self' 'unsafe-inline' 'unsafe-eval' 削弱 XSS 缓解,注入可直接执行

D. 配置与密钥

# 证据 判定
D1 git ls-files --error-unmatch cfc-backend/src/main/resources/application.yml → 已跟踪;下列明文口令全部入库:application.yml:24(DB 口令)、:49(sfms DB 口令)、:68(JWT 密钥默认)、:89(微信 appsecret)、:91-92(mch-key/api-v3-key)、:100-101(虚拟支付 appkey)、:103(虚拟支付 token)、:116-117(dify api-key)、:124(callback-secret)、:150(opencode 管理口令)、:179-180(京东云 access-key/secret-key) 阻断(值已脱敏,不在此复述)
D2 cfc-backend/src/main/resources/cert/apiclient_key.pem、apiclient_cert.pem、apiclient_cert.p12 — git ls-files cfc-backend/src/main/resources/cert/ 全部已跟踪;.gitignore 未覆盖 cert/(grep -n "cert\|pem\|p12" .gitignore 无命中) 阻断 微信支付商户私钥入库
D3 git ls-files cfc-langgraph/ → .env 已跟踪,含 LLM_API_KEY / EMBEDDING_API_KEY / LANGCHAIN_API_KEY 阻断 LLM 凭据入库
D4 git log --all --diff-filter=A -- cfc-backend/config/application-prod.yml → 389615495 已新增入历史;该版本含明文 DB 口令、JWT 密钥、test-mode: true 阻断 生产配置进 git 历史(当前工作树已 untracked,但历史仍在)
D5 application.yml:98 — test-mode: true(无 ${WECHAT_TEST_MODE:...} 占位);cfc-backend/config/application-prod.yml:37 — 生产 profile 同样 test-mode: true 阻断
D6 service/WechatService.java:115-124 — 启动守卫 checkTestModeAtStartup() 仅当 spring.profiles.active=="prod" 才抛异常;application.yml 未设 spring.profiles.active(grep 无命中);cfc-backend/startup.sh:17 — PROFILE="${1:-dev}";cfc-backend/start.sh:11 — ENV="dev" 阻断 守卫默认不触发
D7 service/VerificationCodeService.java:78-83 — if (testMode && "123456".equals(code)) return true; 阻断 验证码固定
D8 service/WechatService.java:209-220 — testMode 下 getPhoneNumber 把 code 原样当手机号(test_<手机号> → 真实手机号) 阻断
D9 controller/auth/AuthController.java:67-84(/api/auth/phone-login,公开白名单 JwtInterceptor.java:39)→ VerificationCodeService.verifyCode 阻断 任意手机号 + 123456 → 账号接管
D10 application.yml:212 — admin.skip-captcha: true;controller/admin/AdminAuthController.java:107-114 — if (!skipCaptcha) { verifyCode(...) },skipCaptcha 时校验分支整体被跳过 阻断 仅凭手机号即得 admin JWT
D11 controller/admin/AdminAuthController.java:337-355 — /api/admin-auth/account-roles 公开(JwtInterceptor.java:62),按手机号返回后台角色列表 严重 管理员账号枚举
D12 controller/admin/AdminAuthController.java:374-383 与 config/RateLimitInterceptor.java:174-186 — 直接采信客户端 X-Forwarded-For / X-Real-IP 严重 限流与登录锁定可绕过
D13 config/RateLimitInterceptor.java:165-172 — path.contains(entry.getKey()) 子串匹配 + ConcurrentHashMap 迭代顺序不确定 → /api/auth/send-code(60s/1 次)可能被 /api/auth/(1s/10 次)规则抢占 严重 验证码防爆破强度不确定
D14 config/WebConfig.java:44-52 — CORS 显式域名白名单(非 *,可接受),但 allowedHeaders("*") + allowCredentials(true),且混入多个 http:// 明文源与 http://111.228.6.214(裸 IP) 一般
D15 config/WebConfig.java:55-68 — 全局 Access-Control-Allow-Private-Network: true 响应头 一般 放宽 Chrome PNA
D16 pom.xml/application.yml 无 actuator、无 h2console 依赖或配置(grep 0 命中) 正面
D17 controller/admin/AdminAuthController.java:410-425 — 兼容旧 MD5 密码哈希(xzyj_salt_ / xzyj_admin_salt_) 严重 弱哈希存量口令风险
D18 application.yml:22,47 — JDBC URL 含 useSSL=false&allowPublicKeyRetrieval=true;config/application-prod.yml:17-21 sfms 指向公网 RDS bianwoyou.mysql.rds.aliyuncs.com 严重 传输无 TLS

E. 依赖风险(在线漏洞库未扫描)

# 证据 判定
E1 cfc-backend/pom.xml:18 — spring-boot-starter-parent 2.7.18 严重 OSS 支持已于 2023-11 结束,2.7.x 存在多条已知 CVE
E2 pom.xml:171 — pdfbox 2.0.31;且 C2 显示上传 PDF 直接解析该库 严重 陈旧 + 处于攻击面
E3 pom.xml:152 fastjson 2.0.40;:145 okhttp 3.14.9;:185 aws-java-sdk-s3 1.11.136(v1 线已 EOL);:178 springdoc-openapi-ui 1.8.0;:24 mybatis-plus 3.5.3.1;:25 jjwt 0.11.5 一般/严重 陈旧
E4 cfc-web/package-lock.json 解析版本:vue 2.7.16(Vue 2 已 EOL)、element-ui 2.15.14、jsplumb 2.15.6、axios 1.16.0 一般
E5 cfc-frontend/package.json — postcss 7.0.39(CVE-2023-44270 影响 <8.4.31)、jest 26.6.3 一般
E6 cfc-langgraph/requirements.txt — chromadb==0.5.23、deepface>=0.0.90、faster-whisper==1.2.1、av==18.1.0、PyPDF2>=3.0 严重 深度学习/媒体栈陈旧且体量大
E7 无 CI 流水线:.github/、.gitlab-ci.yml、Jenkinsfile、.circleci/、azure-pipelines.yml 均不存在(ls -d 全部未命中);对全部 git 跟踪文件的穷举复核:git ls-files \| grep -iE "^\.github/\|\.gitlab-ci\|Jenkinsfile\|^\.circleci/\|azure-pipelines" → 0 命中 阻断(安全测试完全未接入研发流程)
E7b 穷举复核全部被跟踪的 yml/yaml/xml/json/sh/bat/ps1/gradle 文件,grep -liE "semgrep\|gitleaks\|snyk\|trivy\|dependency-check\|codeql\|spotbugs\|sonar" → 0 命中(唯一相关脚本 tools/check-test-mode.ps1 见 E9) 无任何 SAST/SCA/密钥扫描工具被引用
E8 githooks/pre-push — 仅执行 mvn -q -DskipTests compile;且需人工 git config core.hooksPath githooks 才生效;XAF_CI_GATE=0 可跳过 严重 无任何测试/安全扫描闸门
E9 tools/check-test-mode.ps1:13-18 — 扫描路径写死 cfc-backend/src/main/resources/,而实际 prod 配置在 cfc-backend/config/;src/main/resources/ 下无 application-prod*.yml → 命中 if ($prodYamlFiles.Count -eq 0) { "Skipping check"; exit 0 } 严重 test-mode 守卫恒为假通过
E10 锁文件:cfc-web/package-lock.json ✓、cfc-frontend/package-lock.json ✓(均存在且被跟踪) 正面
E11 后端 189 个测试类中仅 ~15 个涉及鉴权关键词(FamilyAccessInterceptorTest、JwtInterceptorLoginRequiredTest、GuideRolePermissionTest 等),无一条覆盖 admin 端点鉴权 / 匿名可达 / 密钥泄露 严重 安全测试覆盖缺口

F. 供应链与数据安全

# 证据 判定
F1 service/VerificationCodeService.java:67、:88、:112 — 验证码与手机号明文入日志 严重
F2 service/WechatService.java:364、:384 — log.error("...{}", json.toJSONString()) 打出微信响应(含 access_token) 严重
F3 config/JwtInterceptor.java:240-241 — token 前 30 字符入日志 严重
F4 service/impl/LocalDiskStorageService.java 依赖客户端文件名扩展名;upload.allowed-types(application.yml:172)与 FileStorageService.java:22 的默认值不一致(yml 含 pdf/audio,代码默认仅 4 种图片) 一般 配置漂移

问题清单(五要素)

P1 【阻断】后台 Controller 大面积缺失鉴权,匿名可直接读写后台数据与系统提示词

① 问题描述:/api/admin/** 下 120 个 Controller 中 88 个完全没有角色校验;JwtInterceptor 对无 Token 请求降级为匿名(userId=-1)并放行,且 /api/admin/** 不在 LOGIN_REQUIRED_PATHS 内。Spring Security 又配置为 anyRequest().permitAll()。三者叠加使后台端点对互联网匿名开放。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/config/SecurityConfig.java:30-41
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java:85-101、:139-149
  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminEarningsController.java:33-39
  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSystemPromptController.java:33-52、:65-73、:85-97
  • 缺失清单(部分):AdminCategoryController / AdminInventoryController / AdminNoticeController / AdminOrderController / AdminProductController / DataMigrationController / MigrationController / SysMenuController / OperationLogController / KnowledgeBaseController 等 88 个

③ 最优修改方案:

  1. 在 JwtInterceptor 中新增统一后台网关:路径以 /api/admin/ 开头时,token 缺失或 roles 不含任一 AdminRoles.BACKEND_ROLES → 直接 writeUnauthorized/sendForbidden,绝不降级匿名。
  2. 把 JwtInterceptor 的"降级匿名"改为默认拒绝:LOGIN_REQUIRED_PATHS 改为默认全部需要登录 + 显式白名单(当前是反的),把 45 条 PUBLIC_PATHS 收敛为真正的公开接口。
  3. 在 SecurityConfig 追加 authorizeRequests().antMatchers("/api/admin/**").hasAnyRole(...)(或最简:全部 authenticated()),并开启 @EnableGlobalMethodSecurity 的方法级校验。
  4. 88 个无校验 Controller:统一加 @PreAuthorize("hasAnyRole('admin','operator','finance','butler','knowledge_admin','teacher','nutritionist','supplier','activity_manager','org_admin')")(按最小权限细分),或引入 AdminRoles.hasAnyBackendRole(roles) 守卫。
  5. 短期兜底:在 WebConfig.addInterceptors 增加一个 AdminAuthInterceptor,按前缀拦截,不依赖逐个改 Controller。

④ 修改目的:把"认证+授权"从散落的手写 if 收敛为默认拒绝的单一闸门,达成"漏洞可见 → 不可利用";消除匿名可达后台这一最宽的暴露面。

⑤ 修改后能否做问题闭环:能。复测方式:① 新增 AdminEndpointAuthTest,对 /api/admin/** 全量路由做参数化扫描,断言「无 Token → 401」「普通 parent Token → 403」「admin Token → 2xx」;② 在 CI 中加一条"路由 × 角色矩阵"守卫测试,新增 Controller 未声明角色即失败。通过标准:/api/admin/** 匿名 100% 返回 401/403,角色矩阵全覆盖无空缺。


P2 【阻断】admin.skip-captcha=true 使管理员登录验证码校验整体失效

① 问题描述:AdminAuthController.login 中验证码校验被 if (!skipCaptcha) 包裹,而主配置 application.yml:212 为 admin.skip-captcha: true。结果:只要知道任意一个管理员手机号,提交任意 code 即可换到带 admin 角色的 JWT,验证码形同不存在。该端点本身在公开白名单(JwtInterceptor.java:59-62)。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java:94-158,特别是 :107-114
  • cfc-backend/src/main/resources/application.yml:212
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java:59-62
  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java:268-331(login-by-password 走 MD5 兼容,见 P10/D17)

③ 最优修改方案:

  1. 立即:admin.skip-captcha 改为 ${ADMIN_SKIP_CAPTCHA:false},且生产 profile 强制 false;application.yml 不得出现 true。
  2. 代码层:把 skipCaptcha 分支从"跳过验证码"改为"跳过图形验证码但仍必须校验短信验证码"——即 skipCaptcha 只控制图形挑战,绝不能控制短信码比对。
  3. skipCaptcha=true 时禁止发放令牌,只允许"降级为短信码登录"(短信码仍校验)。
  4. 补强:/api/admin-auth/login 增加独立的强限流(见 P10)与"同 IP 多手机号尝试"告警。

④ 修改目的:消除"知道手机号即接管后台"这一完整认证绕过,使后台入口具备真正的持有性证明(短信码/密码)。

⑤ 修改后能否做问题闭环:能。复测方式:新增 AdminAuthCaptchaTest —— 生产配置下 POST /api/admin-auth/login {phone:"<任一后台账号手机号>", code:"000000"} 必须返回"验证码错误",且 skipCaptcha 只影响图形挑战、短信码仍校验;再用 code=真实短信码 断言可成功。通过标准:无效 code 100% 被拒,且不存在任何 code 值可绕过。


P3 【阻断】wechat.test-mode=true(生产 profile 亦为 true)→ 验证码固定 123456 + 手机号直出 = 任意账号接管

① 问题描述:生产 profile 与主配置都是 test-mode: true。该开关一旦生效:① 任何手机号 + 验证码 123456 即通过 /api/auth/phone-login;② /api/auth/wechat-phone-login 的 code 可直接写成 test_<受害者手机号> 换取该手机号;③ 支付/订阅走 mock 不真实扣费。启动期守卫 checkTestModeAtStartup() 只在 spring.profiles.active=="prod" 时抛异常,而配置里从未设置 spring.profiles.active,启动脚本还默认 dev。

② 测试 bug 点:

  • cfc-backend/src/main/resources/application.yml:98(test-mode: true,无环境变量占位)
  • cfc-backend/config/application-prod.yml:37(生产 profile 同为 true)
  • cfc-backend/src/main/java/com/etotem/cfc/service/VerificationCodeService.java:78-83("123456" 直通)、:66-70
  • cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java:115-124(守卫条件)、:209-220(手机号直出)
  • cfc-backend/src/main/java/com/etotem/cfc/controller/auth/AuthController.java:67-84、:90-101
  • cfc-backend/startup.sh:17、cfc-backend/start.sh:11
  • cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java:95-96,111,278,436,479,504

③ 最优修改方案:

  1. test-mode 改为 ${WECHAT_TEST_MODE:false},默认值 false;application-prod.yml 显式 test-mode: false。
  2. 启动守卫改为白名单式:仅当 spring.profiles.active ∈ {dev,local,test} 且 testMode=true 时才允许启动;其它 profile(含 prod / 未设置 profile / 拼写错误)一律 fail-fast 抛异常。同时把 activeProfile 默认值从 dev 改为空并对空值 fail-fast。
  3. VerificationCodeService.verifyCode 的 123456 直通分支删除,或收紧为"仅当 wechat.test-mode=true 且 spring.profiles.active ∈ {dev,local,test}";建议直接删除并改由测试 profile 的 mock provider 提供。
  4. WechatService.getPhoneNumber 的 testMode 直出分支同样收紧到 dev/local/test。
  5. 修复 tools/check-test-mode.ps1:13-18 的扫描路径为 cfc-backend/config/,且找不到 prod 配置文件时必须 exit 1(当前 exit 0 是假通过根源);并把该脚本接入 CI 闸门。
  6. startup.sh:17 / start.sh:11 的默认 profile 改为 prod(或启动即校验 profile 合法性)。

④ 修改目的:让"测试开关"在生产结构性地不可能开启,堵住 C 端账号接管与支付绕过两条链路;同时让 CI 守卫从"假通过"变为"真闸门"。

⑤ 修改后能否做问题闭环:能。复测方式:① 启动期测试——以 SPRING_PROFILES_ACTIVE=prod + wechat.test-mode=true 启动,必须启动失败并打印明确错误;② 接口测试——/api/auth/phone-login 用任意手机号 + 123456 必须被拒;③ CI 闸门测试——在 config/ 下放一个含 test-mode: true 的 prod 文件,check-test-mode.ps1 必须 exit 1。通过标准:三条全绿,且 grep -rn "test-mode: true" cfc-backend/config cfc-backend/src/main/resources 仅出现在非 prod 文件。


P4 【阻断】明文密钥、微信支付商户私钥与生产配置进入 Git 仓库(含历史)

① 问题描述:数据库口令、JWT 签名密钥、微信 appsecret、微信支付 mch-key / api-v3-key、虚拟支付 appkey/token、Dify API Key、回调密钥、opencode 管理口令、京东云 AK/SK 全部以明文写在已跟踪的 application.yml 中;微信支付商户私钥 apiclient_key.pem 与证书、.p12、以及含 LLM Key 的 cfc-langgraph/.env 均被 Git 跟踪;cfc-backend/config/application-prod.yml 曾于 commit 389615495 进入历史且含明文口令与 test-mode: true。值本身不在本报告复述。

② 测试 bug 点:

  • cfc-backend/src/main/resources/application.yml:24、:49、:68、:89、:91-92、:100-101、:103、:116-117、:124、:150、:179-180
  • cfc-backend/src/main/resources/cert/apiclient_key.pem、apiclient_cert.pem、apiclient_cert.p12(git ls-files 全部命中;.gitignore 未覆盖 cert/)
  • cfc-langgraph/.env(git ls-files 命中;含 LLM_API_KEY / EMBEDDING_API_KEY / LANGCHAIN_API_KEY)
  • cfc-backend/config/application-prod.yml(git log --all --diff-filter=A → 389615495)

③ 最优修改方案:

  1. 轮换全部泄露凭据(这是第一步,不可延后):DB 口令、JWT 密钥(轮换后所有会话失效,属预期)、微信 appsecret、mch-key / api-v3-key、虚拟支付 appkey/token、京东云 AK/SK、Dify Key、opencode 口令、LLM Key;微信支付商户证书一并在微信商户平台重新生成并吊销旧证书。
  2. 代码库改造:application.yml 中所有敏感值改为无默认值的占位(password: ${DB_PASSWORD},无 : 默认值 → 缺失即启动失败),密钥统一走环境变量 / 配置中心 / KMS。
  3. .gitignore 增加 *.pem、*.p12、*.jks、cert/、.env(保留 .env.example)。
  4. 清理 Git 历史:git filter-repo --path cfc-backend/src/main/resources/cert --path cfc-langgraph/.env --invert-paths,并强制推送 + 通知所有协作者重新 clone;历史清理后仍需完成第 1 步轮换。
  5. 落地检测:gitleaks detect --redact(或 trufflehog)接入 CI pre-commit 与 pre-push,命中即阻断。
  6. 补一条"配置文件零明文密钥"的静态测试:扫描 src/main/resources 与 config/ 下 yml,禁止出现 password: <非 ${...} 占位>、secret: <非 ${...}>。

④ 修改目的:防止密钥泄露——让凭据只存在于受控的密钥管理系统与运行环境中,仓库历史不再持有任何可用凭据。

⑤ 修改后能否做问题闭环:能。复测方式:① gitleaks detect --no-git + gitleaks detect(含历史)均 0 命中;② git log --all --diff-filter=A -- cfc-backend/config/application-prod.yml 后历史已无该文件;③ 新增单测 NoPlaintextSecretTest 扫描全部 yml/env 断言无明文口令。通过标准:三条全绿 + 轮换记录留痕。


P5 【阻断】/api/stats/** 无鉴权 + /child/overview 无归属校验 → 匿名可读全平台经营数据、用户手机号与任意儿童私有数据

① 问题描述:统计 Controller 的 8 个端点全部没有角色校验(既无 @RequestAttribute("role") 也无 @PreAuthorize),且路径不在 LOGIN_REQUIRED_PATHS,因此匿名可调。其中 /api/stats/traffic 的用户停留时长榜把用户名回落到手机号;/api/stats/child/overview 只凭一个 memberId 就返回该成员的积分、活跃天数、任务完成率、游戏最高分与近 7 日积分曲线,完全没有校验该成员是否属于调用者家庭。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/controller/stats/StatsController.java:89-90、:122、:191、:225、:257、:420(无鉴权)
  • StatsController.java:669-676(用户名回落手机号)
  • StatsController.java:292-357(/child/overview,memberId 无归属校验;:299 familyMemberMapper.selectById(memberId)、:303-306 pointsLog、:315 taskService.getAllTasksByChild(memberId)、:322 gameRecordService.listByMemberId(memberId))
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java:85-101(/api/stats 不在 LOGIN_REQUIRED)

③ 最优修改方案:

  1. /api/stats/dashboard|overview|revenue|membership|trend|traffic 全部加 @PreAuthorize("hasAnyRole('admin','operator','finance')")(或复用 AdminRoles.hasAnyPlatformRole(roles)),并把 /api/stats/** 加入 LOGIN_REQUIRED_PATHS。
  2. /child/overview 必须补归属校验:引入 FamilyMemberService.isCallerInSameFamily(memberId, userId)(该方法已存在于 FamilyMemberService.java:1431-1444)或 FamilyMemberService.getMemberLogs 同款 user.getFamilyId() 比对,不通过返回 403;memberId 为空时直接 400。
  3. 响应体脱敏:userDwellList 的 userName 禁止回落到手机号,统一用脱敏昵称或 userId。
  4. 把 StatsController 的平台级查询(selectCount(null) 等)与 C 端家庭级查询(/family/summary、/points/ranking)拆成两个 Controller,避免权限模型混淆。

④ 修改目的:漏洞可见 → 不可利用:把"平台经营数据"与"家庭私有数据"各自收敛到正确的权限边界;杜绝 PII(手机号)与未成年人健康数据外泄。

⑤ 修改后能否做问题闭环:能。复测方式:新增 StatsAuthzTest:① 无 Token 调 6 个平台级端点 → 全部 401;② parent Token 调 → 403;③ admin Token → 200;④ parent A 用家庭 B 的 memberId 调 /child/overview → 403;⑤ 断言响应 JSON 中不出现手机号形态字符串(正则 1[3-9]\d{9})。通过标准:五条全绿。


P6 【严重】JWT 无吊销/刷新机制 + 30 天长有效期 + 密钥可预测 + token 可走 URL

① 问题描述:令牌有效期 30 天;没有 jti、没有黑名单、没有刷新令牌与吊销接口;用户改密码/退出登录/被踢出家庭后,已签发令牌依然有效;签名密钥在配置中是可预测明文;JwtInterceptor 还接受 ?token= 查询参数(令牌会进入 nginx access log、浏览器历史与 Referer 头)。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java:58-69(无 jti)、:79-91(无吊销查询)
  • cfc-backend/src/main/resources/application.yml:67-73(30 天)
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java:127(query token)、:123-128
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java:27-44(短密钥 SHA-256 扩展,无最小强度校验)

③ 最优修改方案:

  1. 令牌加入 jti(UUID),有效期降到 2 小时(30 天仅保留 refresh token)。
  2. 实现刷新令牌(独立签名、独立 TTL、存 Redis)与吊销:登出/改密/踢出家庭时把 jti 写入 Redis 黑名单(TTL = 剩余有效期);JwtInterceptor 校验时检查黑名单。
  3. 密钥强度校验:secret 长度 < 32 字节时启动失败(不再 SHA-256 兜底);密钥来自环境变量/密钥管理,轮换有版本号(kid)支持双密钥并存平滑切换。
  4. 移除 request.getParameter("token") 兜底,仅接受 Authorization: Bearer;若小程序确有需要,走专用 /api/auth/refresh 端点用 refresh token 换。
  5. 响应头 X-User-Id / X-User-Role(JwtInterceptor.java:168-169)对外回显用户身份,建议移除。

④ 修改目的:让会话可回收、密钥强度可度量、令牌不落 URL,降低令牌泄露后的爆炸半径。

⑤ 修改后能否做问题闭环:能。复测方式:新增 JwtLifecycleTest:① 签发 → 登出 → 携带旧令牌访问 → 401;② 改密后旧令牌 → 401;③ 无效 alg=none / alg=RS256 混淆令牌 → 401(parseClaimsJws 已具备,补回归锁定);④ ?token= 形式请求 → 401;⑤ 短密钥启动 → 启动失败。通过标准:五条全绿。


P7 【严重】无 CI 流水线、无 SAST/SCA/DAST;check-test-mode.ps1 守卫因路径写错而恒为假通过

① 问题描述:仓库不存在任何 CI 配置(.github/、.gitlab-ci.yml、Jenkinsfile、.circleci/、azure-pipelines.yml 全部缺失)。唯一的闸门是本地 githooks/pre-push,只跑 mvn compile(-DskipTests),需人工 git config core.hooksPath 才生效,且可用 XAF_CI_GATE=0 绕过。仓库内唯一的"安全脚本" tools/check-test-mode.ps1 扫描 cfc-backend/src/main/resources/ 下的 application-prod*.yml,但该目录根本没有 prod 配置(真实位置是 cfc-backend/config/),于是走 if ($prodYamlFiles.Count -eq 0) { "Skipping check"; exit 0 } —— 守卫永远返回成功。也就是说:安全检查在流程中"存在但无效"。

② 测试 bug 点:

  • 仓库根:无 .github/、无 .gitlab-ci.yml、无 Jenkinsfile
  • githooks/pre-push:1-20(仅 compile;XAF_CI_GATE=0 可跳过)
  • cfc-backend/tools/check-test-mode.ps1:13-18(扫描路径错误 + 缺文件即 exit 0)——注意脚本实际路径为 d:/cfc/tools/check-test-mode.ps1:13-18
  • cfc-backend/pom.xml:spring-boot-starter-parent 2.7.18(pom.xml:18)依赖 logback-classic(BOM 管理)

③ 最优修改方案:

  1. 建立 CI(GitHub Actions 或 Jenkins),最少三道闸门:
    • SAST:semgrep --config=p/java(或 p/secrets)→ 高危 阻断;
    • SAST-secrets:gitleaks detect --redact → 命中 阻断;
    • SCA:mvn org.owasp:dependency-check-maven:check + pip-audit -r cfc-langgraph/requirements.txt + npm audit --audit-level=high → 高危 阻断,中危告警。
  2. 把 tools/check-test-mode.ps1 扫描路径改为 cfc-backend/config/,且"找不到 prod 配置"时 exit 1;同款思路覆盖 wechat.test-mode、admin.skip-captcha、jwt.secret 三项。
  3. githooks/pre-commit 加"密钥与 test-mode 快速扫描",让本地也能兜住。
  4. pom.xml 升级至受支持的 Spring Boot 3.x(或至少 2.7.18→最新 2.7.x 补丁线并接受 EOL 风险登记);pdfbox 升至 2.0.35+,aws-java-sdk-s3 迁 v2。
  5. 锁文件纳入 CI 校验:npm ci 必须成功(禁止 npm install 漂移),pip install -r requirements_frozen.txt(该文件已存在)。

④ 修改目的:把安全测试真正接入研发流程(SAST/SCA/密钥扫描进 CI 且有阻断策略),让漏洞在合并前可见;同时修掉"假通过"的假闸门。

⑤ 修改后能否做问题闭环:能。复测方式:① 造一个含 test-mode: true 的 prod 配置提交,CI 必须红;② 造一个含 password: abc123 的 yml,gitleaks 必须红;③ 注入一个已知 CVE 的依赖,dependency-check 必须红;④ 故意让 mvn compile 失败,CI 必须红(证明闸门真的在跑)。通过标准:四个负向用例全部被拦截。


P8 【严重】文章富文本与 AI 报告内容无 HTML 净化即 v-html 渲染 → 存储型 XSS

① 问题描述:后端无 Jsoup / OWASP Java HTML Sanitizer / AntiSamy,前端无 DOMPurify。文章 content 原样入库后,管理端与教师端用 v-html 渲染;AI 生成的报告解读(内容源自用户上传的 PDF)同样 v-html 渲染。攻击者可在文章内容或 PDF 中植入 <img src=x onerror=...>,当管理员/规划师打开审核页或报告页时脚本以该用户域内权限执行——可进一步调用后台 API 提权(叠加 P1 缺乏鉴权则可直接造成数据篡改)。CSP 含 'unsafe-inline' 'unsafe-eval',无二次缓解。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java:104、:197
  • cfc-web/src/views/admin/review/ArticleAudit.vue:80 — v-html="currentRow.content"
  • cfc-web/src/views/admin/ProxyReportUpload.vue:59、:121
  • cfc-web/src/views/teacher/ProxyReport.vue:75、:158
  • cfc-web/src/views/admin/HealthPlanManage.vue:95 — v-html="renderPlanContent(currentPlan.planContent)"
  • cfc-backend/src/main/java/com/etotem/cfc/config/SecurityHeadersFilter.java:26-28
  • 无净化器证据:后端 pom.xml 无 jsoup/antisamy;cfc-web/package.json、cfc-frontend/package.json 无 dompurify

③ 最优修改方案:

  1. 后端入库前净化(唯一可信边界):引入 org.jsoup:jsoup,用 Jsoup.clean(html, Safelist.relaxed().removeTags("script","style","iframe","object","embed").addTags("img").addAttributes("img","src","alt","title")),在 AdminArticleController 保存/更新前统一过一道;同样处理 planContent 与 AI 报告字段。
  2. 前端 v-html 渲染前再过 DOMPurify(双保险),cfc-web 加 dompurify 依赖并在 renderPlanContent、报告渲染处接入。
  3. CSP 收紧:去掉 'unsafe-eval',script-src 移除 'unsafe-inline' 改用 nonce/hash;img-src 去掉 data: 或改用 blob:。
  4. AI 生成内容(aiAnalysis)视为不可信外部输入,落库前同样净化。
  5. 补回归测试:投毒样本入库 → 读出后断言 <script / onerror= 不存在。

④ 修改目的:消除存储型 XSS,防止恶意内容在管理员/规划师会话中执行并借权限完成横向移动。

⑤ 修改后能否做问题闭环:能。复测方式:新增 XssSanitizationTest(后端)与前端 Jest 用例——提交含 <script>alert(1)</script>、<img src=x onerror=alert(1)>、javascript: 链接的文章与报告,断言落库内容与渲染结果中不含可执行脚本;同时断言 CSP 头不含 unsafe-eval。通过标准:全部断言通过 + CSP 收紧项生效。


P9 【严重】上传文件校验薄弱(PDF 无魔数校验即解析、图片无服务端白名单、扩展名未防路径穿越)+ 上传栈依赖陈旧

① 问题描述:健康报告 PDF 上传只校验"文件名以 .pdf 结尾 或 Content-Type 为 application/pdf",二者是 OR 关系,且不校验文件魔数,随后立即交由 PDFBox 解析——把"类型可伪造的文件"直接送进解析器。图片分支仅依赖客户端提供的 Content-Type。LocalDiskStorageService 把客户端文件名后缀直接拼进落盘路径,未剥离路径分隔符。同时 pdfbox 2.0.31 属陈旧版本且正处攻击面。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java:820-831(/report/upload,:829 filename.endsWith(".pdf") OR contentType)、:832-833(直接 pdfParseService.parse)
  • HealthReportController.java:914-945(/report/upload-only,:939 isImageContent(contentType))
  • cfc-backend/src/main/java/com/etotem/cfc/service/impl/LocalDiskStorageService.java:66-75(扩展名取自 getOriginalFilename())、:97-105
  • cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java:36-47(仅按 contentType 白名单,未做魔数校验)
  • cfc-backend/pom.xml:171(pdfbox 2.0.31)

③ 最优修改方案:

  1. 魔数校验:读前 5 字节断言 %PDF-;图片用 ImageIO.read() 实际解码验证(解码失败即拒绝),不再信任 Content-Type。
  2. 改 OR 为 AND:既要扩展名合法,也要 Content-Type 合法,还要魔数合法,三者同时满足。
  3. 大小与页数限制:控制器层显式 size <= 10MB;PDFBox 解析前限制页数(如 <= 50)与解压后体积,防解压炸弹;解析在独立进程/线程池 + 超时中执行(当前 pdfParseService.parse 在请求线程内同步执行)。
  4. 文件名净化:LocalDiskStorageService 改用 Paths.get(originalFilename).getFileName().toString() 剥离目录成分,扩展名用白名单正则 ^[A-Za-z0-9]{1,8}$ 重写,落盘前 normalize() 后校验仍以 uploadPath 为前缀(防 .. 穿越)。
  5. 升级 pdfbox 至 2.0.35+;spring-boot-starter-parent 升级到受支持版本线(见 P7)。

④ 修改目的:把上传从"信任客户端声明"改为"服务端实证",消除恶意文件解析(DoS / 解析器漏洞)面与路径穿越。

⑤ 修改后能否做问题闭环:能。复测方式:新增 UploadSecurityTest:① 上传名为 x.pdf 但内容是 PNG → 拒绝;② 上传内容是 %PDF- 但名为 x.png → 拒绝;③ 上传含 ../ 的 multipart filename → 落盘路径必须在 upload.base-dir 内(断言 resolved.startsWith(base));④ 超过大小上限 → 413/拒绝。通过标准:四个负向用例全绿。


P10 【一般】限流与登录锁定可被伪造 IP 绕过;验证码/Token 明文入日志;管理员账号可枚举

① 问题描述:① 限流与登录锁定均直接采信客户端可控的 X-Forwarded-For / X-Real-IP,攻击者每次换一个假 IP 即获得全新限流桶,验证码防爆破与 admin 登录锁定形同虚设;② findRule 用 path.contains(key) 子串匹配且规则表为 ConcurrentHashMap,/api/auth/send-code 的"60 秒 1 次"规则可能被 /api/auth/ 的"1 秒 10 次"抢占,实际强度不确定;③ 验证码、微信 access_token、JWT 片段被明文写入日志;④ /api/admin-auth/account-roles 公开且按手机号返回角色列表,可批量枚举管理员账号;⑤ 登录密码兼容 MD5 哈希。

② 测试 bug 点:

  • cfc-backend/src/main/java/com/etotem/cfc/config/RateLimitInterceptor.java:174-186(getClientIp 采信 XFF)、:165-172(path.contains + 规则表顺序)、:52-60(规则定义)
  • cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java:374-383(getClientIP 采信 XFF)、:337-355(账号枚举)、:410-425(MD5 兼容)
  • cfc-backend/src/main/java/com/etotem/cfc/service/VerificationCodeService.java:67、:88、:112
  • cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java:364、:384
  • cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java:240-241

③ 最优修改方案:

  1. 可信代理链:只信任来自白名单反向代理的 X-Forwarded-For(取链尾第一跳),否则用 request.getRemoteAddr();或改用 Spring ForwardedHeaderFilter + server.forward-headers-strategy=native。
  2. 限流规则改为有序匹配(List<Rule> 按前缀长度倒序遍历,先匹配先生效),/api/auth/send-code 用 startsWith 精确前缀;同时把 /api/admin-auth/login 单独设为"每 IP+每手机号 10 分钟 5 次"。
  3. 日志脱敏:验证码、access_token、JWT 一律不落日志(或只落 hash 前 8 位);WechatService 打响应 JSON 前先剔除 access_token 字段。
  4. /api/admin-auth/account-roles 移除或加"同 IP 每日查询上限 + 失败不区分手机号是否存在"(统一返回空列表)。
  5. 存量 MD5 口令:登录成功后强制升级为 BCrypt(SecurityConfig.passwordEncoder() 已就绪),并安排全量重置。

④ 修改目的:让防爆破与限流真正可被依赖;防止日志成为凭据二次泄露源;阻断管理员账号枚举。

⑤ 修改后能否做问题闭环:能。复测方式:新增 RateLimitTrustBoundaryTest:① 携带伪造 X-Forwarded-For 连续请求 → 仍被同一真实 IP 桶限流;② /api/auth/send-code 连发 2 次 → 第二次 429(60s 规则生效);③ 断言日志文件中不出现 code=123456 / access_token / JWT 前 30 字符。通过标准:三条全绿。


未实测项(未授权未执行 / 未实测)

未授权未执行(review 档无联网/无攻击授权)

  1. 依赖漏洞在线扫描:未执行 npm audit / mvn org.owasp:dependency-check-maven:check / pip-audit / trivy fs / osv-scanner 等需联网更新漏洞库的命令 → 本报告 E 组所有"陈旧/高危"判断仅基于版本号与已知历史,未与当前漏洞库比对,不能等同于"这些版本一定存在可利用 CVE"。
  2. DAST 动态扫描:未对运行中服务(9082 后端)做任何主动扫描或攻击验证。
  3. 真实渗透测试:未执行越权/注入/文件上传的实弹验证;P1/P5/P9 的"匿名可达"结论由代码路径推导得出(拦截器放行逻辑 + Controller 无校验 + 路径不在 LOGIN_REQUIRED),未经运行时请求证实。
  4. SAST 工具扫描:未运行 semgrep / spotbugs / sonar 等(离线档未安装且未获授权);结论来自人工模式检索。
  5. 供应链来源可信度核查:未对 npm / PyPI / Maven Central 做镜像源与包完整性校验。
  6. 密钥有效性验证:未尝试使用任何已发现的密钥/口令/私钥发起认证请求,以避免对生产系统造成未授权访问;因此"凭据当前是否仍有效"未实测。

未实测(需主理人另行安排)

  1. CORS / Private Network Access 实际生效性:仅静态审阅 WebConfig.java:44-68 配置,未发实际跨域请求验证 Access-Control-Allow-Private-Network 是否真被浏览器接受。
  2. wechat.test-mode 生产实际取值:仓库内 application.yml:98 与 config/application-prod.yml:37 均为 true,但生产容器的环境变量覆盖与实际运行 profile 未实测(SPRING_PROFILES_ACTIVE 在部署侧的实际取值需运维确认)。
  3. 数据库与 Redis 的真实暴露面:192.168.16.251 / bianwoyou.mysql.rds.aliyuncs.com 的实际可达性与公网暴露程度未做网络层探测。
  4. 前端产物安全:未构建 cfc-web / cfc-frontend 产物做 bundle 级审计(source map 是否泄露、是否内置敏感配置)。
  5. AI 侧车 cfc-langgraph 的边界防护:cfc-langgraph/app/main.py 的 CORS、中间件与 API 鉴权仅做了粗查(未发现 CORSMiddleware/Depends 鉴权),未逐端点复核,也未验证其是否直接暴露公网(application.yml:141 base-url 生产指向 ai.etotem.com.cn)。
  6. 数据库迁移脚本的破坏性:DatabaseInitializer.runMigrations() 在启动期执行 DDL,其对生产库的影响属安全与稳定性交叉项,本次未实测。

附:给主理人的编排建议

  1. 本维度建议 Gate = FAIL。P1~P5 五项阻断级问题互相放大(P1 提供匿名可达面、P2/P3 提供令牌获取、P4 提供密钥、P5 提供数据规模),单修其一不足以闭环。
  2. 优先级建议:P2/P3(认证绕过,改动量最小、收益最大)→ P4(密钥轮换,需运维协同、周期最长,应立即启动)→ P1/P5(鉴权收敛,需系统性改造)→ P7(CI 闸门,防止回归)→ P6/P8/P9/P10。
  3. 与其他维度的交界:P1/P5 的改造会改动大量 Controller 契约,与「集成·接口级审查」(柯砚)可能产生接口签名变动冲突,建议由主理人拉通一次统一改造窗口;P7 的 CI 闸门与「系统·验收级审查」(白樾)的 E2E 资产盘点可共用同一流水线。