egress

package
v0.23.0 Latest Latest
Warning

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

Go to latest
Published: Nov 7, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package egress 出口围栏:LLM 可控 URL 的字面量层 SSRF 防护——阻断指向 本机/私网/保留段的 IP 字面量与 localhost 域,放行普通域名。

⚠️ **域名 = 零 DNS 防护**:Check / CheckHost 系列对域名不做解析,私网/ 云元数据/CGNAT 等 IP 策略可被「解析到内网的域名」全量绕过,也没有任何 DNS-rebinding 防护。**LLM 可控 URL 的场景请用 CheckResolving / CheckHostResolving**(域名解析后逐 IP 过同一套策略,解析失败与命中 禁止段都 fail-closed,并返回已检地址供调用方钉住拨号),或叠加 Policy.NewClient 的拨号层守卫。放行域名是 preflight 快筛的取舍,不是 安全结论——把 Check 当「URL 安全检查」用等于零防护。

对标 ZCode webfetch-egress-guard.ts:

  • 只做字面量判定,不对域名做 DNS preflight——部分网络 1s 内解析不完会 误杀公网目标(解析层防护由消费方按需叠加,如自定义 DialContext 的 Control 钩子二次校验解析结果;需要本包出解析判定时用 CheckResolving);
  • 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

View Source
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 Check

func Check(rawURL string) error

Check 校验完整 URL 的 host(解析失败 fail-closed)。

⚠️ 只校验 http(s)(scheme 白名单见 checkScheme)且**不解析域名**—— 域名可被解析到内网,LLM 可控 URL 场景请用 CheckResolving。

func CheckHost

func CheckHost(host string) error

CheckHost 校验 host(URL hostname 或去端口/括号后的 host 字面量)。 空值 fail-closed;非 IP 字面量(域名)放行——见包注释的 preflight 取舍。

⚠️ 域名不解析=对「解析到内网的域名」零防护,LLM 可控 URL 场景请用 CheckHostResolving。

func CheckHostResolving added in v0.20.0

func CheckHostResolving(ctx context.Context, host string, p Policy, r Resolver) ([]netip.Addr, error)

CheckHostResolving 校验 host,域名解析后全部结果逐个过 IP 策略 (语义与 fail-closed 口径见 CheckResolving)。

func CheckHostWith

func CheckHostWith(host string, p Policy) error

CheckHostWith 校验 host(放行面语义见 Policy;放行位仅作用于私网与 回环/未指定两族;链路本地(含云元数据)、组播、CGNAT 与保留段一律拒绝—— 放行面只该覆盖「目标在内网是合法场景」的那部分)。

func CheckResolving added in v0.20.0

func CheckResolving(ctx context.Context, rawURL string, p Policy, r Resolver) ([]netip.Addr, error)

CheckResolving 校验完整 URL,域名解析后**全部解析结果逐个过 IP 策略**。

这是 LLM 可控 URL 场景应使用的入口:Check 系列对域名不解析,私网/云元数据/ CGNAT 等 IP 策略可被「解析到内网的域名」(如 169.254.169.254.nip.io)全量 绕过,也没有任何 DNS-rebinding 防护。

语义(全链路 fail-closed):

  • scheme 必须 http/https(与 Check 同一白名单);
  • IP 字面量 host 走与 CheckHost 相同的字面量判定,返回该地址本身;
  • 域名 host 经 r(nil 时用 net.DefaultResolver)解析,**任何一条**解析 结果命中策略即阻断;解析失败、解析结果为空同样阻断——「解析不出来」 与「解析到禁止 IP」都是不可放行的未知,放行任何一个都是把围栏的 判定权交给网络环境;
  • 返回值是**已全部过检**的地址集合:调用方拨号时钉住其中之一 (而非重新解析),才能闭合「检查后换解析」的 TOCTOU。

注意:放行≠承诺之后的解析不变。攻击者控制权威 DNS 时可在检查与拨号之间 换答案(DNS rebinding)——拨号侧用返回的地址钉住,或叠加 Policy.NewClient 的拨号层守卫,二者至少取一。

