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
- Variables
- func CheckChainWith(start string, hops []string, allow Allow, maxHops int) error
- func CheckChainWithCtx(ctx context.Context, start string, hops []string, allow Allow, maxHops int) error
- func CheckURLWith(rawURL string, allow Allow) error
- func CheckURLWithCtx(ctx context.Context, rawURL string, allow Allow) error
- func DialFunc(next func(ctx context.Context, network, addr string) (net.Conn, error), ...) func(ctx context.Context, network, addr string) (net.Conn, error)
- func IsTerminalLocation(location string) bool
- func NewClient(base *http.Client, allow Allow) (*http.Client, error)
- func NewTransport(base *http.Transport, allow Allow) *http.Transport
- func PolicyGeneration() uint64
- func ResolveLocation(current, location string) (string, error)
- func SetExtraBlockedHostSuffixes(suffixes ...string) error
- func SetExtraBlockedHostnames(names ...string)
- func SetPolicy(hostnames, suffixes []string) error
- func Unsubscribe(h Subscription)
- type Allow
- type BlockedNameError
- type DialOptions
- type HopDeniedError
- type Policy
- type Subscription
Constants ¶
const DefaultMaxHops = 10
DefaultMaxHops 缺省最大跳转跳数。超过即拒绝(防跳转环)。
Variables ¶
var ErrBlockedName = errors.New("出站主机名命中拦截名单")
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 本该无人跟随, 现在却仍被逐跳校验,结论看上去一切正常,畸形链因此隐身。
var ErrInvalidBlockedSuffix = errors.New("扩展拦截后缀非法")
ErrInvalidBlockedSuffix 扩展后缀非法(注入期拒绝)。errors.Is 判定。
var ErrNilSubscriber = errors.New("扩展策略订阅回调为空")
ErrNilSubscriber 订阅回调为空。
登记一个必然 panic 的订阅者毫无意义,故直接报错并返回零句柄:调用方能**凭返回值** 知道订阅没生效,而不是等到第一次变更时才发现「原来一直没跟上」。
判在**本包**而不是 subhub:三个使用方各有自己的哨兵与语境文案,共用一个会让 `errors.Is(err, netguard.ErrNilSubscriber)` 这类既有调用点判定链断掉。
var ErrTooManyHops = errors.New("重定向跳数超限")
ErrTooManyHops 跳转跳数超限。
Functions ¶
func CheckChainWith ¶
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 ¶
CheckURLWith 校验出站 URL 是否允许平台代发。
放行面用具名的 Allow(见 allow.go):私网与回环是两个独立开关,零值即全拒。 外部输入面(平台代发给他人的目标)用零值;探测自己部署的服务用 AllowSelfService()——那个名字把意图留在了代码里。
func CheckURLWithCtx ¶
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 ¶
IsTerminalLocation 该 Location 值表示「没有下一跳」(空或全空白)。
func NewClient ¶
NewClient 返回一个挂了出站守卫的 HTTP 客户端。
base 为 nil 时用 http.DefaultClient 的形状(零值 client)。base 的其余字段 (Timeout / Jar / CheckRedirect)原样保留。
base.Transport 若是 *http.Transport 则克隆并挂上守卫;若是自定义 RoundTripper 则**无法**挂拨号层守卫(拨号发生在传输层内部),此时返回错误——而不是悄悄 放行。「悄悄放行」的失败形态是「守卫看起来接上了,实际没接」。
base.Transport 为 nil 时用 http.DefaultTransport 的克隆。
func NewTransport ¶
NewTransport 返回一个挂了出站守卫拨号器的传输层。
base 为 nil 时克隆 http.DefaultTransport。base 自己的 DialContext 会被守卫 **包住**(守卫叠加在它之上,不是替换它)——宿主已有的自定义拨号器 (代理、连接池参数)不会丢。
守卫只能挂在传输层:拨号发生在传输层内部,绕开传输层就没有拨号层防线。
func PolicyGeneration ¶
func PolicyGeneration() uint64
PolicyGeneration 当前策略代次。调用方可在前后各取一次,变化即说明期间换过策略。
代次**单调且不回卷**——清空策略也不归零。若清空会回卷,那么 「换上一份 → 清空 → 再换上一份」与「什么都没换」在代次上不可区分, 可观测性就白做了。
代次只有一个真相:策略指针内部的 gen。**不再有独立的全局计数器**—— 两者并存时它们会短暂不一致,而「快照代次 == 全局代次」这条承诺 恰恰是调用方据以判断「我读的这段时间里换过没有」的依据。
func ResolveLocation ¶
ResolveLocation 把一跳的 Location 值解析成绝对 URL。
必须解析而不是直接送 CheckURL 的理由:Location 头**允许相对地址** (`/next`、`../up`、`//other.host/x`)。相对地址直接送 CheckURL 会因缺 host 被拒——那看似 fail-closed 正确,实际上会诱导调用方「只校验第一跳就放行后续」, 反而把防护降级了。协议相对地址 `//host/x` 更危险:它换了 host, 不解析就看不出跨主机跳转。
空 Location(空串或全空白)**不解析**为 current,而是返回 ErrEmptyLocation: 「没有下一跳」不是一个 URL,解析成「跳向自己」等于凭空造一跳。调用方据此终止 跟随(参考实现即把该 3xx 响应原样返回,不当作错误)。
func SetExtraBlockedHostSuffixes ¶
SetExtraBlockedHostSuffixes 注册/清空扩展后缀名单(不传即清空)。
全量校验通过后才替换:任一项非法则**旧策略原样保留**,不做部分应用—— 半套名单比旧名单更难推理。校验规则与理由见 SetPolicy。
本函数只改后缀、保留当前主机名,整体仍是一次原子交换;需要同时改两项请用 SetPolicy。 保留主机名的读同样在 CAS 重试循环内部(并发下不丢更新),理由见 SetExtraBlockedHostnames。
func SetExtraBlockedHostnames ¶
func SetExtraBlockedHostnames(names ...string)
SetExtraBlockedHostnames 注册/清空扩展精确主机名名单(不传即清空)。
与默认名单**并集**生效,只能加不能减;默认名单刻意不做成可写变量:一个 `=` 赋值 就能把元数据名拦截悄悄摘掉,而这种改动没有任何编译期或运行期信号。
归一化与判定侧同口径(小写 + 去尾点 + 去空白),调用方不必自己保证大小写—— 与默认名单一样,`Foo.Example.` 和 `foo.example` 是同一个名字。空项直接丢弃。
本函数只改扩展名单、保留当前后缀,整体仍是一次原子交换;需要同时改两项请用 SetPolicy(两次调用之间存在撕裂窗口,且后一次校验失败不会回滚前一次)。
保留后缀的读发生在 CAS 重试循环**内部**:并发写者若同时改了后缀, 本函数基于最新值重算,不会用陈旧副本把它覆盖回去。旧实现是 「先 CurrentPolicy() 读出来、再 SetPolicy 写回」,两步之间有窗口, 并发下会静默丢掉对方的改动。
func SetPolicy ¶
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 ¶
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 ¶
BlocksHostname 该精确主机名是否在扩展精确名单内(已归一化比较)。
func (*Policy) BlocksSuffix ¶
BlocksSuffix 返回命中的扩展后缀与是否命中。命中即说明该主机名被该后缀覆盖。
func (*Policy) Generation ¶
Generation 本策略的代次。单调递增,每次成功交换 +1;0 表示从未换过(即内置默认)。
type Subscription ¶
type Subscription uint64
Subscription 订阅句柄,传给 Unsubscribe 注销。0 表示无效。
func Subscribe ¶
func Subscribe(fn func(*Policy)) (Subscription, error)
Subscribe 登记一个策略变更订阅者,返回句柄与是否成功。
为什么注册完立刻回调一次当前快照 ¶
不这么做,订阅者对「现在生效的是什么」一无所知,要等到下一次变更才知道。热重载 跑在长生命周期进程里的典型形态恰恰是「注册完之后很久没有下一次变更」,于是订阅者 带着一个从没填过的视图上线——比没有订阅更危险,因为它以为自己已经跟上了。
注册先于立即回调:宁可让订阅者多收一次(立即回调给旧快照、紧接着的变更通知给新 快照,顺序仍单调向前),也不能在两者之间漏掉一次变更。
立即回调同样在锁外调用、panic 同样被 recover,与变更通知完全一致。