Skip to content

重定向 ​

HTTPC 默认自动跟随 HTTP 重定向(最多 10 次),并记录完整的重定向链。本页覆盖跟随控制、次数限制、链路追踪、状态码方法语义、跨域凭据剥离、环形重定向检测、域名白名单以及与 SSRF 防护的联动。

默认行为 ​

不进行任何配置时,客户端自动跟随重定向,直到到达最终响应或达到次数上限:

go
package main

import (
	"fmt"
	"log"

	"github.com/cybergodev/httpc"
)

func main() {
	result, err := httpc.Get("https://httpbin.org/redirect/2")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(result.StatusCode())       // 200(最终响应的状态码)
	fmt.Println(result.Meta.RedirectCount) // 2(实际跟随的重定向次数)
}

引擎自动跟随 301/302/303/307/308 五种重定向状态码,方法语义遵循 HTTP 规范:

状态码方法处理
301 / 302 / 303可能将 POST 改写为 GET(规范允许)
307 / 308保持原方法与请求体重发

状态码语义(301/302/303/307/308) ​

五种状态码在「方法改写」与「请求体」上的差异如下:

状态码含义方法处理请求体典型用途
301永久移动GET/HEAD 不变;POST 等改写为 GET丢弃域名迁移、URL 规范化
302临时移动(Found)同 301(沿用事实标准行为)丢弃临时跳转、登录后转向
303参见其他(See Other)一律改为 GET丢弃POST 后跳转结果页
307临时重定向保持原方法保留重发需重发 body 的临时跳转
308永久重定向保持原方法保留重发需重发 body 的永久跳转

带请求体的 307/308 不会自动跟随

307/308 要求按原方法重放请求体。底层 net/http 只有在请求体可重放(设置了 GetBody)时才会跟随,而 HTTPC 构建请求时未设置该函数——因此携带非空请求体的请求收到 307/308 时不自动跟随,3xx 响应原样返回(不报错)。需要跟随这类跳转时,用 WithFollowRedirects(false) 手动循环处理,或让服务端改用 302/303。

300/304 与其他 3xx 不在跟随范围

即使携带 Location 头,引擎也只跟随 301/302/303/307/308;300(Multiple Choices)、304(Not Modified)及其他 3xx 响应原样返回,由调用方自行处理。注意 Result.IsRedirect() 判断的是整个 300–399 区间,与是否自动跟随无关。

超出上限即报错

重定向次数超过 MaxRedirects 时请求以错误结束(错误信息形如 stopped after 3 redirects),不会无限循环。MaxRedirects 合法范围 0-50,越界会在配置校验时报错。

控制跟随 ​

重定向配置分布在三个层级,作用范围从大到小:

层级配置作用范围
客户端级Config.Defaults.FollowRedirects / MaxRedirects整个客户端的所有请求
请求级WithFollowRedirects(bool) / WithMaxRedirects(n)单次请求,覆盖客户端配置
预设SecureConfig()、MinimalConfig()(均置 FollowRedirects=false)安全/极简场景默认不跟随

客户端级 ​

Config.Defaults(RequestDefaults)为整个客户端设置重定向策略:

go
cfg := httpc.DefaultConfig()
cfg.Defaults.FollowRedirects = true  // 默认:跟随
cfg.Defaults.MaxRedirects = 5        // 默认:10

client, err := httpc.New(cfg)

请求级 ​

WithFollowRedirects / WithMaxRedirects 覆盖单次请求的客户端配置:

go
// 仅这一次请求禁止跟随,直接拿到 3xx 响应
result, err := httpc.Get("https://httpbin.org/redirect/1",
    httpc.WithFollowRedirects(false),
)
if result.IsRedirect() {
    fmt.Println(result.Response.Headers.Get("Location")) // 重定向目标地址
}

// 仅这一次请求限制跟随 3 次
result, err = httpc.Get(url, httpc.WithMaxRedirects(3))

MaxRedirects(0) 不是禁用

