第04章 HTTP 代理协议:绝对 URI、CONNECT 隧道、转发头与连接池

学习目标

  • 掌握 HTTP 请求的四种 request-target 形式,理解正向代理为何用"绝对 URI"
  • 搞懂 hop-by-hop 头与 end-to-end 头的区别,知道代理为什么"必须删头"
  • 读懂 Via、X-Forwarded-For、Forwarded 这套转发溯源头,及其信任边界
  • 用 407 + Proxy-Authorization 完成代理认证
  • 吃透 CONNECT 隧道:从报文、200 响应到隧道内的裸字节流

前置知识


原理

一、request-target 的四种形式

HTTP 请求行长这样:方法 请求目标 版本。这个"请求目标"(request-target)有四种形式(RFC 7230 §5.3),代理协议的一切从这里展开:

形式样例用在哪
origin-formGET /index.html HTTP/1.1直连源站、反向代理(最常见)
absolute-formGET http://example.com/index.html HTTP/1.1正向代理(客户端→代理)
authority-formCONNECT example.com:443 HTTP/1.1只用于 CONNECT 建隧道
asterisk-formOPTIONS * HTTP/1.1只用于服务器级 OPTIONS

为什么正向代理必须用 absolute-form? 因为代理面向"任意目标"。客户端如果只发 GET /index.html,代理根本不知道该把请求转给谁。Host 头虽然也带主机名,但 absolute-form 是协议层面明确告诉代理"完整 URL 在此,请转发"。这就是 第01章 里"目标写在请求里 vs 写在配置里"的协议落点。

客户端 → 正向代理:  GET http://example.com/p HTTP/1.1   (absolute-form)
正向代理 → 源站:    GET /p HTTP/1.1                       (代理转成 origin-form)
                     Host: example.com

一个合格的正向代理收到 absolute-form 后,会剥掉 scheme 和 host,转成 origin-form 再发给源站——因为源站不需要、有时也不接受 absolute-form。

二、hop-by-hop 头 vs end-to-end 头(代理为什么"删头")

这是 HTTP 代理最容易被忽视、却最关键的一条规则。HTTP 头分两类(RFC 2616 §13.5.1):

  • end-to-end(端到端)头:要一路传到最终接收方。如 Host、Content-Type、Cache-Control。
  • hop-by-hop(逐跳)头:只对当前这一跳连接有意义,代理必须删除,不得转发。

固定的 hop-by-hop 头有 8 个:

Connection         Keep-Alive        Proxy-Authenticate
Proxy-Authorization   TE             Trailer*
Transfer-Encoding  Upgrade

(Trailer 在新规范中归类有争议,实践中按逐跳处理最安全。)

更重要的是:Connection 头里列出的字段名也都是 hop-by-hop,代理必须一并删除。例如客户端发 Connection: close, X-Foo,代理要删掉 Connection 自身、Foo 头。

为什么这事关安全? 如果代理不删 hop-by-hop 头,会引发:

  • 连接走私(Request Smuggling):Transfer-Encoding / Content-Length 跨跳不一致时,攻击者可在一个请求里"偷藏"另一个请求
  • 信息泄漏:Proxy-Authorization(你的代理密码)被原样转发到源站
┌─────────┐   Connection: keep-alive, Foo   ┌────────┐   (删除 Connection 及 Foo)   ┌────────┐
│ Client  │ ──────────────────────────────▶ │ Proxy  │ ────────────────────────────▶ │ Origin │
│         │   Proxy-Authorization: ...      │        │   (删除!不外泄代理凭证)      │        │
└─────────┘                                 └────────┘                               └────────┘

三、转发溯源头:Via / X-Forwarded-For / Forwarded

代理"一拆为二"后,源站默认只看到代理的 IP。要让源站知道"真实客户端是谁、经过了哪些代理",靠这几个头:

头作用样例
Via标记请求经过的每一跳代理(协议+版本+标识)Via: 1.1 squid-prod (squid/5.7)
X-Forwarded-For事实标准:记录原始客户端及沿途代理 IP(链式追加)X-Forwarded-For: 203.0.113.7, 10.0.0.2
X-Forwarded-Proto原始请求的协议(http/https),TLS 终止后用它告诉后端X-Forwarded-Proto: https
X-Forwarded-Host原始 HostX-Forwarded-Host: example.com
ForwardedRFC 7239 的标准化版本,合并上面三者Forwarded: for=203.0.113.7;proto=https;by=10.0.0.2

