onesvm-browser-server/server/internal/policy/ssrf.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

164 lines
5.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package policy 出站策略引擎:SSRF fail-closed 守卫、域名规则 trie、robots 缓存。
//
// 复用声明:SSRF 私网判定逻辑移植自 bench/trafilatura-http/app.py `_BLOCKED_NETS`/
// `_forbidden_host`/`_guard_url`(Go 版语义等价 + 每次重定向重验,design-arch §5.4)。
package policy
import (
"context"
"fmt"
"net"
"net/url"
"strings"
"time"
)
// blockedNets 禁止出站的网段(bench app.py _BLOCKED_NETS 全集,勿增删顺序):
// 0.0.0.0/8、RFC1918、127/8、169.254/16(链路本地/云 metadata)、::1、fc00::/7、fe80::/10。
var blockedNets = func() []*net.IPNet {
cidrs := []string{
"0.0.0.0/8",
"10.0.0.0/8",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.168.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
out := make([]*net.IPNet, 0, len(cidrs))
for _, c := range cidrs {
_, n, err := net.ParseCIDR(c)
if err != nil {
panic(fmt.Sprintf("policy: 解析内建 CIDR %s 失败: %v", c, err))
}
out = append(out, n)
}
return out
}()
// blockedHosts 云 metadata / K8s 服务域(bench app.py _BLOCKED_HOSTS + 同形扩展)。
var blockedHosts = map[string]bool{
"metadata.google.internal": true,
"metadata.google.com": true,
"kubernetes.default.svc": true,
}
// DeniedError 合规拦截错误(rule_id 供审计)。
type DeniedError struct {
RuleID string
Reason string
}
// Error 实现 error。
func (e *DeniedError) Error() string { return "denied:" + e.RuleID + ": " + e.Reason }
// Guard SSRF 守卫。resolver 可注入(测试用),nil 用 net.DefaultResolver。
type Guard struct {
Resolver *net.Resolver
}
// allowedPort 仅 80/443(design-arch §5.4 请求侧)。
func allowedPort(port string) bool {
return port == "" || port == "80" || port == "443"
}
// CheckURL 校验 URL 合法性(协议/端口/host),不做 DNS(分离以支持每跳重验)。
// 返回 DeniedError 即拦截。
func (g *Guard) CheckURL(rawURL string) *DeniedError {
u, err := url.Parse(rawURL)
if err != nil {
return &DeniedError{RuleID: "ssrf_parse", Reason: "URL 解析失败"}
}
if u.Scheme != "http" && u.Scheme != "https" {
return &DeniedError{RuleID: "ssrf_scheme", Reason: "仅允许 http/https"}
}
host := u.Hostname()
if host == "" {
return &DeniedError{RuleID: "ssrf_nohost", Reason: "缺少 host"}
}
if !allowedPort(u.Port()) {
return &DeniedError{RuleID: "ssrf_port", Reason: "仅允许 80/443 端口"}
}
return g.CheckHost(host)
}
// CheckHost 校验 host(字面量与黑名单层;DNS 解析层由 ResolveCheck 承担)。
func (g *Guard) CheckHost(host string) *DeniedError {
h := strings.ToLower(strings.Trim(host, "[]"))
if blockedHosts[h] || strings.HasSuffix(h, ".internal") || strings.HasSuffix(h, ".local") {
return &DeniedError{RuleID: "ssrf_metadata_host", Reason: "禁内网/元数据域名"}
}
// localhost / *.localhost 解析恒为回环(RFC 6761),DNS 前即拦。
if h == "localhost" || strings.HasSuffix(h, ".localhost") {
return &DeniedError{RuleID: "ssrf_private_ip", Reason: "禁 localhost"}
}
if ip := net.ParseIP(h); ip != nil {
return g.checkIP(ip)
}
return nil
}
// ResolveCheck DNS 解析后逐 IP 校验(bench app.py:解析结果逐一比对 _BLOCKED_NETS)。
// 防域名解析到私网/元数据 IP 的绕过。fail-closed:解析失败即拒绝。
func (g *Guard) ResolveCheck(ctx context.Context, host string) *DeniedError {
if d := g.CheckHost(host); d != nil {
return d
}
if ip := net.ParseIP(strings.ToLower(strings.Trim(host, "[]"))); ip != nil {
return nil // 字面量 IP 已在 CheckHost 判过
}
resolver := g.Resolver
if resolver == nil {
resolver = net.DefaultResolver
}
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
addrs, err := resolver.LookupIPAddr(cctx, host)
if err != nil {
return &DeniedError{RuleID: "ssrf_dns_fail", Reason: "DNS 解析失败(fail-closed)"}
}
if len(addrs) == 0 {
return &DeniedError{RuleID: "ssrf_dns_empty", Reason: "DNS 无记录"}
}
for _, addr := range addrs {
if d := g.checkIP(addr.IP); d != nil {
return d
}
}
return nil
}
// checkIP 单 IP 私网判定(bench app.py 循环体)。
func (g *Guard) checkIP(ip net.IP) *DeniedError {
for _, n := range blockedNets {
if n.Contains(ip) {
return &DeniedError{RuleID: "ssrf_private_ip", Reason: fmt.Sprintf("私网 IP %s", ip)}
}
}
// IPv4-mapped IPv6(::ffff:10.0.0.1)归一后再判一次。
if ip4 := ip.To4(); ip4 != nil && !isV4(ip) {
for _, n := range blockedNets {
if n.Contains(ip4) {
return &DeniedError{RuleID: "ssrf_private_ip", Reason: fmt.Sprintf("私网 IP %s", ip4)}
}
}
}
return nil
}
func isV4(ip net.IP) bool { return ip.To4() != nil && strings.Contains(ip.String(), ".") }
// CheckRedirect 每跳重验(design-arch §5.4:每次重定向重验)。
// httpx Client 的 CheckRedirect hook 每跳调用:新 URL 先 CheckURL 再 ResolveCheck。
func (g *Guard) CheckRedirect(ctx context.Context, rawURL string) *DeniedError {
if d := g.CheckURL(rawURL); d != nil {
return d
}
u, err := url.Parse(rawURL)
if err != nil {
return &DeniedError{RuleID: "ssrf_parse", Reason: "重定向 URL 解析失败"}
}
return g.ResolveCheck(ctx, u.Hostname())
}