Documentation
¶
Overview ¶
Package egress 出口围栏:LLM 可控 URL 的字面量层 SSRF 防护——阻断指向 本机/私网/保留段的 IP 字面量与 localhost 域,放行普通域名。
对标 ZCode webfetch-egress-guard.ts:
- 只做字面量判定,不对域名做 DNS preflight——部分网络 1s 内解析不完会 误杀公网目标(解析层防护由消费方按需叠加,如自定义 DialContext 的 Control 钩子二次校验解析结果);
- IPv4-mapped IPv6(::ffff:0:0/96)与 NAT64 well-known prefix (64:ff9b::/96)先还原内嵌 IPv4 再套同一套 IPv4 策略——这是绕过 IP 围栏的经典逃逸路径;
- 显式排除 special-use IPv6 段(ORCHID/discard/benchmark/文档段)—— 标准库 IsPrivate 等判定不覆盖这些段。
放行面与拨号层 ¶
放行面用 Policy(**具名值**)表达:写面校验(Check / CheckHost)与拨号层守卫 (Policy.NewClient / Policy.MustClient)从**同一个值**派生——两层口径不可能 不一致(Options 的双 bool 做不到:语义只能靠调用点口头约定区分,值一传岔 就出「校验层放行、拨号层拒」的假红或反过来的安全问题)。
拨号层判的是**拨号地址的字面量**,用的是与写面同一个判定函数,不做 DNS 解析层判定(与本包「只做字面量判定」的既有口径一致)。
用法:
if err := egress.Check(u); err != nil { …拒绝抓取… }
// 同一份放行面同时管住写面与拨号层:
p := egress.PrivateNoLoopback
if err := p.Check(u); err != nil { …拒绝抓取… }
c := p.MustClient(base)
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ( // PublicOnly 只放公网(目标完全由用户填的场景)——零值口径。 PublicOnly = Policy{} // PrivateNoLoopback 放行私网、拒回环(内网自建服务是合法场景)。 PrivateNoLoopback = Policy{AllowPrivate: true} // SelfService 私网与回环都放行(探测本机自建服务)。 SelfService = Policy{AllowPrivate: true, AllowLoopback: true} )
具名放行面(常用组合)。
⚠️ 这些是**包级变量**:可以读,但**不要重新赋值**——改它们等于全局改掉所有 消费方的放行面(Policy 是值类型,要变体时自己构造副本:
p := egress.PrivateNoLoopback p.AllowLoopback = true
).
Functions ¶
func CheckHost ¶
CheckHost 校验 host(URL hostname 或去端口/括号后的 host 字面量)。 空值 fail-closed;非 IP 字面量(域名)放行——见包注释的 preflight 取舍。
func CheckHostWith ¶
CheckHostWith 校验 host(放行面语义见 Policy;放行位仅作用于私网与 回环/未指定两族;链路本地(含云元数据)、组播、CGNAT 与保留段一律拒绝—— 放行面只该覆盖「目标在内网是合法场景」的那部分)。
Types ¶
type BlockedError ¶
type BlockedError struct {
Host string // 原始 host(URL Hostname 形态)
Addr string // 命中判定的地址(载体还原后的字面量;域名为空)
Reason string // 命中原因(人可读)
}
BlockedError 围栏命中(结构化:调用方按需呈现原因与目标)。
func (*BlockedError) Error ¶
func (e *BlockedError) Error() string
type Options ¶
type Options = Policy
Options 旧名(兼容别名)。
已由 Policy 取代:Policy 是本体,Options 只是旧名——保留它是为了让既有调用点 继续编译(egress.Options{AllowPrivate: true} 与 egress.Options{} 都照旧可用), 语义与 Policy 完全一致(**同一个类型**,不是两套并行表示)。
新代码请用 Policy + 具名放行面(PublicOnly / PrivateNoLoopback / SelfService)。
type Policy ¶ added in v0.15.0
type Policy struct {
// AllowPrivate 放行私网段(IPv4 RFC1918 / IPv6 唯一本地)。
AllowPrivate bool
// AllowLoopback 放行回环与未指定地址(仅 127.0.0.0/8、::1、0.0.0.0、::
// 四个字面量)。0.0.0.0/8 余下整段是 RFC 1122 的 "this network" 保留段,
// 与 CGNAT/benchmark 同列——不在放行面内。
AllowLoopback bool
}
Policy 放行面(**具名值**)。
为什么要有这个类型 ¶
Options 的两个 bool 位**读不出意图**:同样的 {AllowPrivate: true, AllowLoopback: false} 在不同调用点可能表达不同场景(内网自建服务 / 回调地址 / 探测本机), 而值一旦传岔,就会出两种故障:
- 校验层放行私网、拨号层按缺省拒 ⇒ 假红(「配置明明允许内网,调用却失败」);
- 校验层拒、拨号层放行 ⇒ **安全问题**(写面拒了,拨号层没拒)。
Policy 把放行面收成**一个值**:写面校验(Check / CheckHost)与拨号层守卫 (NewClient / MustClient)都从同一个值派生 ⇒ **结构上没法不一致**。
与 Options 的关系 ¶
Policy 是本体,Options 是**旧名的兼容别名**(同一个类型,不是两套表示): 既有的 egress.Options{AllowPrivate: true} / egress.Options{} 调用点照旧编译, 语义逐字相同。新代码请用 Policy 与下面的具名放行面。
零值口径 ¶
零值 = 两个放行位全关 = 只放公网与域名(与 Check / CheckHost 的缺省口径一致), 即 PublicOnly。
func (Policy) Check ¶ added in v0.15.0
Check 校验完整 URL 的 host(放行面 = 本值;解析失败 fail-closed)。
与 CheckWith 等价——同一个值派生,语义不可能与写面分叉。
func (Policy) CheckHost ¶ added in v0.15.0
CheckHost 校验 host(放行面 = 本值;空值 fail-closed;非 IP 字面量放行)。
与 CheckHostWith 等价——同一个值派生,语义不可能与写面分叉。
func (Policy) MustClient ¶ added in v0.15.0
MustClient 同 NewClient,出错即 panic(适合包级初始化等不容错的场景)。
func (Policy) NewClient ¶ added in v0.15.0
NewClient 派生一个把**同一放行面**落到拨号层的 *http.Client。
拨号层判的是什么 ¶
拨号层用的是与写面**同一个字面量判定**(Policy.CheckHost):拨号地址的 host 是 IP 字面量时按放行面判;是域名时放行——与本包「只做字面量判定、不做 DNS preflight」的既有口径一致。
刻意**不做**「连上之后核对实际对端地址」:那会让拨号层比写面**更严** (写面放行域名、拨号层拒它的解析结果),两层口径就又不一致了—— 那正是本类型要消灭的故障形态。解析层防护仍由消费方按需叠加。
拨号层多出来的价值 ¶
- **重定向**:http.Client 默认跟随重定向,而写面只校验了**最初那个 URL**; 真正被拨号的是重定向目标 ⇒ 由拨号层兜住。
- **漏掉写面校验的调用方**:拨号层仍然拦住 IP 字面量目标。
边界(有意不覆盖的) ¶
- 走了代理(Transport.Proxy)时,拨号地址是**代理地址**而不是目标地址 ⇒ 目标侧的判定仍要靠写面 p.Check(url)。
- 域名解析到私网(DNS 层)不在本包判定面内——与包的既有口径一致。
参数与错误 ¶
base 为 nil 时按 http.DefaultClient 取;**不修改** base,也不修改全局默认传输器 (默认传输器走 Clone)。base.Transport 不是 *http.Transport 时挂不上拨号守卫 ⇒ 返回错误(用 MustClient 则 panic)。