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 解析器)会当地址——写面放行等于把判定推给拨号层,构成回环绕过路径。
构造态豁免:WithAllowPrivate / WithAllowCIDRs / WithAllowDomains。 **精确 host 白名单**:WithExactHosts —— "host 必须完全等于管理员配的那个 host" 这类判据在放行型模型下没有对应表达(后缀豁免只能放宽域及其全部子域,无法钉死 到具体 host),它与豁免维度独立可叠加,且命中后仍走 scheme 与私网判定。
Guard 在 New 之后只读,可并发共享。仅依赖标准库(pkg 零第三方依赖红线)。
用法(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 ¶
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 会被这里误杀)。未命中一律拒绝。
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 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 是实际拨号地址(默认端口 已补齐),按字面比对会误杀正常配置。