XFF 的链式语义:每经过一个代理,就在 X-Forwarded-For 右侧追加自己看到的来源 IP:

Client(203.0.113.7) → 代理A → 代理B → Origin
Origin 看到:X-Forwarded-For: 203.0.113.7, <代理A的IP>
                              └最左是真实客户端┘ └右侧是沿途代理┘

⚠️ 信任边界:X-Forwarded-For 是客户端可伪造的普通头!只有"你自己掌控的、紧挨着的那个代理追加的那一段"可信。取真实客户端 IP 时,绝不能无脑取最左值——必须从你信任的代理往外数。这是 Web 安全里最常见的 IP 伪造点,详见 第33章 代理安全。

四、代理认证:407 与 Proxy-Authorization

代理认证和源站认证是两套独立机制,状态码和头都不同:

角色质询状态码质询头应答头
源站认证401 UnauthorizedWWW-AuthenticateAuthorization
代理认证407 Proxy Authentication RequiredProxy-AuthenticateProxy-Authorization

握手流程:

Client → Proxy:  GET http://example.com/ HTTP/1.1
Proxy  → Client: 407 Proxy Authentication Required
                 Proxy-Authenticate: Basic realm="corp-proxy"
Client → Proxy:  GET http://example.com/ HTTP/1.1
                 Proxy-Authorization: Basic dXNlcjpwYXNz      ← base64(user:pass)
Proxy  → Client: 200 OK ...(认证通过,开始转发)

注意 Proxy-Authorization 是 hop-by-hop 头——代理用完必须删掉,绝不能转发给源站(否则你的代理密码就泄漏给外部网站了)。

五、CONNECT:明文代理如何代理加密流量

正向代理能改写明文 HTTP,但 HTTPS 是加密的,代理无法解析。解法是 CONNECT:让代理只做盲转字节的 L4 隧道。

① Client → Proxy:  CONNECT example.com:443 HTTP/1.1     (authority-form)
                    Host: example.com:443
                    Proxy-Authorization: ...(如需认证)

② Proxy 建立到 example.com:443 的 TCP 连接

③ Proxy → Client:  HTTP/1.1 200 Connection Established  (无响应体)

④ 此后这条 TCP 连接变成纯隧道:
   Client ══ TLS ClientHello / 证书 / 加密应用数据 ══▶ Origin
   代理只在两个 socket 间 bidirectional copy,看不懂任何明文

CONNECT 的几个要点:

  • 响应是 2xx(习惯用 200,reason 短语写啥都行),且没有 body、没有 Content-Length
  • 隧道建立后,这条连接不再是 HTTP,代理不能再往里塞头
  • 出于安全,代理通常限制 CONNECT 的目标端口(只放行 443 等),否则会沦为任意 TCP 跳板(开放代理风险,见 第33章)
  • 想看明文?只能做 MITM:代理动态签发证书、终止 TLS 再重新加密——这是 第05章 和 mitmproxy(第18章)的主题

六、连接管理与 Proxy-Connection 的历史包袱

代理与客户端、代理与源站之间都希望复用 TCP 连接(keep-alive)省握手开销。但 Connection 是 hop-by-hop 头,早期代理实现有 bug:会把 Connection: keep-alive 原样透传,导致问题。于是浏览器发明了非标准的 Proxy-Connection 头来"绕过"老代理。

结论:Proxy-Connection 是历史遗留,不是标准。现代实践:

  • 客户端↔代理、代理↔源站是两段独立的 keep-alive,各自管理连接池
  • 代理对源站维护连接池复用后端连接(性能关键,见 第31章 性能调优)
  • 看到 Proxy-Connection 知道它是怎么来的即可,自己写代理别依赖它

命令 / 实验

实验一:看正向代理对头的处理(Via 与删头)

用 Squid 起一个标准正向代理(apt install squid,默认听 3128):

curl -v -x http://127.0.0.1:3128 http://httpbin.org/headers