0 是「未设置」哨兵值——引擎回退到默认值 10,而不是禁止重定向。要禁止跟随,用 WithFollowRedirects(false) 或 Config.Defaults.FollowRedirects = false。

SecureConfig 默认禁止重定向

SecureConfig() 预设将 FollowRedirects 置为 false,防止借助重定向把请求导向内部地址(重定向型 SSRF)。安全细节见 SSRF 防护。

追踪重定向链 ​

Result.Meta 记录每次请求的重定向信息:

字段说明
Meta.RedirectCount实际跟随的重定向次数
Meta.RedirectChain重定向过程中经过的 URL 序列

两个字段的精确语义:

  • RedirectChain 记录的是每一跳的来源 URL:第一项是初始请求 URL,之后依次是各中间 URL;最终目标 URL 不在链中。需要最终地址时读 result.Request.URL(跟随完成后的最终请求 URL)。
  • RedirectCount 恒等于 len(RedirectChain)。
go
package main

import (
	"fmt"
	"log"

	"github.com/cybergodev/httpc"
)

func main() {
	result, err := httpc.Get("https://httpbin.org/redirect/3")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("跟随 %d 次重定向\n", result.Meta.RedirectCount)
	for i, u := range result.Meta.RedirectChain {
		fmt.Printf("  %d. %s\n", i+1, u)
	}
	fmt.Println("最终地址:", result.Request.URL)
	// 输出:
	//   1. https://httpbin.org/redirect/3
	//   2. https://httpbin.org/redirect/2
	//   3. https://httpbin.org/redirect/1
	// 最终地址:https://httpbin.org/get
}

跨域凭据自动剥离 ​

跟随重定向时,引擎在每一跳检查目标主机名:与初始请求的主机名不一致(跨域跳转)时,自动删除敏感请求头,防止凭据泄露给重定向目标:

请求头目标主机 = 初始请求主机目标主机 ≠ 初始请求主机
Authorization保留删除
Proxy-Authorization保留删除
Cookie保留删除
其他自定义头保留保留

判定细节:

  • 比较基准是初始请求的主机名(第一跳的 via[0]),不是上一跳。api.example.com → www.example.com 属于跨域(精确主机名比较,子域不同也算);A → B → A 回到初始主机则不剥离。
  • 剥离独立于 cookie jar:即使未启用 EnableCookies,手工设置的 Cookie 请求头在跨域跳转时同样被删除。

配合 WithBasicAuth / WithBearerToken 使用时无需额外处理——令牌只发往原始主机,不会随跳转带给第三方域。

环形重定向检测 ​

除次数上限外,引擎还检测环形重定向(跳转目标此前已在链路中出现过)。命中时请求立即以错误结束,不必等次数用尽:

text
A → B → A     环形:报错 circular redirect detected: A
A → A → A     连续同 URL:不算环形(服务器可能每次返回不同响应)

环形检测与 MaxRedirects 次数上限互为补充:次数上限兜住一切循环,环形检测把「明显绕圈」的链路提前识破,减少无谓的请求。

重定向错误归类 ​

跟随被拒绝(超限、环形、白名单、SSRF 拦截)时,请求以 ClientError 结束,Type 统一为 ErrorTypeValidation:

底层错误信息Message 字段触发条件
stopped after N redirectsredirect limit exceeded跳转次数达到 MaxRedirects(或默认 10)
circular redirect detected: <URL>circular redirect detected目标 URL 早已出现在跳转链中
redirect blocked by whitelist: ...redirect blocked by policy目标域名不在 RedirectWhitelist 内
redirect blocked: ...redirect blocked by policy目标主机被 SSRF 防护拦截
go
package main

import (
	"errors"
	"fmt"
	"log"

	"github.com/cybergodev/httpc"
)