func CheckWith

func CheckWith(rawURL string, p Policy) error

CheckWith 校验完整 URL 的 host(放行面语义见 Policy;解析失败 fail-closed)。

⚠️ 只校验 http(s)(scheme 白名单见 checkScheme)且**不解析域名**—— 域名可被解析到内网,LLM 可控 URL 场景请用 CheckResolving。

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

func (p Policy) Check(rawURL string) error

Check 校验完整 URL 的 host(放行面 = 本值;解析失败 fail-closed)。

与 CheckWith 等价——同一个值派生,语义不可能与写面分叉。 ⚠️ 不解析域名(零 DNS 防护),LLM 可控 URL 场景用 CheckResolving。

func (Policy) CheckHost added in v0.15.0

func (p Policy) CheckHost(host string) error

CheckHost 校验 host(放行面 = 本值;空值 fail-closed;非 IP 字面量放行)。

与 CheckHostWith 等价——同一个值派生,语义不可能与写面分叉。 ⚠️ 不解析域名(零 DNS 防护),LLM 可控 URL 场景用 CheckHostResolving。

func (Policy) CheckRedirect added in v0.22.0

func (p Policy) CheckRedirect(req *http.Request, via []*http.Request) error

CheckRedirect 逐跳判定,直接赋给 http.Client.CheckRedirect。

它补的是哪一半

写面(Check / CheckWith)只判**首跳 URL**;NewClient 的拨号层守卫兜住了重定向 目标,但它有两个结构性盲点:

  • **调用方自带的传输器不是 *http.Transport**(otelhttp / 重试 / 认证包装都是 RoundTripper 包装)⇒ NewClient 直接返回错误,调用方在本包内没有任何逐跳路径;
  • **走代理**(Transport.Proxy)时拨号地址是**代理地址** ⇒ 拨号层看不到目标。 这正是 NewClient 注释里写明的边界(「走了代理时目标侧判定仍要靠写面」), 而重定向时**写面只判了首跳**——于是这一段是裸的。

跳数上限为什么必须在这里承担

`http.Client` 一旦设置了 CheckRedirect,**就不再套用自己的默认 10 跳上限**。所以 一个「只判 URL 不判跳数」的 CheckRedirect 等于顺手把默认的跳数保护关掉—— 上限必须在同一个函数里。这里固定取 net/http 的默认值 10;要换上限就自己包一层 (先判 `len(via)` 再调本函数),本包**不另开一个上限旋钮**:两个上限并存时谁 生效取决于调用点,口径必然漂。

用法

c := p.MustClient(base)                 // 拨号层守卫(非代理场景已足够)
c.CheckRedirect = p.CheckRedirect       // 代理场景 / 自定义传输器的逐跳兜底

两者同时挂是**纵深**,不是二选一:一次重定向会先过 CheckRedirect,真正拨号时再过 拨号层守卫——两层判的是同一份放行面,故不会出现「一层放行、一层拒」的假红。

func (Policy) CheckResolving added in v0.20.0

func (p Policy) CheckResolving(ctx context.Context, rawURL string, r Resolver) ([]netip.Addr, error)

CheckResolving 解析校验完整 URL:域名解析后全部结果逐个过本放行面的 IP 策略(语义与 fail-closed 口径见包级 CheckResolving)。

与包级 CheckResolving 等价——同一个值派生,写面与拨号层不可能分叉。

func (Policy) MustClient added in v0.15.0

func (p Policy) MustClient(base *http.Client) *http.Client

MustClient 同 NewClient,出错即 panic(适合包级初始化等不容错的场景)。

func (Policy) NewClient added in v0.15.0

func (p Policy) NewClient(base *http.Client) (*http.Client, error)

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

type Resolver added in v0.20.0

type Resolver interface {
	LookupIP(ctx context.Context, network, host string) ([]net.IP, error)
}

Resolver 域名解析的最小接口:*net.Resolver 天然满足,测试与特殊网络 环境注入 stub 也只需要实现这一个方法。

Jump to

Keyboard shortcuts

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