皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第三十七期 · 第 37 号 二〇二六年十一月 · 霜降之后

后端代码安全审查实录:15 个高频缺陷的原理、危害与修复

2026-10-24 125 阅读

背景:某管理系统后端(Spring Boot + 自研 JWT 认证 + MyBatis + PostgreSQL,多服务部署)。本文来自一次完整的代码安全审查复盘:第一节是完整检查清单(通过的、没通过的都列出),后面把每个缺陷的原理、危害、日常容易忽略的要素、预防手段和修复方案逐一展开,并附上代码实例。既是复盘,也是一份可复用的审查清单。 文中代码均已做过抽象处理,不含任何真实系统、组织与人名信息。


一、检查清单总览

审查覆盖以下 16 项。打 ✅ 的是检查后确认没问题的,打 ❌ 的是发现了问题的(编号对应后文详解)。通过项同样值得记录——它们是这次防线里没塌的部分,也是下次回归时必须复测的用例来源。

# 检查项 检查手段 结论
1 JWT 签发/校验、密钥管理 读签发与校验工具类、密钥配置 ✅ 签名校验完整,密钥长度有下限校验,密钥未硬编码
2 令牌撤销(登出/强制下线立即生效) 读过滤器 + 会话表 + 各服务实现类 ❌ 问题①:撤销校验只在一个服务生效
3 认证过滤器白名单匹配逻辑 逐行读匹配函数 + 用真实路径模拟 ❌ 问题②:/actuator 精确匹配挡死探活
4 SQL 注入 grep 全部 mapper XML 中的 ${} ✅ 全部 #{} 预编译,无拼接
5 RBAC 权限是否有服务端强制 全库 grep 权限注解 / AOP / 权限判断 ❌ 问题③:零命中,权限纯前端展示
6 越权访问(IDOR)逐接口核对 对每个 {id} 接口追数据范围校验 ❌ 问题④⑤⑥:详情 / 写接口 / 日志查询三处漏校验
7 敏感数据存储与展示 读加密工具类 + 详情字段装配 ✅ AES-GCM 加密 + 掩码返回(密钥熵问题见问题⑮)
8 公开接口滥用面 读认证白名单 + 公开写接口实现 ❌ 问题⑦:公开提交无频控且自动建账号
9 登录防爆破 读登录实现 + 失败处理逻辑 ❌ 问题⑧:无失败锁定/限流/验证码
10 统一错误信息(防账号枚举) 对比"账号不存在"与"密码错误"的响应 ✅ 两种失败统一提示,无枚举口子
11 事务边界 读 @Transactional / 显式事务用法 ❌ 问题⑨:事务方法内做 HTTP 远程调用
12 并发竞态与状态机 找所有状态字段的 UPDATE,查前置条件 ❌ 问题⑩:发布类接口存在 check-then-act 竞态
13 环境配置安全 逐个核对 ${XXX:默认值} 的默认值 ❌ 问题⑪:profile 默认 dev,生产漏配即激活 dev 逻辑
14 架构与文档一致性 拿架构约束文档逐条对照实现 ❌ 问题⑫:服务共享数据库,文档未同步登记
15 死代码与构建产物 对照父 pom 的 modules 与磁盘目录 ❌ 问题⑬:废弃模块目录残留(含旧 jar)
16 运行期资源管理 grep @Scheduled / 清理任务 / 索引 ❌ 问题⑭:会话表无清理、每请求点查一次

一张表看下来有个规律:✅ 的 4 项全部是"点"上的问题(单个函数、单条 SQL 写对了就行),❌ 的 12 项几乎全部是"线"和"面"上的问题——跨服务的机制一致性、跨接口的校验完整性、代码与文档的同步。安全缺陷很少坏在一个函数里,多坏在两个函数之间。


二、认证与会话类

问题① 令牌撤销只在一个服务生效

现象:系统用 JWT 做认证,同时配了会话表实现「登出 / 被顶下线 / 管理员强制下线后令牌立即失效」。机制是:过滤器解析完 token 后,通过 TokenSessionChecker 接口查一次会话表,确认这个 jti 还活着。但撤销校验的实现类只存在于其中一个服务,其他服务悄悄退化成了「纯无状态 JWT」。

原理:JWT 是无状态的——签名正确且未过期就有效,服务端无法主动让某个 token 失效。所以要加一层「有状态的撤销裁决」:签发时把 jti 写进会话表,每个请求再查一次表。问题出在 checker 是可选依赖:

// 反例:可选依赖静默降级——拿不到实现就跳过校验,没有任何日志
private boolean sessionAlive(String jti) {
    TokenSessionChecker checker = checkerProvider.getIfAvailable();
    if (checker == null) {
        return true;              // 撤销校验悄悄失效
    }
    return checker.isActive(jti);
}

ObjectProvider.getIfAvailable() 这种写法代码读起来完全正常,测试里配了 mock 也完全正常——只有部署形态变了(多服务、少了一个实现)才会暴露。

危害:用户登出、被顶下线、被管理员强制下线后,旧 token 在自然过期前(比如 2 小时)依然可以访问所有没有 checker 的服务。对安全审计来说,"强制下线"这个动作等于没做。

日常容易忽略的要素:

  • ObjectProvider / @Autowired(required=false) / getIfAvailable() 这类"可选依赖"模式,静默降级时无感知;
  • 安全机制在多服务部署时,默认要问一句:**这条机制在每个服务上都生效吗?**尤其是"跨切面"的机制(认证、审计、限流),最容易被做成"只在第一个实现它的服务里存在";
  • 测试通常只测单服务,跨服务的撤销一致性没有用例覆盖。

