← 中文资源

简体中文版本

服务端请求伪造(SSRF)

SSRF 让服务器去抓取攻击者指定的 URL——云元数据端点、内部管理面、回环服务——滥用受信网络位置触达客户端永远够不到的资源。

阅读英文原文

概念定义

服务器能访问浏览器到不了的内部网络。一旦它按用户给的 URL 发起抓取,用户就等效于站在了那个网络里。高价值目标包括返回实例凭据的链路本地云元数据、无认证的内部面板、暴露 HTTP 的数据库。

工作机制

攻击者向 webhook 或预览功能提交元数据地址或内部主机名。绕过手法:公共主机重定向到私网、校验与抓取之间 DNS 变答(重绑定)、IP 的替代编码。加固手段:校验后钉住解析出的 IP、拒绝重定向或逐跳再校验、元数据端点强制会话令牌(IMDSv2)。

使用场景

一切按 URL 抓取的功能:链接预览、webhook 投递、头像导入、文档渲染。防御优先按域名白名单而非私网段黑名单——白名单不存在“忘了某种编码”的绕过。

常见误区

只封私网 IP 字面量挡不住重绑定与重定向;SSRF 不止读——请求方法与头部也可被操纵,能触达改状态的内部 API;从元数据拿到凭据后可升级成整云账号接管。出口防火墙若自行解析域名会破坏 IP 钉住,除非共享受控解析器。校验与使用的时间差是反复出现的根因:校验和连接必须是原子操作。

代码示例

// 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)
}
export interface RedirectPolicy {
  allowedHosts?: readonly string[];
  allowedProtocols?: readonly string[];
  baseUrl?: string;
}

export function isSafeRedirect(target: string, policy: RedirectPolicy): boolean {
  if (typeof target !== "string") return false;
  const trimmed = target.trim();
  if (trimmed === "" || /[\u0000-\u001f\\]/.test(trimmed)) return false;
  const protocols = policy.allowedProtocols ?? ["https:"];
  let url: URL;
  try {
    url = new URL(trimmed, policy.baseUrl);
  } catch {
    return false;
  }
  if (!protocols.includes(url.protocol)) return false;
  if (url.username || url.password) return false;
  const base = policy.baseUrl ? new URL(policy.baseUrl) : undefined;
  const sameOrigin = base ? url.origin === base.origin : false;
  const allowed = (policy.allowedHosts ?? []).some((candidate) => {
    return candidate.toLowerCase() === url.host.toLowerCase();
  });
  return sameOrigin || allowed;
}

export function resolveSafeRedirect(target: string, policy: RedirectPolicy, fallback = "/"): string {
  if (!isSafeRedirect(target, policy)) return fallback;
  const url = new URL(target, policy.baseUrl);
  if (!policy.baseUrl) return url.toString();
  return url.pathname + url.search + url.hash;
}