重定向是一份响应,要求客户端在另一个目标上发起新请求。Location 字段和状态码共同描述去哪以及方法怎样变化。浏览器通常会自动跟随,但客户端库也可以停下、检查或拒绝后续跳转。每一跳都应当作为协议数据记录:只看最终页面,无法知道 POST 是否变成了 GET、凭据是否越过了源边界,或是否有循环消耗了重试预算。
永久和临时表达不同承诺
301 与 308 表示永久迁移,302 与 307 表示临时绕行;303 明确要求用 GET(原请求为 HEAD 时用 HEAD)取得表示。永久状态可能被缓存、写入书签和搜索索引,所以维护窗口不能随意使用永久码。临时状态告诉客户端下次仍应访问旧地址。RFC 9111 的缓存规则和资源策略仍然有效,“永久”不是忘记授权、信任任意主机或跳过证书检查的指令。
方法是否保留要看状态码
出于历史兼容,许多客户端在 301、302 或 303 后把 POST 改为 GET 并丢掉正文;307 和 308 则要求保留原方法与正文,但前提是客户端能够重新读取该正文。重定向不会把非幂等动作变得安全,保留方法也不等于源站已经接受了重放。服务端应选择与操作语义相符的状态,客户端则应确认请求体可重放并匹配合适的重试策略。
相对 Location 要根据有效请求 URI 解析;客户端应记录解析后的每一跳并设置最大跳数。缺少 Location 时没有可跟随的新目标,自循环和过长链是服务器缺陷,不是无限提高上限的理由。跳转也可能改变 scheme、端口或 origin,转发 Cookie、Bearer token 和客户端证书前必须执行显式允许列表;指向另一源的链接不是发送凭据的授权。
Location 的相对写法还会受到路径末尾斜杠和查询串影响。?page=2、/page=2 与 ../page=2 不是同一个目标,重定向后的有效 URI 也可能已经经过规范化。实现应保存原始值、解析后的绝对值和每一跳的源,出现异常时才能解释为何访问了某个地址。不要使用字符串拼接判断是否同源,应比较解析后的 scheme、主机和端口,并明确是否允许 HTTP 降级、跨端口或经过代理的内部地址。
坏掉的样子
登录代理可能把 API POST 重定向到 HTML 页面,方便的客户端又悄悄把它改成 GET。边缘节点缓存了永久跳转后,回滚时仍会把流量送去已经下线的主机。307 遇到一次性或流式正文时可能无法重放,客户端应返回原响应或清晰错误,不能凭空构造空请求。重定向中间件若盲目接受外部绝对地址,还可能把 Authorization 头泄露出去;循环则会拖到超时才暴露。
排查时逐跳观察状态码、Location、方法、正文长度、最终源和响应内容,先关闭自动跟随。最终 200 不证明原始动作发生,因为最后一个资源可能只是另一种表示。对同源跳转可以有明确的自动策略,跨源跳转则应更严格。日志不要包含凭据;发布永久跳转之前,必须先验证回滚地址、缓存失效和客户端行为。
重定向策略还要和缓存策略一起发布。永久跳转缓存寿命过长,会让已经修好的目标继续被访问;临时跳转若没有合适缓存头,也可能被中间层保存得比维护窗口更久。部署前用真实客户端检查冷缓存和热缓存,再验证旧地址、目标地址和回滚地址的证书、权限及监控。对于写请求,记录源站实际收到的每一跳方法和正文摘要,比只统计最终 200 数量更能发现重复提交或方法丢失。
快速验证
把下面程序保存为 redirect_probe.go,用 Go 1.23 或更新版本运行。两个回环服务记录每次请求。客户端只跟随同源跳转,含初始请求在内最多允许三次请求,拒绝另一源,并在请求体不能重放时停下;目标会回显实际收到的方法与正文。
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type visit struct {
Path, Method, Body string
}
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var foreignCalls atomic.Int64
foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
foreignCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
}))
defer foreign.Close()
var mu sync.Mutex
var visits []visit
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024))
must(err)
seen := visit{r.URL.Path, r.Method, string(body)}
mu.Lock()
visits = append(visits, seen)
mu.Unlock()
if r.URL.Path == "/destination" {
w.Header().Set("Content-Type", "application/json")
must(json.NewEncoder(w).Encode(seen))
return
}
status, location := http.StatusFound, ""
switch r.URL.Path {
case "/loop":
location = "/loop"
case "/foreign":
location = foreign.URL + "/destination"
case "/missing":
default:
status, err = strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/r/"))
must(err)
location = "/destination"
}
if location != "" {
w.Header().Set("Location", location)
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
fmt.Fprint(w, "redirect")
}))
server.Config.ReadHeaderTimeout = time.Second
server.Config.ReadTimeout = 2 * time.Second
server.Start()
defer server.Close()
origin, err := url.Parse(server.URL)
must(err)
errOrigin := errors.New("redirect changes origin")
errBudget := errors.New("redirect budget exhausted")
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if request.URL.Scheme != origin.Scheme || request.URL.Host != origin.Host {
return errOrigin
}
if len(via) >= 3 {
return errBudget
}
return nil
},
}
read := func(response *http.Response) []byte {
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1", "HTTP/1.1 fixture required")
return body
}
request := func(route string, body io.Reader) *http.Request {
req, err := http.NewRequest(http.MethodPost, server.URL+route, body)
must(err)
return req
}
snapshot := func() []visit {
mu.Lock()
defer mu.Unlock()
return append([]visit(nil), visits...)
}
const payload = "action=replace"
follow := func(status int) {
route := "/r/" + strconv.Itoa(status)
req := request(route, strings.NewReader(payload))
check(req.GetBody != nil, "fixture body must be replayable")
before := len(snapshot())
response, err := client.Do(req)
must(err)
var final visit
must(json.Unmarshal(read(response), &final))
method, body := "POST", payload
if status == 301 || status == 302 || status == 303 {
method, body = "GET", ""
}
check(response.StatusCode == 200 && response.Request.URL.Path == "/destination", "wrong final response")
check(final == (visit{"/destination", method, body}), "wrong redirected method/body")
actual := snapshot()[before:]
check(len(actual) == 2 && actual[0] == (visit{route, "POST", payload}) && actual[1] == final, "both wire hops must match")
fmt.Printf("%d: POST -> %s body=%q\n", status, method, body)
}
for _, status := range []int{301, 302, 303, 307, 308} {
follow(status)
}
stopped := *client
stopped.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
before := len(snapshot())
response, err := stopped.Do(request("/r/307", strings.NewReader(payload)))
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 307, "inspect original redirect")
check(response.Header.Get("Location") == "/destination" && len(snapshot()) == before+1, "no-follow must stop before target")
fmt.Println("no-follow: 307 with relative Location; target not requested")
before = len(snapshot())
unreplayable := request("/r/307", io.NopCloser(strings.NewReader(payload)))
unreplayable.ContentLength = int64(len(payload))
check(unreplayable.GetBody == nil, "fixture must not support replay")
response, err = client.Do(unreplayable)
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 307, "Go must retain the unreplayable redirect")
check(len(snapshot()) == before+1, "unreplayable body must not reach target")
fmt.Println("unreplayable: 307 returned without following")
for _, test := range []struct {
route string
err error
hops int
}{{"/foreign", errOrigin, 1}, {"/loop", errBudget, 3}} {
before = len(snapshot())
response, err = client.Do(request(test.route, strings.NewReader(payload)))
check(errors.Is(err, test.err), "wrong redirect policy error")
if response != nil {
_ = response.Body.Close() // Already closed by Client.Do on policy rejection.
}
check(len(snapshot()) == before+test.hops, "wrong number of admitted hops")
fmt.Println(test.route, test.err)
}
check(foreignCalls.Load() == 0, "different-origin target must never be contacted")
before = len(snapshot())
response, err = client.Do(request("/missing", strings.NewReader(payload)))
must(err)
check(string(read(response)) == "redirect" && response.StatusCode == 302, "missing Location must retain 302")
check(len(snapshot()) == before+1, "missing Location cannot select a target")
fmt.Println("missing Location: original 302 returned")
follow(307)
check(len(snapshot()) == 19 && foreignCalls.Load() == 0, "wrong final request counts")
fmt.Println("nineteen same-origin requests; zero different-origin requests")
fmt.Println(runtime.Version())
}
程序会打印五组方法比较:301、302、303 后变成无正文 GET,307、308 则保留带 action=replace 的 POST。关闭自动跟随后,客户端看到原始 307,不请求目标;一次性正文也会让 Go 返回原响应而不跟随。跨源策略在真正访问第二台服务器前拒绝请求,循环发出三次请求后停止,即只跟随两次重定向,缺少 Location 则保留原始 302。最后一次 307 成功,证明失败路径之后仍能恢复。这只隔离客户端重定向行为,不证明浏览器 Cookie 策略、缓存存储或业务动作可以安全重试。
延伸阅读
来源
- RFC 9110:HTTP 语义,重定向状态码、Location 解析和方法语义,已核验 2026-09-09。
- RFC 9111:HTTP 缓存,新鲜度、显式缓存控制和已存响应行为,已核验 2026-09-09。
- MDN:HTTP 重定向,浏览器行为与状态码区别,已核验 2026-09-09。
- Go net/http,CheckRedirect、可重放正文和 ErrUseLastResponse,已核验 2026-09-09。