主题
安全拦截链
两层完全不同的机制常被混为一谈:Spring MVC 的
HandlerInterceptor拦所有/api/**(License、埋点),而「名单拦截链」只在check请求内部执行,get请求根本不经过它。
1. 两层结构对照
text
HTTP 请求
│
▼ ① Spring MVC 拦截器层(web.xml → captcha-server-servlet.xml:21-33,仅 /api/**)
├─ LicenceCheckInterceptor.preHandle ── 无有效证书 → 111 LICENCE_ERROR(JSONP)
├─ PrometheusMetricsInterceptor ── 只埋点,不阻断
▼
② Controller → validate() 参数解析(此层已有 100 PARAM_ERROR 拦截)
▼
③ check 专有:名单拦截链(CheckCaptchaService:353 内部调用)
├─ IpListInterceptorServiceImpl ── 命中 → 418 + hitBlacklist=2
├─ UserListInterceptorServiceImpl ── 命中 → 418 + hitBlacklist=3
▼
④ 类型校验(答案比对)→ 上下文规则链 → 高频命中写名单| 组件 | 类型 | 生效范围 | 数据来源 | 命中输出 | 代码 |
|---|---|---|---|---|---|
LicenceCheckInterceptor | HandlerInterceptorAdapter | 所有 /api/** | DB nc_licence(RSA 解密后缓存内存) | ErrorCode.LICENCE_ERROR(111) | captcha-server/src/main/java/com/netease/is/nc/server/common/interceptor/LicenceCheckInterceptor.java:26 |
PrometheusMetricsInterceptor | HandlerInterceptorAdapter | 所有 /api/** | 无 | 不阻断 | captcha-server/src/main/java/com/netease/is/nc/server/prometheus/PrometheusMetricsInterceptor.java:16 |
InterceptorServiceFactory | 业务责任链执行器 | 仅 check | Spring 注入的全部 InterceptorService | 短路返回 true | captcha-server/src/main/java/com/netease/is/nc/server/security/interceptor/InterceptorServiceFactory.java:75 |
| 指纹黑名单 | 语句块(非拦截器) | 仅 check,且在拦截链之前 | Redis captcha:fingerPrint:* | 413 HF_FAILED + hitBlacklist=1 | captcha-server/src/main/java/com/netease/is/nc/server/service/CheckCaptchaService.java:174 |
这是第一个安全语义断层:get 与 getconf 不做任何名单判断。被列入黑名单的来源仍可无限取图,只是永远交不了卷。
2. LicenceCheckInterceptor 详解
java
// captcha-server/.../common/interceptor/LicenceCheckInterceptor.java:26
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
boolean haveValid = licenceService.queryHaveLicence();
if (!haveValid) {
ApiResponse<?> error = ApiResponse.error(ErrorCode.LICENCE_ERROR, "no valid licence");
writeJsonpResponse(response, "", error); // callback 传空串 → 输出 "({...});"
return false;
}
return true;
}| 环节 | 实现 | 位置 |
|---|---|---|
| 数据表 | nc_licence,queryAll 不带 status 过滤,全表读 | captcha-base/src/main/java/com/netease/is/nc/base/licence/Licence.xml:108 |
| 解密算法 | RSA/ECB/PKCS1Padding,密文是 hex,公钥硬编码在类内 | captcha-base/src/main/java/com/netease/is/nc/base/utils/LicenceHelper.java:26、:53 |
| 判定字段 | JSON e = 过期毫秒时间戳;p = 产品号(validate 用,本拦截器只看 e) | LicenceHelper.java:39、LicenceServiceImpl.java:123 |
| 失效回写 | 已过期 → update nc_licence set status = 2 where licence_content = ? | Licence.xml:112、LicenceServiceImpl.java:98 |
| 刷新机制 | 单线程 scheduleAtFixedRate,初始延迟 1s、周期 5000ms,每次全量重建 licenceMap | captcha-base/src/main/java/com/netease/is/nc/base/licence/LicenceServiceImpl.java:75-90 |
| 放行条件 | licenceMap 中任意一条 e > now 即放行 | LicenceServiceImpl.java:120 |
注意加密材料与配置的关系:拦截器侧用的是 LicenceHelper.PUBLIC_KEY 常量,不是 base.properties 的 license.publicKey(本仓库该键默认为空,captcha-base/src/main/resources/base.properties:62)。那条配置只服务于后台上传导入时的解密,二者不是同一把钥匙。
第二个语义断层:queryHaveLicence() 只要有任何一张有效证书就整体放行,不校验「当前 captchaId 是否在授权范围内」。按产品/类型/数量的细粒度授权在另一张表 nc_license + LicenseService.canUseCaptcha(),只在取图与语音时检查(GetCaptchaService.java:103、GetControllerV2.java:207)。两套表各读各的,只写一套会出现「能取图但 verify 全失败」或反之。
3. 名单拦截责任链
InterceptorServiceFactory 用构造注入拿到全部 InterceptorService 实现,并把自己从列表里剔除以防递归(InterceptorServiceFactory.java:41-54)。执行严格短路:任一返回 true 立刻终止,异常则记日志后继续下一个(:85、:91)。
java
// captcha-server/.../security/interceptor/InterceptorServiceFactory.java:83
for (InterceptorService interceptorService : interceptorServices) {
try {
if (interceptorService.methodInterceptor(request, checkResult)) {
log.info("请求被拦截 - 拦截器[{}]", interceptorService.getClass().getSimpleName());
return true; // 短路
}
} catch (Exception ex) {
log.error("拦截器执行异常,继续执行下一个拦截器", ex); // fail-open
}
}
return false;check 侧的接线在 captcha-server/src/main/java/com/netease/is/nc/server/service/CheckCaptchaService.java:353:
java
if (interceptorServiceFactory.methodInterceptor(request, result)) {
return CaptchaRetUtils.fillError(result, CaptchaError.NOT_ALLOWED, "not allowed");
}3.1 IpListInterceptorServiceImpl
| 项 | 内容 |
|---|---|
| 数据表 | nc_managed_ip(ip,type,expire_time,status) |
| Redis Key | pri:captcha:ip:{ip}(RedisKeyFormat.java:72) |
| Value | 1 黑 / 2 白 / 3 灰(ManagedIpType.java:13-15) |
| 取值入口 | ManagedIpRedisService.getType(ip)(ManagedIpRedisService.java:242),Redis 异常返回 null |
| 判定 | 仅 type == BLACK 拦截;白名单与灰名单都不影响放行(IpListInterceptorServiceImpl.java:139) |
| IP 提取 | SecurityUtils.getRemoteAddr() = X-Forwarded-For 首个 IP,无 XFF 时取 request.getRemoteAddr()(SecurityUtils.java:27-45) |
| 命中 | checkResult.setHitIpBlacklist() → hitBlacklist=2,返回 true |
| 异常/取不到 IP | 直接放行(IpListInterceptorServiceImpl.java:53、:72) |
类注释写着「检测请求头部的所有 IP 地址,包括直连 IP / XFF 代理链 IP / 真实客户端 IP」,但 extractIpAddresses 实际只塞了一个值(IpListInterceptorServiceImpl.java:90-105)。即:按最左侧(客户端自称)IP 判定,中间的透明代理 IP 不参与匹配。这个差异直接决定了下文 nginx 配置的必要性。
3.2 UserListInterceptorServiceImpl
| 项 | 内容 |
|---|---|
| 数据表 | nc_namelist_user(user,action_obj,type,hit_count,last_hit_time) |
| Redis Key | captcha:user:{captchaId}{user}——两个占位符无分隔符直接拼接(RedisKeyFormat.java:73) |
| Value | 1 黑 / 2 白 / 3 灰(NamelistType.java:13-15) |
| 取值 | getType(captchaId, user)(NameListUserRedisService.java:250) |
| 本地缓存 | Caffeine,expireAfterWrite=10s、maximumSize=10000、softValues、空值占位 -1 防穿透(NameListUserRedisService.java:80、:122、:278) |
| 参数要求 | user 与 id 都非空才检查,缺一个直接跳过(UserListInterceptorServiceImpl.java:100) |
| 命中 | setHitUserBlacklist() → hitBlacklist=3,返回 true |
| 异常 | 放行(:66) |
user 从请求参数取(ParameterUtils.optString(request, "user", ""),:79),因此该参数完全由客户端控制——黑白名单的真正约束力来自签名/风控侧,而非这个参数本身。
3.3 指纹黑名单(链外第一道)
先于拦截链执行,只取风控回填的 riskInfo.deviceId,键为 captcha:fingerPrint:{fingerPrint}:{deviceType}(RedisKeyFormat.java:74;CheckCaptchaService.java:174-200)。命中返回 413 HF_FAILED,hitType=20200,并单独落一条日志。它与 IP/用户名单的错误码不同(413 vs 418),运营归因靠 hitBlacklist 而非错误码。
4. DB → Redis 同步与刷新周期
text
nc_managed_ip ─┐
nc_namelist_user ─┼─ @Scheduled(fixedDelay=10000) 全量拉取
nc_managed_finger_print ─┘ (AbstractListManagementSync.java:155)
│
├─ 首次全量:先写新值,再删不在新集合中的旧 key(atomicReplace)
│ ManagedIpRedisService.java:144 / NameListUserRedisService.java:442
└─ 用户名单额外 localCache.invalidateAll()(:459)- 后台改名单到生效:Redis 侧 ≤10s;用户名单再叠一层 10s 本地缓存,最坏 ≈20s。
- 同步用「先增后删」而非「先删后增」:第一阶段失败旧数据仍可用;第二阶段失败只是留冗余 key(
ManagedIpRedisService.java:98-120注释即为此说明)。 - 服务副本约束:
is.backup=true的实例跳过同步(AbstractListManagementSync.java:159),多副本同时跑会重复原子替换。
5. 注册顺序与配置位置
| 顺序 | 组件 | 注册方式 | 配置行号 |
|---|---|---|---|
| 1 | LicenceCheckInterceptor | <mvc:interceptor> + <mvc:mapping path="/api/**"/> | captcha-server/src/main/webapp/WEB-INF/captcha-server-servlet.xml:23-27 |
| 2 | PrometheusMetricsInterceptor | 同上 | captcha-server/src/main/webapp/WEB-INF/captcha-server-servlet.xml:28-32 |
| — | 名单拦截器链 | Spring @Component 自动注入工厂,不经 XML | IpListInterceptorServiceImpl.java:28、UserListInterceptorServiceImpl.java:24 |
两个 MVC 拦截器都没有 order 属性,执行顺序按 XML 声明顺序;名单链没有 @Order,顺序由 Spring 注入的 List<InterceptorService> 决定(等价于 bean 名扫描顺序),不是显式契约。当前两个拦截器互不依赖,顺序无影响;但新增「带副作用且相互依赖」的拦截器前必须先补 @Order 或 Ordered 实现。
6. 与 nginx 的配合
docs/二次校验接口隔离代理方案.md 给出的结论直接决定上面这些机制能否成立:
| 结论 | 原因 | 出处 |
|---|---|---|
外网入口只开放 get/getconf/check/图片/JS,其余 404 | /api/v2/verify 一旦暴露,攻击者可用自己的 secretId 组合试探签名 | docs/二次校验接口隔离代理方案.md:16-27、:133 |
X-Forwarded-For 必须覆盖写入 $remote_addr,不得追加或透传 | IP 拦截取 XFF 首值;若透传客户端自带值,黑名单可被 10 行代码绕过 | docs/二次校验接口隔离代理方案.md:92 |
/metrics 不对公网开放 | 指标带 server_ip + hostname,直接泄露拓扑 | docs/二次校验接口隔离代理方案.md:27 |
/api/v2/verify、/api/v1/sms 仅内网监听 + allow 10.0.0.0/8 | 服务端到服务端调用,无需公网可达 | docs/二次校验接口隔离代理方案.md:140-158 |
| 不得做路径前缀重写 | 路径是 SDK 内置规则 | docs/二次校验接口隔离代理方案.md:60 |
/api/v1/sms 自身还有一道代码级 IP 白名单:线上环境只允许 127.0.0.1 与网关 IP,测试环境不限制(captcha-server/src/main/java/com/netease/is/nc/server/api/sms/UplinkSMSController.java:44、:85)。
7. 安全评估
优点
| 优点 | 体现 |
|---|---|
| 短路语义清晰 | 任一命中立即返回,不做无谓 Redis 查询 |
| 可用性优先 | 名单 Redis 异常、字段缺失一律放行(fail-open),不因名单库故障导致全站验证码不可用 |
| 维度可区分 | 指纹 413、IP/用户 418,配合 hitBlacklist 1/2/3 支持后台按维度归因与统计 |
| 零侵入扩展 | 新增拦截器只需 implements InterceptorService + @Component,工厂自动收集 |
| 配置热生效 | 10s 周期 DB→Redis 全量替换,后台改名单无需重启 |
| 双层 License | 拦截器挡「完全无授权」,canUseCaptcha 挡「类型/数量越权」 |
缺口(按风险排序)
| 缺口 | 影响 | 建议 |
|---|---|---|
get/getconf 不过名单 | 黑名单来源可继续耗取图与预生成资源,形成成本型攻击面 | 在 GetCaptchaService 前置一次 IP 判定,或放到 nginx 层 |
| IP 只取 XFF 首值 | 依赖 nginx 覆盖写入;客户自带 LB 未覆盖时判定失真 | 改为「直连 IP + XFF 全链」遍历,与类注释对齐 |
| 全部异常 fail-open | 名单库被打挂 / 序列化异常即等于关闭拦截 | 增加「连续异常计数 → 降级为只拦白名单外的已知黑 IP」兜底 |
| 用户名单 key 无分隔符 | cid=ab,user=cde 与 cid=abc,user=de 撞同一 key | key 改为 captcha:user:{cid}:{user} |
user 参数客户端可控 | 命中判定可被主动规避(换 user 值) | 关键场景改用签名后的业务主体标识 |
| 无显式拦截器顺序 | 依赖注入顺序,重构易改变语义 | 实现 Ordered 或统一 @Order |
| 用户名单 ≤20s 延迟 | 作弊流量在封禁生效前的窗口内仍可通过 | 命中写名单时同步 localCache.invalidate(key)(当前只在 save 路径做) |
queryHaveLicence 粒度粗 | 一张有效证书覆盖全部 App | 拦截器改为按 captchaId 校验授权范围 |
| 配置未接线 | LoginAttemptsChecker、SafeCheckInterceptor 属 admin 侧设计,与 server 拦截链无关,切勿混用 | 分模块各自维护 |
8. 变更自检清单
- 新增
InterceptorService必须返回true=拦截、false=放行,且不要抛异常指望上层捕获——工厂会吞掉异常并继续(等于放行)。 - 命中时同时写
CheckResult的hitBlacklist标记,否则后台归因显示「未命中」。 - 任何名单 Redis key 变更必须同步
SyncAbstractRedisService.atomicReplace的 pattern(RedisKeyFormat.getFormat().replace("%s","*")),否则全量同步会删不干净。 - 改动
X-Forwarded-For语义前,先确认代理层是覆盖还是追加写入。 - 触碰
LicenceHelper.PUBLIC_KEY会同时影响所有已交付现场的旧证书,属破坏性变更。