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

urlguard

package
v0.23.0 Latest Latest
Warning

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

Go to latest
Published: Oct 23, 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 解析器)会当地址——写面放行等于把判定推给拨号层,构成回环绕过路径。

构造态豁免: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

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 再判定。

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 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