func main() {
	cfg := httpc.DefaultConfig()
	cfg.Defaults.MaxRedirects = 2 // 只允许跟随 2 次

	client, err := httpc.New(cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// /redirect/5 需要跟随 5 次,必然超过上限
	_, err = client.Get("https://httpbin.org/redirect/5")
	if err != nil {
		var clientErr *httpc.ClientError
		if errors.As(err, &clientErr) && clientErr.Type == httpc.ErrorTypeValidation {
			fmt.Println("重定向被拒绝:", clientErr.Message)
			// 输出:重定向被拒绝:redirect limit exceeded
		}
	}
}

重定向目标的 SSRF 校验还包含两条硬性规则:只允许 http/https 协议(ftp://、自定义协议的 Location 被拒绝)与目标主机不得为空。校验阶段不做 DNS 解析——完整的 IP 验证与 DNS rebinding 防护在连接建立时由拨号器执行(详见 SSRF 防护)。

域名白名单 ​

Security.RedirectWhitelist 将重定向目标限制在受信域名内,防御开放重定向攻击:

go
cfg := httpc.DefaultConfig()
cfg.Security.RedirectWhitelist = []string{
    "api.example.com",
    "*.cdn.example.com", // 通配符:匹配严格子域,不含裸域
}

client, err := httpc.New(cfg)

匹配规则细节:

  • 精确匹配:api.example.com 只匹配自身。
  • 通配符:*.cdn.example.com 匹配严格子域(如 img.cdn.example.com),不匹配裸域 cdn.example.com;两者都要放行需同时列出。
  • 比较对象是 Location 目标的主机名(不含端口与协议),比较前做归一化:忽略首尾空白、大小写不敏感。

设置后,重定向到白名单之外域名的请求会被拒绝;重定向目标同时经过 SSRF IP 验证。处理用户提供的 URL 时,建议配合 SecureConfig 或白名单使用。

手动处理重定向 ​

需要逐跳检查、按条件跟随或自定义日志时,禁止自动跟随并循环处理。注意两点:Location 可能是相对地址,需按当前 URL 解析成绝对地址;手动循环不经过白名单检查,但每一跳建连时连接层的 SSRF IP 校验照常生效。

go
package main

import (
	"fmt"
	"log"
	"net/url"

	"github.com/cybergodev/httpc"
)

func main() {
	cfg := httpc.DefaultConfig()
	cfg.Defaults.FollowRedirects = false

	client, err := httpc.New(cfg)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	currentURL := "https://httpbin.org/redirect/3"
	base, err := url.Parse(currentURL)
	if err != nil {
		log.Fatal(err)
	}

	for i := 0; i < 5; i++ {
		result, err := client.Get(currentURL)
		if err != nil {
			log.Fatal(err)
		}
		if !result.IsRedirect() {
			fmt.Println("到达最终地址:", currentURL)
			break
		}

		location := result.Response.Headers.Get("Location")
		if location == "" {
			fmt.Println("重定向响应缺少 Location 头,停止跟随")
			break
		}

		// 相对地址按当前 URL 解析为绝对地址
		next, err := base.Parse(location)
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("第 %d 跳:%s\n", i+1, next.String())

		currentURL = next.String()
		base = next
	}
}

手动循环中的安全责任

自动跟随时的白名单与重定向目标预检不会作用于手动循环。处理不可信来源的 Location 时,建议在循环里自行校验目标域名(或复用白名单逻辑);连接层的 SSRF 校验仍然兜底,但域级控制需要自己做。

常见问题 ​

现象原因解决方案
POST 收到 307/308 未跟随请求体不可重放(未设置 GetBody),按规范不自动跟随手动循环处理,或让服务端改用 302/303
WithMaxRedirects(0) 没有禁用跟随0 是「未设置」哨兵值,回退默认 10用 WithFollowRedirects(false)
带 Location 的 300/399 没有跳转引擎只跟随 301/302/303/307/308用 IsRedirect() + Location 自行处理
跳转后查询参数「丢失」目标查询串完全由 Location 决定,客户端不合并原请求参数服务端在 Location 中带上需要的参数
想知道最终落在哪个域名RedirectChain 不含最终 URL读 result.Request.URL
跨域跳转后请求 401Authorization/Cookie 被跨域剥离(安全设计)对新域名重新认证,或让服务端同域跳转

下一步 ​