Documentation
¶
Overview ¶
Package urlguard 提供出站 URL 的 SSRF(Server-Side Request Forgery)防护原语, 供「出站目标是用户自配 URL」的服务(webhook 接收器:钉钉/飞书/企微机器人、 告警接入通道等)复用([oekit/httpx#11](https://github.com/oekit/httpx/issues/11))。
两层防护缺一不可:
层 1 CheckURL——URL 字面量校验:host 直接写私网/loopback IP、 .internal/.local/localhost 约定域名、非 http(s) scheme 一律拒绝,挡住低成本探测。
层 2 DialFunc——拨号层包装:host 解析出的**每一个** IP 都过 CheckIP(DNS rebinding / 劫持场景,URL 层对它无能为力),全部放行才调底层拨号;建连成功后再核对实际 RemoteAddr——预检与真实端点之间的重解析窗口(TOCTOU)被端点核对闭合, Happy-Eyeballs 双栈竞争的胜出路径同样被覆盖。
默认阻断范围:未指定地址(0.0.0.0/8、::)、loopback(127.0.0.0/8、::1)、 RFC1918 私网(10/8、172.16/12、192.168/16)、链路本地(169.254/16——含云元数据端点 169.254.169.254、fe80::/10)、CGNAT(100.64/10)、ULA(fc00::/7)、组播/保留 (224/4、240/4、ff00::/8、192.0.0.0/24、TEST-NET 192.0.2.0/24·198.51.100.0/24· 203.0.113.0/24、基准测试 198.18.0.0/15);域名 .internal / .local / .localdomain / localhost(含子域)。 IPv4-mapped IPv6(::ffff:10.0.0.1)先归一化为 IPv4 再判定。
IPv6 特殊用途段与 NAT64 段同样阻断:64:ff9b::/96(NAT64 well-known prefix—— 64:ff9b::7f00:1 即 127.0.0.1 的 IPv6 写法)、64:ff9b:1::/48(NAT64 local-use)、 100::/64(discard-only)、2001::/32(Teredo)、2001:2::/48(benchmarking)、 2001:10::/28(ORCHID)、2001:20::/28(ORCHIDv2)、2001:db8::/32(文档段)、 2002::/16(6to4——可内嵌任意 IPv4,含私网/回环)。
exotic IPv4 字面量形态(0x7f000001 / 0177.0.0.1 / 127.1 / 2130706433)一律拒绝: 标准库 net.ParseIP 不认它们,但宽松解析器(cgo resolver 的 inet_aton 语义、 浏览器 URL 解析器)会当地址——写面放行等于把判定推给拨号层,构成回环绕过路径。
IPv6 zone 后缀(http://[::1%25lo]/、http://[fe80::1%25eth0]/)同样按地址部分 判定:net.ParseIP 不认 zone,不剥掉就等于整段跳过 IP 字面量判定——loopback、 链路本地、ULA、IPv4-mapped 全能以 zone 形态绕过。zone 只决定走哪块网卡, 不改变连的是哪个地址。
构造态豁免:WithAllowPrivate / WithAllowCIDRs / WithAllowDomains。 **精确 host 白名单**:WithExactHosts —— "host 必须完全等于管理员配的那个 host" 这类判据在放行型模型下没有对应表达(后缀豁免只能放宽域及其全部子域,无法钉死 到具体 host),它与豁免维度独立可叠加,且命中后仍走 scheme 与私网判定。
**自定义判定钩子**:WithCheckIPFunc / WithCheckHostFunc —— 放行型模型表达不了 消费方常见的限制型策略(白名单只放行我的网段 / 黑名单屏蔽这些网段 / 域名 白黑名单)。注入后消费方的策略成为**单一判定源**,且同时作用于「预检」与 「建连后端点复核」两处——重解析窗口(DNS rebinding / TOCTOU)由消费方自己 的策略闭合,不必在 urlguard 之外再叠一层自有端点判定。判定顺序:内置在前、 自定义在后;构造态豁免只豁免内置判定,不再跳过自定义判定。
Guard 在 New 之后只读,可并发共享。仅依赖标准库(pkg 零第三方依赖红线)。
已知边界 ¶
- 代理:DialFunc 的签名拿不到「目标 host」(http.Transport.Proxy 生效时 DialContext 收到的是**代理**地址),因此拨号层校验的是代理的 IP、真实目标的 DNS 从头到尾没被 IP 级校验过。走代理时必须先过 CheckURL(层 1), 且代理自身不在阻断网段内。
- 自定义传输:端点复核只认 TCP / UDP / IP 三种 net.Addr;其他实现 (自定义 conn)取不到 IP ⇒ 该次端点复核跳过。
用法(webhook 出口一行接入,[httpx](https://github.com/oekit/httpx) 的 WithDialFunc 直接组合):
g, err := urlguard.New() // 缺省全阻断;测试环境
// g, err := urlguard.New(urlguard.WithAllowPrivate()) // 打本机 mock server 用
if err != nil { ... }
if err := g.CheckURL(webhookURL); err != nil { ... } // 层 1:入参校验
hc := &http.Client{Transport: &http.Transport{ DialContext: urlguard.SafeDialFunc(nil) }}
// 或与 httpx 组合:httpx.WithDialFunc(g.DialFunc(nil))
Index ¶
- func IsBlockedURL(raw string) error
- type DialFunc
- type Guard
- type Option
- func WithAllowCIDRs(cidrs ...string) Option
- func WithAllowDomains(domains ...string) Option
- func WithAllowPrivate() Option
- func WithAllowedSchemes(schemes ...string) Option
- func WithCheckHostFunc(fn func(host string) error) Option
- func WithCheckIPFunc(fn func(net.IP) error) Option
- func WithExactHosts(hosts ...string) Option
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func IsBlockedURL ¶
IsBlockedURL 用缺省 Guard 做层 1 字面量校验(等价 New().CheckURL)。 nil 返回 = 放行。
Types ¶
type DialFunc ¶
DialFunc 与 httpx/net 的拨号函数同形(标准库形态),可直接互相包装。
func SafeDialFunc ¶
SafeDialFunc 用缺省 Guard 包装拨号函数(等价 New().DialFunc(next))。 next 为 nil 时用 net 的默认拨号。
type Guard ¶
type Guard struct {
// contains filtered or unexported fields
}
Guard 是 SSRF 防护器:New 之后只读,可并发共享。
func (*Guard) CheckHost ¶
CheckHost 判定主机名/IP 字面量是否可放行(nil 返回 = 放行)。 host 不带端口;大小写与尾点不敏感。
配了 WithExactHosts 时:host 必须命中白名单中**不带端口**的条目—— CheckHost 的入参没有端口,端口级条目无法被它确认(否则 CheckURL 已按 authority 命中端口级条目的 URL 会被这里误杀)。未命中一律拒绝。
func (*Guard) CheckIP ¶
CheckIP 判定单个 IP 是否可放行(nil 返回 = 放行)。 IPv4-mapped IPv6 先归一化为 IPv4 再判定。
判定顺序(pkg#91):**内置判定在前、自定义判定在后**——内置的私网/保留段 阻断是兜底(fail-closed),自定义判定(WithCheckIPFunc)在其之后叠加。 构造态豁免(WithAllowPrivate / WithAllowCIDRs)只豁免内置判定,不再跳过 自定义判定。
type Option ¶
type Option func(*Guard)
Option 配置 Guard。
func WithAllowCIDRs ¶
WithAllowCIDRs 按网段显式豁免(如内网 webhook 端点 "10.20.0.0/16")。 网段文法非法时 New 报错。
func WithAllowDomains ¶
WithAllowDomains 按域名豁免(小写化后保存;命中语义 = 等于或点分后缀, "example.com" 同时放行 "api.example.com";不支持通配符前缀,写根域即可)。
func WithAllowPrivate ¶
func WithAllowPrivate() Option
WithAllowPrivate 放行全部内网/loopback 地址——仅供测试环境打本机 mock server; 生产构造不要使用。
func WithAllowedSchemes ¶
WithAllowedSchemes 覆盖 CheckURL 的 scheme 白名单(缺省 http/https)。
func WithCheckHostFunc ¶ added in v0.25.0
WithCheckHostFunc 注入**自定义 host 判定**(pkg#91):返回 nil = 放行, 非 nil = 拒绝。语义与 WithCheckIPFunc 同构,只是维度是 host 而非 IP。
为什么需要这一档:host 维度同样只有「放宽型」入口——WithAllowDomains 是 "放宽某些域的私网/约定域阻断",WithExactHosts 是"只允许这些 authority", 没有"限制到这些域"的常规白/黑名单入口(域名后缀豁免表达不了黑名单)。
判定顺序:内置判定在前(精确白名单 → 字面量判定),自定义判定在后。
入参是**归一化后**的 host(小写、去尾点、去 IPv6 方括号),与 CheckHost 的入参口径一致。IP 字面量同样以字符串形式传入(本维度不区分地址与域名)。
钩子可能被调用多次:CheckHost / CheckURL / 拨号层各判定一次。钩子应是 无副作用的纯判定。
func WithCheckIPFunc ¶ added in v0.25.0
WithCheckIPFunc 注入**自定义 IP 判定**(pkg#91):返回 nil = 放行,非 nil = 拒绝。
为什么需要这一档:urlguard 是**纯放行型**防护——缺省阻断私网/loopback/ 保留段与约定域名后缀,除此之外的公网地址一律放行。它现有的三个 IP 维度 都无法表达消费方常见的出站策略:
- WithAllowCIDRs:语义是「豁免私网阻断」,不是「限制到这些网段」—— 白名单外的任意公网 IP 仍默认放行;
- blockedCIDRs:固定私网清单,没有运行时追加拒绝项的入口;
- 整个包没有端口维度。
而消费方的出站防护通常是**两段式**:写面校验(请求发起前解析一次 DNS 按 自己的策略判定 URL)+ 建连层复核(拨号时解析、再按同一套策略判定**真实 连到的端点 IP**)。第 2 段不是冗余——第 1 段解析 DNS 与真正建连之间存在 重解析窗口(DNS rebinding / TOCTOU),攻击者可在窗口内把 A 记录切到内网 地址:校验层看到公网 IP、建连落到内网 IP。
注入本钩子后,消费方的策略成为**单一判定源**:拨号层预检与建连后的真实 端点核对用的是同一个判定(Guard.DialFunc 已有的骨架:解析 → 预检 → 建连 → 以真实 conn.RemoteAddr() 复核),重解析窗口由消费方自己的策略闭合, 不必在 urlguard 之外再叠一层自有端点判定。
判定顺序:**内置判定在前、自定义判定在后**。内置的私网/保留段阻断仍是 兜底(fail-closed),自定义判定在其之后叠加(可白名单也可黑名单)。
⚠️ 构造态豁免的语义收敛(pkg#91):WithAllowPrivate / WithAllowCIDRs 只豁免**内置**的私网/保留段阻断,**不再跳过自定义判定**——注入钩子后, 豁免不再意味着"整个 IP 判定被短路"。未注入钩子时行为与既有完全一致。
入参是**归一化后**的 IP:IPv4-mapped IPv6(::ffff:10.0.0.1)已归一化为 IPv4(与内置判定同一形态),消费方无需自己处理 mapped 绕过。
钩子可能被调用多次:拨号层对解析出的**每个** IP 各调一次(预检), 建连成功后对真实端点再调一次(复核),URL 层的 IP 字面量判定也会调。 钩子应是无副作用的纯判定。
func WithExactHosts ¶ added in v0.20.0
WithExactHosts 精确 host 白名单:出站目标的 authority(host[:port])必须 **完全等于**列表中某项,否则一律拒绝。与 WithAllowDomains(后缀豁免) 语义独立、可叠加——两者是不同维度:WithAllowDomains 定"哪些域放宽私网等 阻断",WithExactHosts 定"只允许哪些 host"。
为什么需要这一档:WithAllowDomains 只能"放宽"某个域及其全部子域,无法 "钉死"到具体 host。而放行型模型下(默认只阻断私网/loopback/保留段/ 约定域名),白名单外的任意公网域默认放行——"host 必须精确等于管理员配的 那个 URL 的 host"(webhook 出口签名绑 host、内部平台 API 出口多一个子域 就多一个未审计入口、凭据与端点绑定)在既有配置里根本没有对应表达。
判定位置:CheckURL 的 scheme 白名单之后、私网判定之前。命中白名单**不等于** 放行私网——钉死的 host 仍要满足 scheme 白名单与私网/保留段判定。
比较口径:
- host 大小写不敏感、尾点不敏感(DNS 语义);
- 端口按数值比较且**参与比较**——配置不带端口时 URL 必须也不带端口 ("https://h:443" 与 "https://h" 不是同一个字面量,按字面判不命中; 宁可严,不替调用方猜默认端口);
- userinfo 不参与比较:url.Parse 已把 "https://h@evil.com/" 的 userinfo 拆开,authority 是 "evil.com"——这正是"钉死 host"要挡的逃逸形态。
条目文法:host[:port](IPv6 字面量用方括号形态 "[::1]:8080")。空串、含 路径/空白/用户信息、端口非法或越界时 New 报错(fail-fast,不带病运行)。
未设置本选项时行为与既有完全一致;设置了但清单为空 = 一个 host 都不许 (fail-closed)。
注意:白名单只在 URL 层判定。DialFunc 做的是 IP 级判定(DNS rebinding / TOCTOU 闭合),不参与白名单比对——拨号层的 addr 是实际拨号地址(默认端口 已补齐),按字面比对会误杀正常配置。