重定向是一份响应,要求客户端在另一个目标上发起新请求。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。