怎么避免:降级必须有痕、有界:

// 改进一:降级必须打日志(启动时探测一次即可,不必每请求都打)
@Component
public class SessionCheckerProbe implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        if (checkerProvider.getIfAvailable() == null) {
            log.warn("未配置 TokenSessionChecker,令牌撤销校验不生效,请确认部署形态");
        }
    }
}

// 改进二:更严格的场景直接 fail-close
private boolean sessionAlive(String jti) {
    TokenSessionChecker checker = checkerProvider.getIfAvailable();
    if (checker == null) {
        log.warn("TokenSessionChecker 缺失,按校验失败处理");
        return false;             // 宁可拒绝服务,不可放行已撤销的令牌
    }
    return checker.isActive(jti);
}

再把"撤销是否全局生效"写进测试红线:登出一个 token,逐一请求所有服务的受保护接口,都应 401。

出了以后怎么解决:给每个服务补齐 checker 实现;会话表放共享库或共享缓存(Redis)里,天然一致。如果会话校验走独立服务,要考虑它自己挂了怎么办(fail-close 还是 fail-open,提前写进设计文档);用缓存加速时要意识到缓存 TTL 就是撤销延迟的上限。


问题② /actuator 白名单精确匹配挡死健康检查

现象:白名单配置写的是 - /actuator,而过滤器对非通配项做精确匹配。健康检查的实际请求路径是 /actuator/health,永远匹配不上。