httpbin.org/headers 会把它最终收到的头原样回显。你会观察到:

{
  "headers": {
    "Host": "httpbin.org",
    "User-Agent": "curl/8.5.0",
    "Via": "1.1 squid-prod (squid/5.7)",          // ← 代理加的溯源头
    "X-Forwarded-For": "203.0.113.7"               // ← 代理追加的真实客户端 IP
    // 注意:没有 Proxy-Connection、没有 Proxy-Authorization —— 被代理删掉了
  }
}

对照 第01章实验一:客户端发出的请求行是 GET http://httpbin.org/headers(absolute-form),但 httpbin 收到的是 origin-form——证明代理做了"绝对 URI → 相对路径"的转换。

实验二:触发 407 代理认证

给 Squid 配上 Basic 认证后:

# 不带凭证 → 407
curl -v -x http://127.0.0.1:3128 http://example.com/
# < HTTP/1.1 407 Proxy Authentication Required
# < Proxy-Authenticate: Basic realm="corp-proxy"

# 带凭证 → 通过(curl 自动算 base64 并加 Proxy-Authorization)
curl -v -x http://alice:secret@127.0.0.1:3128 http://example.com/
# > Proxy-Authorization: Basic YWxpY2U6c2VjcmV0
# < HTTP/1.1 200 OK

实验三:观察 CONNECT 隧道的完整握手

curl -v -x http://127.0.0.1:3128 https://example.com/ 2>&1 | head -25

关键输出(已精简注释):

* Establish HTTP proxy tunnel to example.com:443
> CONNECT example.com:443 HTTP/1.1           ← authority-form 隧道请求
> Host: example.com:443
> Proxy-Connection: Keep-Alive
>
< HTTP/1.1 200 Connection established         ← 代理:隧道已开
<
* CONNECT phase completed
* ALPN: offers h2,http/1.1
* TLS connection using TLSv1.3 ...             ← 此后是 TLS,代理看不懂
> GET / HTTP/2                                 ← 这个 GET 是加密发给源站的,代理无感

抓包侧证:CONNECT 之后代理两端的流量是无法解析的 TLS record:

sudo tcpdump -i any -n -A 'tcp port 3128' 2>/dev/null | head -30
# 你会先看到明文 "CONNECT example.com:443" 和 "200 Connection established",
# 之后全是不可读的二进制(TLS)——这就是隧道的"盲转"本质。

排错

现象可能原因排查方向
代理返回 502 Bad Gateway代理到源站这一跳失败(DNS 解析不了、源站拒连、源站返回非法响应)在代理上直接 curl 目标;查源站可达性
代理返回 504 Gateway Timeout代理等源站响应超时查源站负载、代理的 read timeout 配置
CONNECT 被拒 403/405代理的端口白名单不含目标端口,或禁用了 CONNECT查代理 ACL(Squid 的 Safe_ports/SSL_ports)
源站拿到的客户端 IP 全是代理 IP未配 X-Forwarded-For,或后端没解析它代理加 XFF;后端配 set_real_ip_from(Nginx)等
代理密码泄漏到源站代理未删 Proxy-Authorization(实现 bug)抓源站侧入流量确认;换合规代理
HTTPS 经代理后证书报错代理在做 MITM(动态签证书)检查是否企业根证书;见 第05章
偶发请求"串味"/响应错配hop-by-hop 头未删导致请求走私统一 Transfer-Encoding/Content-Length 处理;见 第33章

本章小结

  • 正向代理用 absolute-form(绝对 URI)接收请求,再转成 origin-form 发给源站。
  • 代理必须删除 hop-by-hop 头(含 Connection 里点名的字段),这既是协议要求也是安全底线。
  • Via/X-Forwarded-For/Forwarded 负责转发溯源;XFF 可伪造,取真实 IP 要从可信代理往外数。
  • 代理认证用 407 + Proxy-Authorization,与源站的 401/Authorization 是两套。
  • CONNECT 让 HTTP 代理用 L4 隧道盲转加密流量,是代理 HTTPS 的标准手段;想看明文得做 MITM。

下一章进入 第05章 HTTPS 与 TLS 代理,把"终止 / 透传 / MITM / SNI 路由 / mTLS"这套 TLS 代理的玩法讲透。