Skip to content

Commit c1b05fc

Browse files
authored
fix(community): OG 抓取 SSRF 加固 + 事务/模块解耦收尾 (#20)
* fix(community): OgFetchService 堵 SSRF,DNS/每跳都过私网黑名单 - 加 PrivateAddressGuard:InetAddress.getAllByName 后逐 IP 判 loopback / RFC1918 / link-local / CGNAT / multicast / ULA,DNS 解析失败 fail-closed - OgFetchService 的 HttpClient 改用 Redirect.NEVER,自己循环处理 3xx(最多 3 跳),每一跳都重新解析 host 再走 PrivateAddressGuard,避免 302 把我们 扔到 169.254.169.254 这种 metadata 端点 - 加 OgFetchServiceSsrfTest:127.0.0.1 / 10.0.0.1 直接挡;公开 host 302 到 169.254.169.254 时第二跳也挡;正常公开 host 走 200 OK 通路 * fix(error): 兜底异常走 SLF4J logger,去掉 printStackTrace printStackTrace 只往 stderr 写,没有 trace id、采集不到 Loki,生产环境等于 看不到。换成 log.error("未处理的异常", exception),栈信息走统一日志管道。 * fix(community): SharedLinkService 写路径补 @transactional - submit / submitInternal / report / enrich 四条写入路径都加 @transactional: submit 的「限频计数读 + insert」必须在同一事务里,否则并发能穿透日配额; report 的「reports 插入 + link report_count 自增 + 可能的 transitionStatus」 三步也需要原子落地,不然 count 和 status 会飘。 - 读方法(findById / buildAdminSummary / listApproved / listBySubmitter / listPendingForAdmin)改 @transactional(readOnly=true),告诉驱动别给事务 分配 xid,同时挡掉意外写。 - 刻意不在 SharedLinkEnrichmentWorker.enrich 这种 @async 方法上包事务: 事务放在它调用的 SharedLinkService.enrich 里,边界更窄;避免异步线程 上挂一条事务连接跨越 OG 抓取 + DeepSeek 调用这两个长外部 I/O。 * fix(community): SharedLinkService 改走 UserCenterService facade community 模块不应该直接注入 usercenter 的 UserAccountRepository——跨模块 访问只经过 service 层。把 bridge 账号查询从 userRepo.findByUsername 改成 UserCenterService.findByUsername(该方法已存在),去掉仓储层导入。 * fix(community): OgFetchService 响应体按 2MB 截断,防无限流撑爆堆 BodyHandlers.ofString() 没有 size 上限,10s timeout 内恶意公开 host 用 chunked 无尽流可以把 JVM 堆吃光。换成 ofInputStream() 边读边计数: - 新增 MAX_BODY_BYTES=2MB(OG meta 都在 <head>,2MB 远超正常站点) - readBodyCapped 用 ByteArrayOutputStream + 8KB chunk,累计超过上限立刻 close 流,返回 exceededLimit=true;不再继续吃后续字节 - 从 Content-Type 抽 charset(公众号/知乎 UTF-8,少数 GBK 站按实际标签) 无法识别或无标签就 UTF-8 兜底 - 3xx / 非 2xx 的 body 用 drainAndClose 丢弃,避免连接卡在 keep-alive 池 - stub HttpClient 测试跟着改成 ByteArrayInputStream body - 新增 OgFetchServiceSsrfTest#fetch_bodyExceedsMaxSize_returnsFailure * refactor(security): PrivateAddressGuard 挪到 common/security 这是跨模块的 SSRF 基础防御,未来 analytics / zotero / github 代理这些 会发 user-controlled URL 请求的模块都要复用。放 community/util 下有误导 ——它不是 community-specific。 - 移动:community/util/PrivateAddressGuard → common/security/PrivateAddressGuard - 更新包声明 + OgFetchService 的 import - 无逻辑变更 * fix(community): 写路径 @transactional 加 rollbackFor=Exception.class Spring 默认只在 unchecked 上回滚;后续有人往 submit / submitInternal / report / enrich 里加 checked 异常(IOException 等)时,不至于悄悄把半 条数据 commit 掉。read-only 方法保持不变——只读事务不用管回滚策略。 * fix(community): redirect Location 畸形时返回结构化 failure 之前 URI.resolve 对畸形 Location 抛 IllegalArgumentException 会一路冒到 外层 catch(Exception) 里被伪装成 "解析异常: ...",排障时看不出根因。 改成在循环里就地 catch,返回 failure("redirect target invalid: <msg>") 并 log.warn 原始 Location,供下游告警识别。 新增 SsrfTest#fetch_redirectWithGarbageLocation_returnsStructuredFailure 覆盖这条路径。 * docs(community): OgFetchService Javadoc 标注 DNS rebinding 残留风险 PrivateAddressGuard 解析 IP 和 HttpClient 建连前的 DNS 解析是两次独立查询, 低 TTL 攻击者域能在窗口期把 A 记录从公网翻到 169.254.169.254。彻底堵 需要换 HC5 / OkHttp + 自定义 DnsResolver pin IP,属于后续工程项。 Javadoc 里记一笔给后面接手的人一个 breadcrumb。 * fix(community): drainAndClose 捕获 read 返回值,别骗计数器 原写法 drained += sink.length 在流尾 / 短读时会把实际 2KB 当 8KB 计数。 功能方向无害(只会比预期更早 break),但代码在撒谎,触发后面维护的人 误判行为边界。改成捕获 n 加上去。 * fix(community): resolveCharset 在 lower 串上做切片,避免大小写错位 原写法在 lower 上 indexOf,再回原 contentType 上 substring。ASCII 情况下 "Charset=" 和 "charset=" 长度都是 8,靠巧合能对上;但这是依赖 locale-less ASCII 长度守恒的脆弱逻辑——哪天把 toLowerCase 换成带 locale 的版本、或在 中间塞个 trim,就会错位一字节读到诡异的 charset 名。 Charset.forName 本身对字符集名大小写不敏感(gbk / utf-8 都认),所以直接 在 lower 上 substring 拿到的小写 charset 名完全够用,还更省心。 * docs(community): DNS rebinding 注释补 OS/JVM 缓存 + IP pinning 细节 光换 HC5 / OkHttp + 自定义 DnsResolver 不够——还得把解析到的 IP 直接喂 给 socket connect、Host 头带原域名走 SNI;OS 层 nscd / systemd-resolved 和 JVM networkaddress.cache.ttl 都能留毫秒级残窗口。把这层纹理写进 Javadoc, 免得后来人换个 HttpClient 就以为彻底修好了。 * docs(community): OgFetchService TIMEOUT 注释修正 ofInputStream 语义 ofInputStream 切换后 HttpRequest.timeout 只覆盖到拿到 response head, 之后从 InputStream 逐块 read 的耗时不受它管;原注释写成「connect + read 合计 10s」会误导后来者。把防御归因挪回 MAX_BODY_BYTES + readBodyCapped。 * fix(security): PrivateAddressGuard 堵 IPv4-mapped IPv6 SSRF 绕过 ::ffff:0:0/96 这块 96 位前缀的 IPv6 地址实际指向 IPv4,但 JDK 的 isLoopbackAddress / isSiteLocalAddress 都按"纯 IPv6"处理,全部返回 false。 原代码 IPv6 分支只判 ULA + link-local,攻击者写 [::ffff:127.0.0.1] / [::ffff:10.0.0.1] / [::ffff:169.254.169.254] (AWS metadata via mapped)就能绕掉所有 IPv4 黑名单。 修法:检测到 ::ffff:0:0/96 前缀(前 10 字节 0、第 11/12 字节 0xff)就 取末 4 字节用 InetAddress.getByAddress 重建 Inet4Address,递归走一遍 isBlockedAddress 走 IPv4 全套规则。 顺便加了 resolveAndCheck 三态枚举(OK / DNS_FAIL / BLOCKED)给上层 区分 DNS 失败和真实命中黑名单——为下一个 commit 做铺垫,旧 isBlockedHost 改成 thin wrapper 不破坏现有调用方。 PrivateAddressGuardTest 新加 10 条用例,包括 4 条 IPv4-mapped 场景。 * fix(community): OgFetchService 区分 DNS 失败和 host 被黑名单挡 之前 PrivateAddressGuard.isBlockedHost 把 UnknownHostException 和 IP 命中 黑名单合并成一个 boolean,OgFetchService 一律返回 "blocked internal host"。 用户敲错域名(typo / 老链接)时排障会以为我们在审查他的链接。 改用 resolveAndCheck 拿到三态枚举: - DNS_FAIL → "dns lookup failed: <host>" - BLOCKED → "blocked internal host"(语义保持,给 SSRF 攻击者看的) - OK → 继续走 仍是 fail-closed:DNS 失败和黑名单命中都直接 return failure,没有放行漏洞。 * test(community): OG 测试用 IP 字面量替掉真实域名,断离线 CI 的 DNS 依赖 OgFetchService.fetch 在发请求前用 PrivateAddressGuard 解析 host,喂 example.com / mp.weixin.qq.com / zhuanlan.zhihu.com 这种真实域名会真的 查 DNS,导致离线 / 受限 CI 直接挂。 把 SSRF 测试 + 平台维度 OG 解析测试里的 fetch URL 全部换成 1.1.1.1 (Cloudflare DNS,公网 IP 字面量),guard 直接判 OK 不查 DNS。 站点平台维度本身由 OG meta 文本断言覆盖,host 在这些用例里就是个 路由占位,不影响 assertion。 parseOg(html, baseUrl) 那条直接调内部方法的不动——Jsoup 的 baseUrl 参数走不到 guard。 * docs(community): submit Javadoc 别再说 @transactional 解决并发限频 旧 Javadoc 写的是"事务覆盖三步否则并发会穿透日配额"——这是骗自己。 SELECT COUNT(*) + INSERT 是经典 check-then-act,PostgreSQL 默认 Read Committed 下两个并发 tx 完全可以都读到 count=N 然后都插入。 @transactional 给的是单次请求内的一致性快照,不是原子限频。 改写 Javadoc 直接讲清楚:tx 是干嘛的、不是干嘛的、真正原子限频要靠 DB UNIQUE / SELECT FOR UPDATE / Redis 哪种方案。RateLimitExceeded 也注上"best-effort,并发可能短暂穿透",避免下游依赖它做强保证。 代码层面不动——真原子限频是单独 PR 的事,已记入 follow-ups。
1 parent e5dcd19 commit c1b05fc

7 files changed

Lines changed: 863 additions & 34 deletions

File tree

src/main/java/com/involutionhell/backend/common/error/GlobalExceptionHandler.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ public ResponseEntity<ApiResponse<Void>> handleBusiness(Exception exception) {
110110
*/
111111
@ExceptionHandler(Exception.class)
112112
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception) {
113-
exception.printStackTrace(); // 建议在开发阶段打印堆栈,生产环境应使用日志框架
113+
// 走 SLF4J 统一进日志管道(含 trace id / 采集到 Loki 等),不再用 printStackTrace 污染 stdout
114+
log.error("未处理的异常", exception);
114115
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
115116
.body(ApiResponse.fail("服务器内部错误"));
116117
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package com.involutionhell.backend.common.security;
2+
3+
import java.net.Inet4Address;
4+
import java.net.Inet6Address;
5+
import java.net.InetAddress;
6+
import java.net.UnknownHostException;
7+
8+
/**
9+
* SSRF 防御:判定主机名解析后的 IP 是否属于不能被服务端主动访问的地址段。
10+
*
11+
* 命中任一地址段即视为“内网”,应立刻拒绝:
12+
* <ul>
13+
* <li>IPv4: 127.0.0.0/8(loopback)、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、
14+
* 169.254.0.0/16(link-local / AWS / GCP metadata)、0.0.0.0/8、
15+
* 100.64.0.0/10(CGNAT)、224.0.0.0/4(multicast)</li>
16+
* <li>IPv6: ::1(loopback)、fc00::/7(ULA)、fe80::/10(link-local)、
17+
* 以及被 {@link InetAddress} 直接归类为 anyLocal / siteLocal / mcast 的任何地址</li>
18+
* <li>IPv4-mapped IPv6 (::ffff:0:0/96):取出末 4 字节,按 IPv4 规则再判一次。
19+
* 否则攻击者用 [::ffff:127.0.0.1] / [::ffff:10.0.0.1] 这类字面量能绕过</li>
20+
* </ul>
21+
*
22+
* 只做“是否应拒绝”的判定,不负责发起 HTTP 请求。调用方需在发 HTTP 前对每一跳
23+
* (包括 302/301 redirect 的目标)都走一次 {@link #resolveAndCheck(String)}。
24+
*
25+
* <p>调用约定:
26+
* <ul>
27+
* <li>{@link #resolveAndCheck(String)} 区分 OK / DNS_FAIL / BLOCKED,调用方可以
28+
* 给最终用户返回不同的错误信息("DNS 查询失败" vs "拒绝内网地址")。</li>
29+
* <li>{@link #isBlockedHost(String)} 是 fail-closed 的布尔包装:DNS 失败也按
30+
* BLOCKED 算。保留给只关心 boolean 的旧调用方(不区分原因即可)。</li>
31+
* </ul>
32+
*/
33+
public final class PrivateAddressGuard {
34+
35+
private PrivateAddressGuard() {}
36+
37+
/** {@link #resolveAndCheck(String)} 的三态结果。 */
38+
public enum CheckResult {
39+
/** 解析成功且所有 IP 都是公网。 */
40+
OK,
41+
/** DNS 解析失败 / 空 host —— 上层应给“DNS 查询失败”这种 user-facing 错误。 */
42+
DNS_FAIL,
43+
/** 解析成功但有 IP 命中内网/回环/link-local 等黑名单段。 */
44+
BLOCKED
45+
}
46+
47+
/**
48+
* 解析 host,对每个 IP 跑黑名单。区分 DNS 失败和真实命中黑名单两种情况,
49+
* 让调用方可以给用户更准确的 error message。
50+
*
51+
* 仍然 fail-closed:{@link CheckResult#DNS_FAIL} 在调用方语义上和
52+
* {@link CheckResult#BLOCKED} 一样要拒绝请求,只是错误文案不一样。
53+
*/
54+
public static CheckResult resolveAndCheck(String host) {
55+
if (host == null || host.isBlank()) {
56+
return CheckResult.DNS_FAIL;
57+
}
58+
InetAddress[] addrs;
59+
try {
60+
addrs = InetAddress.getAllByName(host);
61+
} catch (UnknownHostException e) {
62+
return CheckResult.DNS_FAIL;
63+
}
64+
if (addrs == null || addrs.length == 0) {
65+
return CheckResult.DNS_FAIL;
66+
}
67+
for (InetAddress a : addrs) {
68+
if (isBlockedAddress(a)) {
69+
return CheckResult.BLOCKED;
70+
}
71+
}
72+
return CheckResult.OK;
73+
}
74+
75+
/**
76+
* 解析 host 对应的所有 IP,只要任意一个 IP 命中内网段就返回 true。
77+
* DNS 解析失败当作“不可达”处理,返回 true(等价于拒绝请求,fail-closed)。
78+
*
79+
* 不区分 DNS 失败和黑名单命中的旧 API;新代码请用
80+
* {@link #resolveAndCheck(String)} 拿到三态枚举。
81+
*/
82+
public static boolean isBlockedHost(String host) {
83+
return resolveAndCheck(host) != CheckResult.OK;
84+
}
85+
86+
/**
87+
* 单个 IP 的内网判定。拆出来方便单元测试。
88+
*/
89+
public static boolean isBlockedAddress(InetAddress addr) {
90+
if (addr == null) return true;
91+
92+
// JDK 已经帮我们覆盖了绝大多数“不是公网”的情况
93+
if (addr.isLoopbackAddress()) return true; // 127.0.0.0/8 / ::1
94+
if (addr.isAnyLocalAddress()) return true; // 0.0.0.0 / ::
95+
if (addr.isLinkLocalAddress()) return true; // 169.254/16 / fe80::/10
96+
if (addr.isSiteLocalAddress()) return true; // 10/8, 172.16/12, 192.168/16
97+
if (addr.isMulticastAddress()) return true; // 224.0.0.0/4 / ff00::/8
98+
99+
if (addr instanceof Inet4Address v4) {
100+
byte[] b = v4.getAddress();
101+
int o1 = b[0] & 0xff;
102+
int o2 = b[1] & 0xff;
103+
104+
// 0.0.0.0/8(“本网络”)JDK 的 isAnyLocalAddress 只判 0.0.0.0 精确值,
105+
// 这里扩到整段保险
106+
if (o1 == 0) return true;
107+
108+
// CGNAT 100.64.0.0/10 不在 JDK 的 siteLocal 里
109+
if (o1 == 100 && (o2 & 0xc0) == 64) return true;
110+
111+
// 防御性:JDK 某些版本 isSiteLocalAddress 的判定以 10/172.16/192.168 为准,
112+
// 再显式补一遍,避免 JDK 行为变化
113+
if (o1 == 10) return true;
114+
if (o1 == 172 && o2 >= 16 && o2 <= 31) return true;
115+
if (o1 == 192 && o2 == 168) return true;
116+
if (o1 == 169 && o2 == 254) return true;
117+
} else if (addr instanceof Inet6Address v6) {
118+
byte[] b = v6.getAddress();
119+
int first = b[0] & 0xff;
120+
121+
// fc00::/7 — Unique Local Address,JDK 没有 isUniqueLocal()
122+
if ((first & 0xfe) == 0xfc) return true;
123+
124+
// fe80::/10 — link-local;JDK 已判过,冗余一遍保险
125+
if (first == 0xfe && (b[1] & 0xc0) == 0x80) return true;
126+
127+
// ::ffff:0:0/96 — IPv4-mapped IPv6。JDK 对这类地址的
128+
// isLoopbackAddress / isSiteLocalAddress 返回 false(它认为这就是
129+
// IPv6),导致攻击者写 [::ffff:127.0.0.1] 或 [::ffff:10.0.0.1]
130+
// 能绕过前面所有 IPv4 规则。这里取末 4 字节重建 Inet4Address
131+
// 再走一次 IPv4 黑名单,关掉这条路。
132+
if (isIPv4Mapped(b)) {
133+
try {
134+
byte[] v4Bytes = new byte[] { b[12], b[13], b[14], b[15] };
135+
InetAddress mapped = InetAddress.getByAddress(v4Bytes);
136+
if (mapped instanceof Inet4Address && isBlockedAddress(mapped)) {
137+
return true;
138+
}
139+
} catch (UnknownHostException e) {
140+
// getByAddress(byte[4]) 不会真去做 DNS 反查,理论上不抛;
141+
// 真抛了视为可疑,按 BLOCKED 处理
142+
return true;
143+
}
144+
}
145+
}
146+
147+
return false;
148+
}
149+
150+
/**
151+
* 判定 16 字节是否是 ::ffff:0:0/96(IPv4-mapped IPv6):
152+
* 前 10 字节全 0,第 11、12 字节都是 0xff。
153+
*/
154+
private static boolean isIPv4Mapped(byte[] b) {
155+
if (b.length != 16) return false;
156+
for (int i = 0; i < 10; i++) {
157+
if (b[i] != 0) return false;
158+
}
159+
return (b[10] & 0xff) == 0xff && (b[11] & 0xff) == 0xff;
160+
}
161+
}

0 commit comments

Comments
 (0)