Help us shape the future of Go! Take the 2026 Go Developer Survey

urlguard

package
v0.29.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 6 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func IsBlockedURL

func IsBlockedURL(raw string) error

IsBlockedURL 用缺省 Guard 做层 1 字面量校验(等价 New().CheckURL)。 nil 返回 = 放行。

Types

type DialFunc

type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc 与 httpx/net 的拨号函数同形(标准库形态),可直接互相包装。

func SafeDialFunc

func SafeDialFunc(next DialFunc) DialFunc

SafeDialFunc 用缺省 Guard 包装拨号函数(等价 New().DialFunc(next))。 next 为 nil 时用 net 的默认拨号。

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard 是 SSRF 防护器:New 之后只读,可并发共享。

func New

func New(opts ...Option) (*Guard, error)

New 构造 Guard。CIDR 文法非法时报错(fail-fast,不带病运行)。

func (*Guard) CheckHost

func (g *Guard) CheckHost(host string) error

CheckHost 判定主机名/IP 字面量是否可放行(nil 返回 = 放行)。 host 不带端口;大小写与尾点不敏感。

配了 WithExactHosts 时:host 必须命中白名单中**不带端口**的条目—— CheckHost 的入参没有端口,端口级条目无法被它确认(否则 CheckURL 已按 authority 命中端口级条目的 URL 会被这里误杀)。未命中一律拒绝。

func (*Guard) CheckIP

func (g *Guard) CheckIP(ip net.IP) error

CheckIP 判定单个 IP 是否可放行(nil 返回 = 放行)。 IPv4-mapped IPv6 先归一化为 IPv4 再判定。

判定顺序(pkg#91):**内置判定在前、自定义判定在后**——内置的私网/保留段 阻断是兜底(fail-closed),自定义判定(WithCheckIPFunc)在其之后叠加。 构造态豁免(WithAllowPrivate / WithAllowCIDRs)只豁免内置判定,不再跳过 自定义判定。

func (*Guard) CheckURL

func (g *Guard) CheckURL(raw string) error

CheckURL 做层 1 字面量校验(nil 返回 = 放行):scheme 必须在白名单内, host 走 CheckHost。仅校验字面量——DNS rebinding 场景必须配合 DialFunc(层 2)。

配了 WithExactHosts 时,URL 的 authority(host[:port])必须完全命中白名单, 否则在 scheme 校验之后、私网判定之前拒绝。

func (*Guard) DialFunc

func (g *Guard) DialFunc(next DialFunc) DialFunc

DialFunc 返回层 2 拨号包装:解析出的每个 IP 先过预检(任一命中阻断即不建连, fail-fast 且错误可读),建连成功后再核对实际 RemoteAddr——两道校验之间的 重解析窗口被端点核对闭合。next 为 nil 时用 net 默认拨号。

type Option

type Option func(*Guard)

Option 配置 Guard。

func WithAllowCIDRs

func WithAllowCIDRs(cidrs ...string) Option

WithAllowCIDRs 按网段显式豁免(如内网 webhook 端点 "10.20.0.0/16")。 网段文法非法时 New 报错。

func WithAllowDomains

func WithAllowDomains(domains ...string) Option

WithAllowDomains 按域名豁免(小写化后保存;命中语义 = 等于或点分后缀, "example.com" 同时放行 "api.example.com";不支持通配符前缀,写根域即可)。

func WithAllowPrivate

func WithAllowPrivate() Option

WithAllowPrivate 放行全部内网/loopback 地址——仅供测试环境打本机 mock server; 生产构造不要使用。

func WithAllowedSchemes

func WithAllowedSchemes(schemes ...string) Option

WithAllowedSchemes 覆盖 CheckURL 的 scheme 白名单(缺省 http/https)。

func WithCheckHostFunc added in v0.25.0

func WithCheckHostFunc(fn func(host string) error) Option

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

func WithCheckIPFunc(fn func(net.IP) error) Option

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

func WithExactHosts(hosts ...string) Option

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 是实际拨号地址(默认端口 已补齐),按字面比对会误杀正常配置。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL