← 中文资源

简体中文版本

SSRF 出站请求白名单防护

防 SSRF 的三重边界:URL 解析层校验协议与主机白名单、DNS 解析后校验非私网地址、重定向禁止跳出白名单域。

阅读英文原文

示例说明

用户可控的 URL 是经典 SSRF 入口:攻击者传入云元数据地址或内网服务地址,让服务器替他访问禁区。字符串层白名单不够——域名解析后的 IP 才是真相,DNS rebinding 能让两次解析结果不同。纵深方案是三道关卡:解析时锁协议与主机、拨号时解析并校验 IP 段、重定向时重新校验目标域。

技术要点

  • URL 层锁定 https 与主机白名单
  • 拨号层校验解析结果的 IP 段
  • 重定向跨域即拒绝

代码解读

validateTarget 拒绝非 https 与未登记主机;dialGuard 自定义拨号器把域名解析为 IP 后逐一比对封锁网段——环回、私网、链路本地与元数据段全覆盖,校验通过才真正拨号。CheckRedirect 只允许同主机跳转,跨域重定向直接拒绝。示例演示合法请求放行与元数据端点被拦。

使用建议

拨号层校验与 URL 层校验缺一不可,DNS rebinding 依赖两者的缝隙。重定向次数也要硬性上限。白名单变更走安全评审流程。

代码示例

// SSRF guard: validate every serverSide request target before dialing
package main

import (
	"context"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"strings"
	"time"
)

var blockedZones = []string{
	"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12",
	"192.168.0.0/16", "169.254.0.0/16", "::1/128", "fc00::/7",
}

var allowedHosts = map[string]bool{
	"api.partner.com": true, "cdn.partner.net": true, "hooks.vendor.io": true,
}

func validateTarget(raw string) (*url.URL, error) {
	parsed, err := url.Parse(raw)
	if err != nil {
		return nil, fmt.Errorf("parse url: %w", err)
	}

	if parsed.Scheme != "https" {
		return nil, fmt.Errorf("scheme %q not allowed", parsed.Scheme)
	}
	if !allowedHosts[parsed.Hostname()] {
		return nil, fmt.Errorf("host %q is not allowlisted", parsed.Hostname())
	}
	return parsed, nil
}

func dialGuard(ctx context.Context, network, addr string) (net.Conn, error) {
	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		return nil, err
	}

	ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return nil, err
	}
	for _, ip := range ips {
		if !isPublic(ip.IP) {
			return nil, fmt.Errorf("resolved %s to private address %s", host, ip.IP)
		}
	}

	dialer := &net.Dialer{Timeout: 5 * time.Second}
	return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), port))
}

func isPublic(ip net.IP) bool {
	for _, zone := range blockedZones {
		_, network, _ := net.ParseCIDR(zone)
		if network.Contains(ip) {
			return false
		}
	}
	return !ip.IsLoopback() && !ip.IsUnspecified() && !ip.IsLinkLocalUnicast()
}

func guardedClient() *http.Client {
	transport := &http.Transport{
		DialContext: dialGuard,
		// 重定向必须重走校验:禁止自动跟随
		DisableKeepAlives: true,
	}
	return &http.Client{
		Transport:     transport,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return validateSameHost(via[0], req.URL)
		},
		Timeout: 8 * time.Second,
	}
}

func validateSameHost(orig, next *url.URL) error {
	if strings.EqualFold(orig.Hostname(), next.Hostname()) {
		return nil
	}
	return fmt.Errorf("redirect to non-allowlisted host %q", next.Hostname())
}

func main() {
	client := guardedClient()
	if _, err := validateTarget("https://api.partner.com/v1/data"); err == nil {
		resp, err := client.Get("https://api.partner.com/v1/data")
		fmt.Println(resp, err)
	}

	_, err := validateTarget("http://169.254.169.254/latest/meta-data")
	fmt.Println("metadata endpoint blocked:", err)
}