netguard

package
v0.31.0 Latest Latest
Warning

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

Go to latest
Published: Nov 11, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package netguard 出站 URL 守卫(SSRF 防护):凡是「服务端代发 HTTP」的出口都统一 过闸——scheme 白名单 + 目标地址解析后逐 IP 过策略 (IP 策略单源 oagentkit/guardx/egress:回环/未指定按 allowLoopback、RFC1918 私网按 allowPrivate 放行,链路本地(含云元数据)/组播/CGNAT/保留段一律拒绝——放行面 只覆盖「目标落在内网或回环上是合法场景」的那部分;CGNAT 等保留段自此比旧版收紧)。 allowLoopback=false / allowPrivate=false 时对应族一并拒绝——面向**不可信输入** 的出口取这一档(零值即此档);面向**可信目标**的访问传 (true, true),因为能否 抵达内网/回环本身就是那个调用的能力,其前提是这类调用的写面另有鉴权约束 (鉴权属宿主职责,本包只管地址族)。

已知局限(纵深取舍):DNS 解析与实际拨号之间存在 TOCTOU(rebinding 可绕过解析期 检查)。**本批已补上拨号层的闭合**——见 dial.go 的 `DialFunc`:把同一套判定 (名字层 + egress 的 IP 策略)落到建连那一刻,并在**建连后核对真实 RemoteAddr**。 校验层(CheckURL)仍保留为**写面早失败**(配置时就报错,比等到发请求才炸好), 但它不再单独构成出站防线——出站防线在拨号层。

接线是宿主的决策:本包给的是纯判定件 + 一个可挂到 `http.Transport.DialContext` 上的包装,宿主的 HTTP 客户端用不用是宿主的事。**未接拨号层的调用路径仍有 rebinding 窗口**(写面校验与实际请求分属两次解析)。

域名只在本包解析并逐 IP 判定(不把域名交给 egress——其「localhost 域恒拒」 是 LLM 可控 URL 的字面量层语义,本包 allowLoopback=true 的自服务探测是合法 场景,经解析后按 IP 过闸即可)。

名字层(见文件下半段):IP 策略只能看见「解析出来的地址」,而解析本身就是攻击面。 故在解析之前先按名字拒一遍——省一次解析,且判定不依赖 DNS 结果。

与上游 urlguard 的分工(oekit/pkg v0.15.0 起有此包)

IP 策略**继续单源 egress**,不因 urlguard 出现而改判。但要说清理由,因为两处 清单**不是包含关系**——一度写成「urlguard 是 egress 的收敛版」是错的,已更正: egress 靠 `netip` 谓词覆盖回环/私网/链路本地/组播/未指定,只把 CGNAT、 192.0.0/24、三个 TEST-NET、198.18/15、240/4 写进显式表;urlguard 则整表列举, 多出 **`0.0.0.0/8`**。

这不是「谁更全」的问题,而是 egress 曾有一处**实证缺口**: `egress.CheckHostWith("0.1.2.3")` 放行、`urlguard` 拒绝。根因是 `netip.Addr.IsUnspecified()` 只认全零这一个地址,而 0.0.0.0/8 不在显式表里, 于是 RFC 1122 的 "this network" 段除 0.0.0.0 外全部漏判——是可用的 SSRF 路径。

