Commit c1b05fc
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
- security
- community/service
- test/java/com/involutionhell/backend
- common/security
- community/service
Lines changed: 2 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
113 | | - | |
| 113 | + | |
| 114 | + | |
114 | 115 | | |
115 | 116 | | |
116 | 117 | | |
| |||
Lines changed: 161 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
0 commit comments