原理:白名单匹配通常要支持两种语义——精确匹配(/login)和前缀通配(/static/**)。实现时很容易只写两条分支,忘了 "/actuator" 这种"想要前缀但忘了写通配符"的中间态:

// 反例:只有"以 /** 结尾走前缀,其余走 equals"两条分支
private boolean isWhitelisted(String path) {
    for (String entry : whitelist) {
        if (entry.endsWith("/**")) {
            if (path.startsWith(entry.substring(0, entry.length() - 3))) {
                return true;
            }
        } else if (path.equals(entry)) {      // "/actuator" ≠ "/actuator/health"
            return true;
        }
    }
    return false;
}
# 修复:写白名单的人以为覆盖了,实际一个子路径都没命中
whitelist:
  - /actuator/**      # 而不是 /actuator
  - /api/v1/auth/login

危害:负载均衡 / 容器编排的探活请求全部 401,服务被判定不健康,重启循环或摘流。更隐蔽的变体:运维为了绕开它把 /actuator/** 全开——会把 env、beans 等敏感端点也暴露出去,等于用一个配置错误换另一个更大的。

日常容易忽略的要素:

  • "看起来配了" ≠ "生效了":白名单属于必须运行时验证的配置,code review 很难看出 /actuator 和 /actuator/** 的差别;
  • 探活请求不带业务 token,它的失败只出现在基础设施层(探针日志),应用日志里往往只有一条 401,容易淹没;
  • 类似的高频踩坑路径还有:/error(Spring Boot 错误页)、静态资源、文件下载链接、回调地址(支付回调、Webhook)。

怎么避免:

  • 白名单启动时自检:对每个配置项断言它要么精确对应一个 Controller 映射,要么以 /** 结尾;把"需要前缀语义的路径必须显式写通配符"定为规约;
  • 上线 checklist 固定一条:不带 token curl 一次 /actuator/health,期望 200;
  • 同时收紧端点暴露,两头都堵:
management:
  endpoints:
    web:
      exposure:
        include: health     # 只暴露探活需要的端点
  endpoint:
    health:
      show-details: never

出了以后怎么解决:白名单项加通配符(一行改动)+ 端点最小暴露;回归时把所有白名单路径逐个 curl 一遍(带 401 预期的、带 200 预期的分开列)。


问题⑧ 登录无防爆破

现象:登录接口正确地统一了"账号不存在"和"密码错误"的提示(防账号枚举,这个细节是对的),但对同一个账号(或同一个 IP)连续失败没有任何锁定、限流、验证码机制。

原理:BCrypt 这类慢哈希让单次猜测成本很高(几十毫秒),但接口级爆破的速度瓶颈不在哈希而在网络——不加限制时每秒几百次请求轻而易举。防爆破的本质是把攻击者的尝试速率压到"算完哈希也试不了几次"的水平。还有个反直觉的组合:统一提示防枚举 + 无锁定,攻击者虽然不知道账号是否存在,但可以对一批从泄露库里拿到的真实账号慢慢试密码——防枚举挡住了侦察,挡不住爆破本身。

危害:弱密码管理账号被撞库后直接接管系统;登录接口本身的请求量也是攻击面。

日常容易忽略的要素:

  • 只做"账号级锁定"会被攻击者换一批账号绕开(分布式 spray:每个账号只试 1-2 次),IP 维度限流要同时存在;
  • 锁定策略太狠(失败 3 次锁 1 天)会被反向利用成拒绝服务——别人可以故意锁死你的管理员账号,需要"锁定 + 解锁通道"配套;
  • 前端限制(按钮置灰、倒计时)只防正常用户,防不住脚本。

怎么避免:分层防护。Nginx 层(改动最小、见效最快):

limit_req_zone $binary_remote_addr zone=login_zone:10m rate=10r/m;

location /api/v1/auth/login {
    limit_req zone=login_zone burst=5 nodelay;
    proxy_pass http://backend;
}

应用层(账号维度失败计数,放 Redis 或库表,注意计数器本身要有过期策略):

String key = "login:fail:" + account;
long fails = redis.increment(key);
redis.expireIfNew(key, Duration.ofMinutes(15));

if (fails >= 5) {
    // 触发图形验证码或短暂延迟;不要直接抛"已锁定"——那会变成账号 DoS 的确认器
    captchaRequired = true;
}

管理后台可以再加 IP 白名单或 VPN 前置。上线 checklist 加一条:用错误密码连打 20 次,观察第几次开始被拦。

出了以后怎么解决:先上 Nginx 限流止血,再补应用内失败计数;已有泄露迹象时强制全量改密 + 按账号批量撤销全部会话(这就是问题①那套撤销机制的用武之地)。


问题⑪ Spring profile 默认值是 dev

现象:application.yml 里 spring.profiles.active: ${SPRING_PROFILES_ACTIVE:dev}——环境变量没设时默认走 dev。而 dev profile 下有:联调用的快捷登录接口(@Profile("dev") 才注册)、dev 专用的默认 JWT 密钥。

原理:默认值是兜底行为,定义了"配置缺失时系统变成什么"。dev 作为默认值意味着"忘记配置"这种最常见的故障模式被定向到最不安全的状态。更微妙的是 profile 同时控制行为(多出的接口)和密钥(默认值),双重退化叠加。

危害:生产部署漏设一个环境变量 → dev 专用接口上线 → 全系统使用代码仓库里可查的 dev 密钥签发 JWT——攻击者拿到仓库或反编译 jar 就能离线伪造任意管理员的 token。这是从"配置失误"到"完全接管"的直通车。

日常容易忽略的要素:

  • 部署清单核对时,人们盯的是"配了哪些变量",漏的是"没配的那个变量默认是什么";
  • @Profile("dev") 的接口在生产 404 这个设计是对的,但前提是生产真的不是 dev profile——所有"仅 dev 生效"的安全设计都悬在同一个默认值上;
  • dev 默认密钥写进代码仓库(哪怕注释里写了"仅本地"),扩散面就是"所有能看到仓库的人 + 所有拿到 jar 的人"。

怎么避免:

# 反例
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

# 改进:不设默认,缺失即启动失败(Spring 对 ${} 占位符缺失默认抛异常)
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE}

再加一道启动守卫,把"dev 只许本地起"变成机器检查而不是人的记忆:

@Component
public class ProfileGuard implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        boolean dev = Arrays.asList(env.getActiveProfiles()).contains("dev");
        boolean explicitAllowed = "true".equals(System.getenv("ALLOW_DEV_PROFILE"));
        if (dev && !explicitAllowed) {
            throw new IllegalStateException("禁止以 dev profile 启动,如为本地调试请设置 ALLOW_DEV_PROFILE=true");
        }
    }
}

密钥类配置永不给默认值;启动日志输出密钥指纹(前 8 位哈希),运维看启动日志即可确认不是 dev 密钥。

出了以后怎么解决:改掉默认值 + 部署脚本补环境变量;如果 dev 密钥曾被生产使用过,按密钥泄露处理——轮换 JWT secret(注意:轮换会让所有在线用户掉线,选低峰执行;有会话表校验机制时轮换可即时生效)。


三、授权与越权类

问题③ RBAC 权限完全没有服务端强制

现象:登录响应里已经把账号的权限码列表下发给了前端(用于控制菜单和按钮显隐),但全代码库检索 @RequirePermission、AOP 权限切面、任何形式的 permissions.contains(...) 判断——零命中。角色管理(建角色、删角色、配权限)、审核、发布、删除……任何持有有效 token 的账号都能直接调用。

原理:前端权限控制(隐藏菜单、禁用按钮)是展示层的礼仪,不是安全边界。攻击者根本不需要你的前端——一个 curl 加上自己的 token,就能直接打后端接口。权限模型必须满足"服务端在处理每个请求时,独立校验调用者是否被授权",前端控制只是体验优化。

危害:

  • 任意登录账号可以给自己配管理员权限(改角色-权限关系),完全接管系统;
  • 普通账号可以执行所有业务写操作,且这些操作都会以自己的身份留下审计记录——破坏是"合规"地发生的;
  • 对有等保、个人信息保护合规要求的系统,这属于整改项。

日常容易忽略的要素:

  • 权限码"已经下发到前端了"会造成"权限已经做了"的错觉——下发和校验是两件完全不同的事;
  • 迭代节奏里"权限点口径还没定"很容易变成"先不做校验",然后一直没做;默认放行的权限体系,欠的账只会越滚越大,因为每加一个接口都在往"无校验"的默认上堆;
  • "都是自己人"的内部系统心态——账号被盗、离职员工、第三方集成全都打破这个假设。

怎么避免:自研方案下用注解 + AOP,大约一百行代码就能把红线立起来:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    String value();
}

@Aspect
@Component
public class PermissionAspect {

    private final PermissionService permissionService;

    @Before("@annotation(requirePermission)")
    public void check(JoinPoint jp, RequirePermission requirePermission) {
        String userId = UserContext.requireUserId();          // 未认证直接抛 401
        Set<String> perms = permissionService.loadPermissions(userId);  // 可加 30~60s 本地缓存
        if (!perms.contains(requirePermission.value())) {
            log.warn("权限拒绝: user={} required={} uri={}",
                    userId, requirePermission.value(), request.getRequestURI());
            throw new BizException(ErrorCode.AUTH_NO_PERMISSION);
        }
    }
}

// 使用:没标注解的接口默认拒绝或至少默认告警(白名单思想)
@RequirePermission("role:manage")
@DeleteMapping("/roles/{id}")
public Result<Void> deleteRole(@PathVariable Long id) { ... }

配套两条:新接口合入前必须有权限注解,否则评审不过;用最低权限账号遍历所有写接口,断言全部被拒(测试红线)。

出了以后怎么解决:注解切面是增量改动,风险可控;关键是回填——把现存接口按权限码分类(读/写/管理),管理类优先补注解;补齐前可临时在网关层按路径前缀做粗粒度拦截止血;同时审计日志回查"是否已有越权操作发生"。


问题④ 详情接口 IDOR:列表有数据范围过滤,详情没有

现象:某业务对象的列表、统计、导出三个接口都正确地按"当前账号可见组织"过滤(visibleOrgIds),唯独 GET /{id} 详情不接收 userId、不做任何范围校验,直接按 ID 查询返回。

原理:这叫 IDOR(Insecure Direct Object Reference),属于水平越权——组织 A 的管理员访问组织 B 的数据。列表过滤 + 详情不过滤的组合极其常见:列表是"顺手"做了过滤(查询参数里自然有 scope),详情是按主键查,"顺手"不到,于是漏了。防御必须基于"拒绝"而非"隐藏"——前端不显示入口 ≠ 接口不存在。

// 反例:按主键直查,无数据范围
public DetailVO detail(Long id) {
    BizEntity e = mapper.selectById(id);
    if (e == null) throw new BizException(ErrorCode.NOT_FOUND);
    return assemble(e);
}

// 改进:统一入口约定——按 ID 取数的方法,第一个参数固定是 userId
public DetailVO detail(Long userId, Long id) {
    BizEntity e = mapper.selectById(id);
    if (e == null || !visibleOrgIds(userId).contains(e.getOrgId())) {
        // 故意与"不存在"同响应:返回 403 等于泄露资源存在性
        throw new BizException(ErrorCode.NOT_FOUND);
    }
    return assemble(e);
}

SQL 层也可以把范围做成硬条件:

<select id="selectVisibleById" resultType="...">

  SELECT * FROM biz_entity
  WHERE id = #{id}
    AND org_id IN
    <foreach collection="visibleOrgIds" item="oid" open="(" separator="," close=")">
      #{oid}
    </foreach>
</select>

危害:低权限账号可遍历自增 ID 拖库——详情里往往是完整档案信息(住址、单位、联系方式等),是核心数据资产的直接泄露路径。

日常容易忽略的要素:

  • 同一个资源的不同接口,数据范围校验要各自实现——不存在"列表做了详情就安全"的继承关系;
  • 自增主键让遍历成本为零(/1 到 /10000),UUID 只能提高遍历成本,不能替代鉴权;
  • 测试通常用管理员账号测详情接口,一切正常——必须用受限账号测才能暴露;
  • 回归时新增接口往往复制现有代码,一个"忘记带 scope"的模板一旦产生就会被复制 N 次。

怎么避免:把数据范围校验做成模板方法或统一入口(如上面的 requireVisible 约定),这个模式在 code review 里一眼可见、缺了就问;MockMvc 层建一个"越权矩阵"用例集(账号 × 接口 × 资源归属),新接口必须进矩阵。

出了以后怎么解决:给 detail 补上与列表相同的范围校验,范围外按"不存在"返回;顺手排查同服务所有 {id} 接口,用同一把尺子;已发生的访问可以从网关日志 + 审计表回查异常 ID 遍历模式。


问题⑤ 写操作接口缺失鉴权上下文

现象:某个状态流转后的落档写接口(POST /xxx/provision/{applyId})的 Controller 和 Service 全链路没有任何 userId 参与——没有范围校验,也没有权限校验。

原理:写接口的危险度高于读接口,却更容易漏校验——因为写接口常被设计成"服务间内部调用",设计者默认"只有内部会调",但 URL 是平的,认证过滤器之后人人平等。接口不会因为你打算内部用就自动变内部。

// 反例:写接口全链路没有用户上下文,与前端接口同路径空间
@PostMapping("/resources/provision/{applyId}")
public Result<Void> provision(@PathVariable Long applyId) {
    service.provision(applyId);      // 谁调的?有权调吗?——答案都是"不知道"
    return Result.ok();
}

// 改进:userId 进签名 + 数据范围校验,review 时缺了直接打回
@PostMapping("/resources/provision/{applyId}")
public Result<Void> provision(@RequestAttribute Long userId,
                              @PathVariable Long applyId) {
    service.requireVisible(userId, applyId);
    service.provision(userId, applyId);
    return Result.ok();
}

真正的内部接口要单独的机制保护,而不是单独的意图:

# /internal/** 独立白名单 + 网络层限制(仅内网可达,不进对外网关路由)
whitelist:
  - /internal/**      # 且网关层不转发 /internal/**,双保险

危害:任意账号可对任意申请触发落档,绕过审核流程直接制造业务数据;与问题③叠加时,等于状态机可以被任意跳过。

日常容易忽略的要素:

  • "这个接口是给其他服务调的"是一种意图,不是机制——除非有独立内网、内部认证(服务间 mTLS / 内网白名单 / 独立 token),否则它就是公网可达的;
  • 服务间调用和前端调用的鉴权强度必须显式区分,混在同一路径空间里就会漏。

怎么避免:约定"写接口一律要求 userId 进方法签名(哪怕暂时不用)";内部接口单独路径前缀 + 独立认证;状态迁移类写操作尽量收敛到业务动作接口上(如 /{id}/review 内部完成落档),不单独暴露"步骤接口"。

出了以后怎么解决:先补数据范围校验,再评估是否把该接口收编进业务动作接口内部调用、对外下线。


问题⑥ 变更日志查询无数据范围

现象:业务对象的变更日志列表/详情接口按关键词全库搜索,没有按账号可见组织过滤。

原理:同问题④,但有个特别容易忽略的点——日志/审计类数据的归属感更弱。开发者会认真给"业务主数据"做范围过滤,因为主数据"明显属于某个组织";而日志按操作流水建模,脑子里是"全局流水",忘了每条日志背后都关联着具体组织的具体对象。审计数据往往还包含变更前后值(敏感字段的旧值、新值),泄露面比业务列表更大。

<!-- 反例:关键词全局搜索,无 scope 条件 -->
<select id="searchLogs" resultType="...">
  SELECT * FROM biz_change_log
  WHERE content ILIKE '%' || #{keyword} || '%'
</select>

<!-- 改进:强制带上数据范围,让范围过滤有落点 -->
<select id="searchLogs" resultType="...">
  SELECT * FROM biz_change_log
  WHERE org_id IN
  <foreach collection="visibleOrgIds" item="oid" open="(" separator="," close=")">
    #{oid}
  </foreach>
  AND content ILIKE '%' || #{keyword} || '%'
</select>

危害:受限账号可搜索全系统所有组织的变更历史,包括其他组织的人事变动轨迹、被驳回的修改内容等。

日常容易忽略的要素:

  • 每新增一类"派生数据"(日志、快照、统计、导出),都要重新回答一次"它的数据范围是什么"——派生数据的 scope 不会自动从主数据继承;
  • 关键词搜索接口(?keyword=)是越权排查的重灾区,因为它天然绕过"按组织浏览"的路径,直接命中任意数据。

怎么避免:审查清单里固定一条:每遇到一个 keyword / 全局搜索接口,检查它的 WHERE 里有没有 scope 条件;日志表设计时带上 org_id 冗余列(或强制 join 主表过滤)。

出了以后怎么解决:列表 SQL 加范围过滤、详情校验归属后按不存在返回;已发生的访问按日志回查。


四、公开接口与滥用类

问题⑦ 公开提交接口无频控、无防重、自动建账号

现象:面向小程序/外部渠道的提交接口在认证白名单里公开(外部端无登录令牌,公开本身合理),接口对手机号、姓名做了格式校验,敏感字段加密落库——但没有任何速率限制、验证码、同号码重复提交限制,而且对新号码自动创建业务账号。

原理:公开写接口 = 互联网上任何脚本都可以无限调用。没有频控意味着资源(数据库行、序列号、存储)可以被无成本消耗;自动建账号则把"刷单"升级成"刷账号库"——攻击者用一万个号码提交,就有一万个账号落库,污染主数据且为后续攻击(撞库、冒名)准备了原料。

// 应用内频控示例(Bucket4j;更简单可用 Guava RateLimiter / Redis 计数)
Bucket bucket = Bucket.builder()
    .addLimit(Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(10))))
    .build();

public Result<Void> submit(SubmitReq req) {
    // 1. 频控:按 IP + 按手机号各一道
    if (!bucketFor(clientIp).tryConsume(1) || !bucketFor(req.getPhone()).tryConsume(1)) {
        throw new BizException(ErrorCode.TOO_MANY_REQUESTS);
    }
    // 2. 幂等防重:同号码存在进行中的申请则拒绝
    boolean exists = applyMapper.existsByPhoneAndStatus(
            req.getPhone(), List.of("PENDING", "REVIEWING"));
    if (exists) {
        throw new BizException(ErrorCode.DUPLICATE_APPLY);
    }
    // 3. 账号创建动作后置:先落申请单(打"预创建"标记),审核通过再建账号
    applyMapper.insert(toApply(req));
    return Result.ok();
}
# 网关层兜底
limit_req_zone $binary_remote_addr zone=public_submit:10m rate=5r/m;
location /api/v1/public/submit {
    limit_req zone=public_submit burst=3 nodelay;
    proxy_pass http://backend;
}

危害:

  • 资源型:申请单、账号、扩展表被灌满垃圾数据,真实数据被淹没,审核工作量被拖垮;
  • 数据型:主数据污染后,清洗成本远高于拦截成本;
  • 合规型:若被用于冒名提交他人信息,涉及个人信息处理的合规问题。

日常容易忽略的要素:

  • "外部端有签名 / 有 Referer"不构成防刷——抓包重放是入门级操作;
  • 格式校验(11 位手机号)只挡住格式错误,挡不住一万个格式正确的假号码;
  • 过渡方案(如"验证码通道没接通前先上线")很容易长期裸奔在"先上线再补"里——过渡方案要有到期时间。

怎么避免:公开写接口的最小防护三件套——频控(按 IP + 按号码)、人机验证(短信/图形验证码)、幂等防重(同号码 + 进行中状态则拒绝);自动建账号的动作尽量后置到审核通过,减小被刷的不可逆影响面;给公开接口设置提交量突增告警。

出了以后怎么解决:按三件套补齐;已灌入的垃圾数据按提交特征(IP、时间窗口、号码段)识别后软删;账号库做一次标记清洗;发生冒名提交时按合规流程评估告知义务。


五、事务与并发类

问题⑨ 事务方法里做 HTTP 远程调用

现象:某发布流程的实现是 @Transactional 方法:先落库主表,再 HTTP 调用账号服务解析关联人列表,再批量写关联表,最后更新状态。

原理:@Transactional 从方法入口开启事务、拿到数据库连接,直到方法返回才提交。中间插入的任何耗时操作——HTTP 调用、发短信、写文件、MQ 同步等待——都被裹在事务里,连接被白白占住。连接池(Hikari 默认 10 个)是全服务共享的,几个慢请求就能把池子占满,后续所有请求排队等连接后超时报错,表现为"服务整体假死"。

// 反例:整个方法在事务里,HTTP 调用也裹进去了
@Transactional
public void publish(Long id) {
    Message msg = messageMapper.selectById(id);
    messageMapper.insert(msg);                                  // 已拿到连接
    List<Long> recipients = authClient.resolveRecipients(msg);  // HTTP:连接被占住
    recipientMapper.batchInsert(recipients);
    messageMapper.updateStatus(id, "PUBLISHED");
}

// 改进:先解析(无事务),后落库(显式事务精确圈定必须原子的几步)
public void publish(Long id) {
    Message msg = messageMapper.selectById(id);
    List<Long> recipients = authClient.resolveRecipients(msg);  // 事务外

    transactionTemplate.executeWithoutResult(tx -> {
        messageMapper.updateStatus(id, "PUBLISHED");
        recipientMapper.batchInsert(recipients);
    });
}

危害:

  • 连接池耗尽 → 全服务不可用,而且故障面远超出这一个接口;
  • 远程调用失败导致整个事务回滚,把"已经成功的本地写"也吞掉,语义上未必是想要的;
  • 事务持有时间长还会拉高死锁、锁等待的概率(行锁在事务期间一直持有)。

日常容易忽略的要素:

  • 事务注解的作用范围是整个方法,不是"包住数据库操作的几行"——写代码时注意力在业务步骤上,意识不到远程调用也被划进去了;
  • 正常联调时远程服务都是毫秒级响应,问题只在下游抖动时爆发;压测若不模拟下游慢响应(延迟注入)测不出来;
  • 嵌套调用更隐蔽:A 服务的 @Transactional 方法里调 B 的接口,B 又回调 A——review 单个方法看不出来。

怎么避免:定一条硬规约——事务方法里只允许数据库操作,远程调用/IO 一律移到事务边界之外;确实无法拆开的长流程,考虑本地消息表 + 异步补偿,而不是把同步链路塞进事务。

出了以后怎么解决:重构为"先解析、后落库"两段式;同时给 Hikari 加 leak-detection-threshold(如 30 秒)、事务加 @Transactional(timeout=...) 兜底,让同类问题在爆发前以 WARN 日志暴露。


问题⑩ check-then-act 并发竞态:一次点击,两批数据

现象:发布流程是:查出单据 → 判断状态是不是 DRAFT → 解析关联人 → UPDATE 状态为 PUBLISHED → 批量插入。两个请求并发通过"是不是 DRAFT"的检查后,都会执行 UPDATE 和插入——数据写两遍。

原理:教科书级的 TOCTOU(Time-of-check to time-of-use):检查和动作之间没有原子性。两个事务都能读到 status='DRAFT'(READ COMMITTED 下互不阻塞),然后先后提交。触发条件极其日常:双击按钮、前端超时重试、开两个标签页。

// 反例:查出来在内存里判断,再无条件 UPDATE——两个并发请求都能通过检查
public void publish(Long id) {
    Message m = messageMapper.selectById(id);
    if (!"DRAFT".equals(m.getStatus())) {
        throw new BizException(ErrorCode.WRONG_STATE);
    }
    // ... 此处与另一个并发请求互不感知
    messageMapper.updateStatus(id, "PUBLISHED");
    recipientMapper.batchInsert(resolve(m));   // 可能执行两次
}

// 改进:条件 UPDATE 做守卫,检查影响行数——成本最低、最可靠的方案
public void publish(Long id) {
    int updated = messageMapper.casPublish(id, "DRAFT", "PUBLISHED");
    if (updated == 0) {
        log.info("状态已被并发请求变更,按幂等成功返回, id={}", id);
        return;
    }
    recipientMapper.batchInsert(resolve(id));  // 只有抢到状态变更的请求会走到这
}
<update id="casPublish">
  UPDATE biz_message
  SET status = #{to}, published_at = now()
  WHERE id = #{id} AND status = #{from}
</update>

危害:轻则重复数据(关联记录重复、已读状态错乱),重则状态机被打穿(一笔申请被两个审核人各审一次、余额被扣两次)。而且它只在并发下出现,测试环境几乎无法复现,往往是上线后才零星出现"莫名其妙的重复数据"。

日常容易忽略的要素:

  • Service 层的"先查再改"在单线程语义下完全正确,review 时天然通过;
  • 前端按钮防重(disabled)只防得住手速,防不住重试逻辑和网络层重发;
  • 人们习惯性认为"加了 @Transactional 就没问题"——事务保证原子性和部分隔离性,不消除两个并发事务都通过同一检查。

怎么避免:状态流转一律用条件 UPDATE 守卫;或者对状态机迁移加排他锁(SELECT ... FOR UPDATE,但要意识到它拉长锁持有时间);写操作接口在架构上默认"可重入、可并发",把幂等当设计前提而不是补丁。

出了以后怎么解决:把所有裸 UPDATE 状态流转改成条件 UPDATE 并检查影响行数;排查一遍所有状态机字段(申请状态、审核状态、订单状态)逐一补守卫;已被打脏的数据按 created_at + 操作日志回溯清理。


六、工程与架构类

问题⑫ 服务共享数据库:架构与文档的双向漂移

现象:系统从多服务演进为两个服务,但两个服务连的是同一个数据库,其中一个也不再是"无库纯转发"的 BFF,而是内置了账号域。而架构文档依然写着"服务间通过 REST API 调用,不共享数据库"。

原理:共享数据库的微服务本质上是分布式单体(distributed monolith):部署上是两个进程,数据上是一个库。它有真实的好处(会话撤销校验就是靠共享会话表实现的,省掉了服务间回环调用),也有真实的代价:事务边界跨进程不可见(两个服务各开事务写同一批表,没有统一的补偿机制)、表结构变更影响面跨服务、服务无法独立选型和扩容。

危害不在运行时,而在漂移上——架构决策和文档各说各话之后:新成员按文档理解系统会做出错误设计;评审失去基准("文档说不共享库""实际早共享了,那文档还有什么用");下次演进时没人说清现状的全貌。

日常容易忽略的要素:

  • 架构演进常常是"逐步发生、某天回头看已经变了",缺少一个"架构决策记录(ADR)"的轻量机制来登记每次转向;
  • "文档过期"和"代码偏离文档"在体感上一样,但治理方式完全不同——先判断哪边是对的,再决定改哪边;
  • 共享库不等于不能用,但它要求配套纪律(表按域分 schema / 明确表所有权 / 禁止跨域 join),这些纪律没有自动检查手段时很快失守。

怎么避免:轻量 ADR 一页纸(背景/决策/后果)放仓库,决策变更时强制回写文档;数据库层明确表所有权:

CREATE SCHEMA account;   -- 门户/账号服务所有
CREATE SCHEMA biz;       -- 业务服务所有

-- code review 检查各服务 mapper 的表归属:门户只许读写 account.*,业务只许读写 biz.*

把"会话校验依赖共享库"这类跨服务的数据依赖显式写进架构文档。

出了以后怎么解决:多数情况下正确解法不是拆库(收益配不上成本),而是改文档承认现状:"两服务同库部署,按域划分表所有权;未来拆库的触发条件是 XXX"。若未来真要拆,先从"读路径去交叉依赖"开始(把直读对方表的地方改成 REST 调用),写路径最后拆。


问题⑬ 废弃模块目录残留

现象:仓库里若干废弃模块目录还在磁盘上,已不在父 pom 的 <modules> 里,但各自的旧配置、旧源码、甚至 target/ 里构建好的旧 jar 都在。

原理:这是架构合并的"考古层"。风险有三:IDE 里它们仍被索引,全文搜索/重构可能误伤旧代码或制造"改了两份"的错觉;target/ 里的旧 jar 若被部署脚本按通配符捞走,废弃版本会被部署上线;旧配置里的连接串、密钥注释会误导排障。

# 反例:按通配符捞 jar——哪个目录多出一个 jar 都会被部署
cp */target/*.jar /data/app/

# 改进:显式指定产物路径,多一个字节都不上
cp app-service/target/app-service-1.4.2.jar /data/app/app-service.jar

危害:主要是误导性——误构建、误部署、误排障,每次花费不大但频繁;偶发的是部署事故(旧 jar 上线)。

日常容易忽略的要素:删除模块时人们习惯删 pom 引用就完事,磁盘目录"先放着吧";.gitignore 通常忽略 target,导致git 里看不到但磁盘上一直有的构建产物。

怎么避免:架构合并/删除模块的 checklist 加最后一步"确认目录物理删除或移入 archive/ 并在 README 说明";CI/部署脚本禁止通配符捞产物。

出了以后怎么解决:直接删除(git 历史里都有,无需手软);旧产物下线时同步清理对应的 systemd unit / 容器编排条目。


问题⑭ 会话表无清理、每请求一次点查

现象:每个认证请求都要查一次会话表(token_id 点查),而这张表没有任何清理机制——过期会话、被顶下线的会话永久累积。

原理:这是问题①方案的伴生成本。撤销校验把无状态 JWT 变成了有状态,代价是每个请求多一次数据库往返,且表只增不减。点查本身有索引时很快,但表无限增长后:索引变深、缓冲池命中率下降、统计信息失真,最终拖慢所有请求;同时它是全系统请求路径上的单点——会话库一抖,所有服务所有接口一起抖。

危害:慢性性能退化 + 可用性单点。日常没有明显症状,数据量上去后逐渐显形(假设日活一千、每人每天登录三次,两年就是两百万行)。

日常容易忽略的要素:

  • "为安全加的表"往往没有从第一天起规划生命周期——业务表有需求驱动清理,安全设施表没人认领;
  • 每请求查库的方案在 review 时看起来干净正确,成本藏在运行时;
  • 顺带要确认 token_id 上有唯一索引——撤销方案依赖它做点查,没有索引就是全表扫描。

怎么避免:建表时同步设计索引与清理策略:

-- 撤销点查依赖的唯一索引(没有它就是全表扫描)
CREATE UNIQUE INDEX uk_login_session_token ON tb_login_session (token_id);

CREATE INDEX idx_login_session_expires ON tb_login_session (expires_at);
@Scheduled(cron = "0 30 3 * * ?")
public void purgeExpiredSessions() {
    // 保留 30 天供审计,过期即清理
    int deleted = sessionMapper.deleteExpired(
            Instant.now().minus(30, ChronoUnit.DAYS));
    log.info("过期会话清理完成, deleted={}", deleted);
}

中期优化——会话有效性加短 TTL 本地缓存,把撤销延迟控制在可接受范围的同时削掉绝大部分点查:

LoadingCache<String, Boolean> sessionCache = Caffeine.newBuilder()
    .expireAfterWrite(Duration.ofSeconds(30))
    .maximumSize(100_000)
    .build(jti -> sessionMapper.existsActive(jti));

撤销的及时性要求本来就是秒级而非毫秒级,30 秒缓存延迟在绝大多数业务里可接受。监控加两条:会话表行数、点查 P99 耗时。

出了以后怎么解决:先补索引与清理任务(立刻见效),再评估上缓存;如果点查已成瓶颈而业务不需要秒级撤销,可以把撤销粒度从"每请求校验"降为"每分钟批量失效列表同步"。


问题⑮ 附带:密钥熵与文档口径

加密密钥熵:某加密工具要求密钥为 16 字节字符串。16 字节本身够 AES-128 用,但"16 个字符的字符串"如果是人拍脑袋定的(如 myapp2026secret),实际熵远低于 128 位。避免:

openssl rand -hex 16    # 生成 32 个十六进制字符,恰好 16 字节高熵密钥
// 启动日志输出密钥指纹(不是密钥本身),运维核对即可
log.info("敏感字段密钥指纹: {}****",
        DigestUtils.md5DigestAsHex(key.getBytes()).substring(0, 8));

出了问题(密钥疑似泄露)按轮换流程处理——注意字段加密密钥轮换比 JWT 轮换复杂:存量密文要用旧密钥解、新密钥重加密,需要写迁移任务并保留多版本前缀的解密能力(如 v1:/v2: 前缀,好的密文格式设计会提前留这个口子):

int sep = ciphertext.indexOf(':');
String version = ciphertext.substring(0, sep);
byte[] plain = switch (version) {
    case "v1" -> cipherV1.decrypt(ciphertext.substring(sep + 1));
    case "v2" -> cipherV2.decrypt(ciphertext.substring(sep + 1));
    default -> throw new IllegalStateException("未知密文版本: " + version);
};

文档口径漂移:架构文档说"接口始终 HTTP 200 + 业务码",认证过滤器却按设计返回 401——代码是对的,文档没衔接。这类"两条都对但互相矛盾"的条款最迷惑新人。避免:文档变更时全文检索相关条款,口径不一致时当场补一句例外说明;拖久了文档公信力归零。


七、方法论:这种审查是怎么做的

把过程抽象出来,供下次复用:

  1. 先架构后细节:读父 pom(真实模块清单)→ 部署形态(几个服务、是否共享库)→ 认证链路(过滤器 → 会话 → 用户上下文)。安全问题的 80% 藏在"跨服务/跨模块的边界"上,单文件 review 看不见。
  2. 认证链路逐跳跟踪:从 Authorization 头进来到 UserContext 落地,每一跳问三个问题:谁校验的?校验失败会怎样?这个机制在每个服务上都生效吗?
  3. 接口清单 × 三个矩阵:把所有接口列出来,逐一核对——
    • 鉴权矩阵:谁校验 token?(白名单放行的理由是否成立)
    • 权限矩阵:写接口谁校验权限码?(grep 权限注解,零命中即红线)
    • 范围矩阵:按 ID / 按关键词的接口,数据范围在哪一层过滤?(列表有 ≠ 详情有)
  4. 注入类靠 grep 兜底:mapper XML 搜 ${}、Statement、Runtime.exec、ObjectInputStream;这类问题机器能查,不占人工时间。
  5. 状态机专项:找所有 status 字段的 UPDATE,检查 WHERE 里有没有前置状态条件(竞态防线)。
  6. 事务边界专项:grep @Transactional 逐个看方法体里有没有 HTTP / IO;写操作检查幂等性。
  7. 配置安全专项:所有 ${XXX:默认值} 的默认值逐个过——密钥类不允许有默认值;profile 默认值指向生产或缺失即失败。
  8. 文档对照:拿架构约束文档逐条核对实现,差异双向登记(代码错的改代码,文档过期的改文档)。
  9. 输出分级:P0(可被外部利用/上线红线)→ P1(上线前必办)→ P2(工程健康度),每条给修复方案和影响文件清单,先确认再动手。

另外记一条清单使用纪律:✅ 项和 ❌ 项同等重要。✅ 项是回归用例的来源(下次审查必须复测),❌ 项是整改跟踪的对象(修完必须回填结论),一份只记问题的清单会让你重复踩已经绕过的坑。


八、修复优先级建议

优先级 项 理由
P0 问题③ RBAC 服务端强制 越权面最大,且放大所有其他问题
P0 问题④ 详情 IDOR 个人信息/核心数据直接泄露路径
P1 问题⑤ 写接口无上下文、⑥ 日志无范围 同类越权,改动小
P1 问题⑦ 公开接口防刷、⑧ 登录防爆破 上线前必办
P1 问题⑪ profile 默认值 一行改动,消除生产误配置的直通车
P2 问题⑫ 文档同步、⑬ 死代码清理、⑭ 会话表治理、⑮ 密钥约定 工程健康度,按节奏

分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论