2026-10-19 已由上游修复(oagentkit/guardx#2 → v0.4.1,把 `0.0.0.0/8` 补进 `ipv4ExplicitBlocks`),本包升到 v0.4.1 后直接受益,**没有在本仓私补网段表**。 `thisnetwork_test.go` 钉住修复后的行为(含 IPv4-mapped 形态),使这条缺口 不可能再靠「只测全零地址」的用例蒙过去。

顺带记一条上游的刻意契约,两者别混:`0.0.0.0` 这个**全零字面量**跟回环同组、 由 allowLoopback 管辖;而 `/8` 余下整段无条件拒、**不受任何放行旗标影响**。 见 egress 的 `IsUnspecified` 分支注释。

域名后缀层曾有两份且互相漂移(`.lan` 本包有 urlguard 无、`.localdomain` urlguard 有本包无),2026-10-19 已把 `.localdomain` 补进下方名单,使本包成为 urlguard 的 严格超集。此后增删后缀仍须先比对:urlguard 那边不含 `.lan`(它是局域网约定域 而非 RFC 保留段),本包这处是权威,**不要把 urlguard 引进来当第二份权威**。

urlguard 的 `DialFunc(next)` 也能闭合上面「已知局限」记的 rebinding 窗口(预检 每个解析 IP + 建连后核对真实 RemoteAddr),语义与本包 dial.go 的 `DialFunc` 几乎相同。

但**不要用它**:它带**自己的一份 IP 清单**,而本包的 IP 策略单源在 egress (见上)。用上游那份等于引入**第二套 IP 策略**——两份清单今天一致,明天上游 加一段而 egress 没加,拨号层就会放行一个校验层认定危险的地址,而漂移的 形态是「静默放行」而不是「误拦」。故本包自建 dial.go 这一层,**判据仍是 egress**: 名字层 + egress 预检 + 建连后核对真实 RemoteAddr。

换句话说:闭窗口这件事由本包自己做,权威只有一份。

本包不在工具调用的判定环上,这是有意的

本包判的是「这个 URL 能不能发出去」;工具调用的判定环(oagentkit/guardx 的 invoke.Stage)判的是「这次调用要不要放行」。两者是**不同的执行点**,不要把本包 做成逐调用的一环——那需要去猜「哪个参数字段是 URL」,是领域知识,不该进内核。

这条分工成立的前提是:程序化执行(ptc 沙箱)里**程序本身够不着网络**——无 fetch / XMLHttpRequest / WebSocket,出站只可能发生在宿主绑定的实现里,而那是宿主自己的代码。 该前提由 ptc/sandbox_test.go 的用例钉住;哪天沙箱里出现了网络原语,就得重新回答 「本包该接在哪」,而不是让程序自己就能出站。

Index

Constants

View Source
const DefaultMaxHops = 10

DefaultMaxHops 缺省最大跳转跳数。超过即拒绝(防跳转环)。

Variables

View Source
var ErrBlockedName = errors.New("出站主机名命中拦截名单")
View Source
var ErrEmptyLocation = errors.New("Location 为空,重定向链在此终止")

ErrEmptyLocation 空 Location:**「链条到此终止」信号,不是「这一跳被拒」**。

3xx 响应没有 Location 时,HTTP 语义是不跟随、就把这个响应原样返回——参考实现 `web_fetch.py:26-28` 正是这么做的(`location = (...).strip()`;`if not location: return response`)。此前 ResolveLocation(current, "") 会解析成 current 自己, 于是 CheckChain 把它当成「跳向自己」:白吃一跳预算并把同一个 URL 重验一遍。 更坏的是它把一个畸形的跳转链洗成了正常链——终止点之后的 hops 本该无人跟随, 现在却仍被逐跳校验,结论看上去一切正常,畸形链因此隐身。

View Source
var ErrInvalidBlockedSuffix = errors.New("扩展拦截后缀非法")

ErrInvalidBlockedSuffix 扩展后缀非法(注入期拒绝)。errors.Is 判定。

View Source
var ErrNilSubscriber = errors.New("扩展策略订阅回调为空")

ErrNilSubscriber 订阅回调为空。

登记一个必然 panic 的订阅者毫无意义,故直接报错并返回零句柄:调用方能**凭返回值** 知道订阅没生效,而不是等到第一次变更时才发现「原来一直没跟上」。

判在**本包**而不是 subhub:三个使用方各有自己的哨兵与语境文案,共用一个会让 `errors.Is(err, netguard.ErrNilSubscriber)` 这类既有调用点判定链断掉。

View Source
var ErrTooManyHops = errors.New("重定向跳数超限")

ErrTooManyHops 跳转跳数超限。

Functions

func CheckChainWith

func CheckChainWith(start string, hops []string, allow Allow, maxHops int) error

CheckChain 逐跳重验:start 为起始 URL,hops 为按序的 Location 值(原始值, 可相对)。返回第一处被拒的跳,全部通过返回 nil。

每一跳都独立过 CheckURL(故每一跳都过名字层),且每一跳都在**解析之后**判定—— 这同时挡掉「DNS rebinding 换主机」与「协议相对地址换主机」两类绕过。

**空 Location 终止链条**:不跟随、不报错、不吃跳数预算,其后的 hops 不会被校验 (它们本就不可达)。返回值 nil 的含义是「已校验的跳全部通过」, 不代表整条 hops 都被校验过——调用方不该在终止点之后再传值。

maxHops <= 0 时取 DefaultMaxHops。hops **超过**上限即拒,不截断继续—— 注意是「超过」而非「达到」:maxHops=10 表示允许 10 次跳转(起始之外), 第 11 次才拒。反过来写成 >= 会白吃一跳预算。

## 为什么先验起始跳、再谈预算

HopDeniedError 的两个字段是给留痕用的,**必须描述一个真的校验过的 URL**。 先判预算的话,坏起始 URL + 超长链会报出 `Hop = maxHops+1`——那一跳从未被校验过, 留痕上读起来像「第 11 跳有结论」,实则整条链子的第一跳就已经是坏的。 两条错误都成立时,先报**真实且更早**的那条:起始跳非法是链的根因, 跳数超限可能只是根因的次生现象。两种结论都不丢,调用方拿到起始跳的 error 后重试或改链即可。 CheckChainWith 逐跳校验一条跳转链(具名放行面)。

放行面与 CheckURLWith、拨号层同一个类型(见 allow.go)——逐跳校验与单跳校验 必须是同一套判据,否则「起始跳放行、后续跳收紧」这种事就没人能发现了。

func CheckChainWithCtx

func CheckChainWithCtx(ctx context.Context, start string, hops []string, allow Allow, maxHops int) error

CheckChainWithCtx 是 CheckChainWith 的取消传播形态:每一跳的解析都带 ctx(见 CheckURLWithCtx)。ctx 为 nil 时等价 CheckChainWith(带 10s 上限)。

func CheckURLWith

func CheckURLWith(rawURL string, allow Allow) error

CheckURLWith 校验出站 URL 是否允许平台代发。

放行面用具名的 Allow(见 allow.go):私网与回环是两个独立开关,零值即全拒。 外部输入面(平台代发给他人的目标)用零值;探测自己部署的服务用 AllowSelfService()——那个名字把意图留在了代码里。

func CheckURLWithCtx

func CheckURLWithCtx(ctx context.Context, rawURL string, allow Allow) error

CheckURLWithCtx 是 CheckURLWith 的取消传播形态:域名解析走带 ctx 的 Resolver——调用方的关停/超时能打断一次卡住的解析(DNS 黑洞场景下 无 ctx 的 LookupIP 会把整个 Policy.Check 无期限挂住)。ctx 为 nil 时 等价 CheckURLWith(带 10s 上限)。

func DialFunc

func DialFunc(next func(ctx context.Context, network, addr string) (net.Conn, error), opts DialOptions) func(ctx context.Context, network, addr string) (net.Conn, error)

DialFunc 返回包在传输层 DialContext 上的出站守卫。

next 为 nil 时用缺省拨号器。返回值可直接赋给 http.Transport.DialContext。

func IsTerminalLocation

func IsTerminalLocation(location string) bool

IsTerminalLocation 该 Location 值表示「没有下一跳」(空或全空白)。

func NewClient

func NewClient(base *http.Client, allow Allow) (*http.Client, error)

NewClient 返回一个挂了出站守卫的 HTTP 客户端。

base 为 nil 时用 http.DefaultClient 的形状(零值 client)。base 的其余字段 (Timeout / Jar / CheckRedirect)原样保留。

base.Transport 若是 *http.Transport 则克隆并挂上守卫;若是自定义 RoundTripper 则**无法**挂拨号层守卫(拨号发生在传输层内部),此时返回错误——而不是悄悄 放行。「悄悄放行」的失败形态是「守卫看起来接上了,实际没接」。

base.Transport 为 nil 时用 http.DefaultTransport 的克隆。

func NewTransport

func NewTransport(base *http.Transport, allow Allow) *http.Transport

NewTransport 返回一个挂了出站守卫拨号器的传输层。

base 为 nil 时克隆 http.DefaultTransport。base 自己的 DialContext 会被守卫 **包住**(守卫叠加在它之上,不是替换它)——宿主已有的自定义拨号器 (代理、连接池参数)不会丢。

守卫只能挂在传输层:拨号发生在传输层内部,绕开传输层就没有拨号层防线。

func PolicyGeneration

func PolicyGeneration() uint64

PolicyGeneration 当前策略代次。调用方可在前后各取一次,变化即说明期间换过策略。

代次**单调且不回卷**——清空策略也不归零。若清空会回卷,那么 「换上一份 → 清空 → 再换上一份」与「什么都没换」在代次上不可区分, 可观测性就白做了。

代次只有一个真相:策略指针内部的 gen。**不再有独立的全局计数器**—— 两者并存时它们会短暂不一致,而「快照代次 == 全局代次」这条承诺 恰恰是调用方据以判断「我读的这段时间里换过没有」的依据。

func ResolveLocation

func ResolveLocation(current, location string) (string, error)

ResolveLocation 把一跳的 Location 值解析成绝对 URL。

必须解析而不是直接送 CheckURL 的理由:Location 头**允许相对地址** (`/next`、`../up`、`//other.host/x`)。相对地址直接送 CheckURL 会因缺 host 被拒——那看似 fail-closed 正确,实际上会诱导调用方「只校验第一跳就放行后续」, 反而把防护降级了。协议相对地址 `//host/x` 更危险:它换了 host, 不解析就看不出跨主机跳转。

空 Location(空串或全空白)**不解析**为 current,而是返回 ErrEmptyLocation: 「没有下一跳」不是一个 URL,解析成「跳向自己」等于凭空造一跳。调用方据此终止 跟随(参考实现即把该 3xx 响应原样返回,不当作错误)。

func SetExtraBlockedHostSuffixes

func SetExtraBlockedHostSuffixes(suffixes ...string) error

SetExtraBlockedHostSuffixes 注册/清空扩展后缀名单(不传即清空)。

全量校验通过后才替换:任一项非法则**旧策略原样保留**,不做部分应用—— 半套名单比旧名单更难推理。校验规则与理由见 SetPolicy。

本函数只改后缀、保留当前主机名,整体仍是一次原子交换;需要同时改两项请用 SetPolicy。 保留主机名的读同样在 CAS 重试循环内部(并发下不丢更新),理由见 SetExtraBlockedHostnames。

func SetExtraBlockedHostnames

func SetExtraBlockedHostnames(names ...string)

SetExtraBlockedHostnames 注册/清空扩展精确主机名名单(不传即清空)。

与默认名单**并集**生效,只能加不能减;默认名单刻意不做成可写变量:一个 `=` 赋值 就能把元数据名拦截悄悄摘掉,而这种改动没有任何编译期或运行期信号。

归一化与判定侧同口径(小写 + 去尾点 + 去空白),调用方不必自己保证大小写—— 与默认名单一样,`Foo.Example.` 和 `foo.example` 是同一个名字。空项直接丢弃。

本函数只改扩展名单、保留当前后缀,整体仍是一次原子交换;需要同时改两项请用 SetPolicy(两次调用之间存在撕裂窗口,且后一次校验失败不会回滚前一次)。

保留后缀的读发生在 CAS 重试循环**内部**:并发写者若同时改了后缀, 本函数基于最新值重算,不会用陈旧副本把它覆盖回去。旧实现是 「先 CurrentPolicy() 读出来、再 SetPolicy 写回」,两步之间有窗口, 并发下会静默丢掉对方的改动。

func SetPolicy

func SetPolicy(hostnames, suffixes []string) error

SetPolicy 整份替换扩展策略:**先全量校验,再一次性交换**。

## 为什么不拆成两次调用

分别调用 SetExtraBlockedHostnames 与 SetExtraBlockedHostSuffixes 时, 后者校验失败不会回滚前者——策略停在半应用状态,而且这个状态没有任何信号。 需要同时改两项时走这里:要么整份生效,要么原样保留。

## 后缀的拒绝规则

名单「不命中」是这个拦截面最坏的失败模式:`.CORP` 静默不匹配,等于挂了一个 看着在拦、实际不拦的后缀,而且没有任何信号。故校验一律发生在**注入期**:

  • 大小写 / 空白:归一,匹配侧只做 HasSuffix,不再清洗;
  • 无前导点的后缀:拒绝(`corp` 会把语义从「拦 foo.corp」悄悄变成 「拦一切以 corp 结尾的名字」——比不拦更糟);
  • 归一化后永不匹配的后缀(裸 `.`、以 `.` 结尾):拒绝。

任一项非法则**旧策略原样保留**,不做部分应用。

## 订阅者只在成功时收到通知

通知发生在本次交换真正落地之后:校验失败的路径直接 return,一次通知都不发。 发了就等于告诉订阅者「策略换过了」,而它此刻根本没换——订阅者据此重建的派生 视图会对应一份从未生效过的策略,且没有任何东西能指出这一点。

## 相对内置默认名单的关系

扩展位与内置默认名单**并集**生效,默认名单不在这张表里、也不可被覆盖: 一个 `=` 赋值就能把元数据名拦截悄悄摘掉,而这种改动没有任何编译期或运行期信号。 本函数只能增补扩展位,不能削减内置默认。

func Unsubscribe

func Unsubscribe(h Subscription)

Unsubscribe 注销订阅。重复注销同一句柄是安全的空操作。

有了它,订阅者才能在长生命周期场景下解绑:否则回调闭包连同它捕获的一切 (宿主对象、配置缓存)会被订阅表永久留住,而这条泄漏不会自己显形。

Types

type Allow

type Allow struct {
	// Private 放行私网段(RFC1918 等内网地址族)。
	Private bool
	// Loopback 放行回环族(127.0.0.0/8、::1,以及未指定地址的本地形态)。
	Loopback bool
}

Allow 描述「哪些目标地址族放行」。

**零值是安全默认**:私网与回环都拒(面向不可信输入的出口:代发的目标不该落在 内网或回环上)。要放行必须显式写出来——放行面是安全边界,默认就该是关的。

func AllowSelfService

func AllowSelfService() Allow

AllowSelfService 返回「私网与回环都放行」的放行面。

用途是**点名意图**:这组比特恰好也是 SSRF 穿透的入口,而「为什么放行」在两个 裸布尔里无处安放(本包曾有一对双 bool 包装,2026-11-06 删除)。名字把意图 留在代码里,review 时看得见。

做成函数而不是可变的包级变量:放行面是安全边界,能被别处改写的全局变量 不是边界(调用方拿到的是一个**值**,改不动它)。

type BlockedNameError

type BlockedNameError struct {
	Host   string
	Reason string
}

BlockedNameError 名字层拒绝结论。带 host 与命中原因,便于留痕判断是哪个名、 按哪条规则拒的;Unwrap 出哨兵让调用方能 errors.Is 统一判定。

func (*BlockedNameError) Error

func (e *BlockedNameError) Error() string

func (*BlockedNameError) Unwrap

func (e *BlockedNameError) Unwrap() error

type DialOptions

type DialOptions struct {
	// Allow 放行面——与校验层(CheckURLWith / CheckChainWith)**同一个类型**,
	// 同一份语义。零值即私网与回环全拒。
	Allow Allow
	// Dialer 底层拨号器。nil 时用带 10s 超时的 net.Dialer 缺省。
	Dialer *net.Dialer
	// Lookup 主机解析器。nil 时用 net.DefaultResolver.LookupIP;测试可注入
	// 假解析器来模拟「解析结果在校验与建连之间改变」。
	Lookup func(ctx context.Context, host string) ([]net.IP, error)
}

DialOptions 拨号层守卫的策略选项。

type HopDeniedError

type HopDeniedError struct {
	Hop int    // 1 = 起始 URL;2.. = 第 n-1 次跳转的目标
	URL string // 被拒的那一跳
	Err error  // 底层原因(CheckURL 的错误,或 ErrTooManyHops)
}

HopDeniedError 某一跳被拒。带跳号与该跳 URL,便于留痕定位是哪一跳出的问题 (只报「URL 不合法」而不知道跳号,等于没报)。

func (*HopDeniedError) Error

func (e *HopDeniedError) Error() string

func (*HopDeniedError) Unwrap

func (e *HopDeniedError) Unwrap() error

type Policy

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

Policy 名字层拦截策略的一份**不可变**快照。

构造后不再被任何写侧触碰(写侧只整体替换新实例),故取出后可安全长期持有与 并发遍历,无需再加锁。

func CurrentPolicy

func CurrentPolicy() *Policy

CurrentPolicy 取当前生效策略的快照。

返回的指针在整个进程生命周期内**只读**(没有任何写侧会 mutate 它),因此可以 安全地长期持有、并发读,甚至与后续的交换并存。零值状态(从未注册过扩展)返回 一个空策略而非 nil——调用方不必判空。

func (*Policy) BlocksHostname

func (p *Policy) BlocksHostname(h string) bool

BlocksHostname 该精确主机名是否在扩展精确名单内(已归一化比较)。

func (*Policy) BlocksSuffix

func (p *Policy) BlocksSuffix(h string) (string, bool)

BlocksSuffix 返回命中的扩展后缀与是否命中。命中即说明该主机名被该后缀覆盖。

func (*Policy) Generation

func (p *Policy) Generation() uint64

Generation 本策略的代次。单调递增,每次成功交换 +1;0 表示从未换过(即内置默认)。

type Subscription

type Subscription uint64

Subscription 订阅句柄,传给 Unsubscribe 注销。0 表示无效。

func Subscribe

func Subscribe(fn func(*Policy)) (Subscription, error)

Subscribe 登记一个策略变更订阅者,返回句柄与是否成功。

为什么注册完立刻回调一次当前快照

不这么做,订阅者对「现在生效的是什么」一无所知,要等到下一次变更才知道。热重载 跑在长生命周期进程里的典型形态恰恰是「注册完之后很久没有下一次变更」,于是订阅者 带着一个从没填过的视图上线——比没有订阅更危险,因为它以为自己已经跟上了。

注册先于立即回调:宁可让订阅者多收一次(立即回调给旧快照、紧接着的变更通知给新 快照,顺序仍单调向前),也不能在两者之间漏掉一次变更。

立即回调同样在锁外调用、panic 同样被 recover,与变更通知完全一致。

Jump to

Keyboard shortcuts

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