知行札记
专题技术实践通信协议实践HTTP 网络通信

请求字段、CORS 与客户端信号

解释内容协商、凭据、代理信任、浏览器访问规则和指纹的限制。

本页沿 HTTP 的具体接口和通信行为展开。沿用归档中的消息与代码示例,归档所称的实测仅作为原作者历史记录;2026-10-05 整理未向外部站点发送实验请求,也未操作浏览器。命令输出随服务、网络、版本和代理条件变化。

HTTP 头字段描述表示格式、内容协商、路由、认证与会话等信息。User-Agent、Accept 等字段可以提供客户端特征;Cookie 和 Authorization 只有在服务端按相应契约校验后,才可能关联到会话或账户。客户端可修改很多字段,多个用户也可能具有相同指纹,因此头部与 TLS 特征不能直接证明具体人的身份。

先看两张「素颜照」

nc(netcat)是终端里的一个小工具:nc -l 9876 让它在 9876 端口「值守」,并把收到的原始字节原样打印出来。在 macOS/Linux 终端运行它,另开一个终端窗口让 curl 发个请求过来——捕获到的完整请求头只有 3 个:

GET /test HTTP/1.1
Host: 127.0.0.1:9876
User-Agent: curl/8.7.1
Accept: */*

Node.js 是常用的 JavaScript 运行环境,http.get() 是用代码发 HTTP 请求的一种方式。它的默认请求更极简,只有 2 个头,连 User-Agent 都不发:

GET /nodejs HTTP/1.1
Host: 127.0.0.1:9879
Connection: keep-alive

而浏览器打开网页(导航请求)默认带 Accept、Accept-Language、Accept-Encoding、Cookie 等十余个头。光看「带了哪些头」,脚本与浏览器就已泾渭分明——这是指纹最表层的一层。

User-Agent:一张可以随意涂改的名片

服务器常按客户端软件返回不同内容(比如给手机发移动版页面),于是让客户端自我介绍——这就是 User-Agent(用户代理,简称 UA),语法为「产品名/版本 + 注释」。但它只是一张名片,写什么全凭自觉。看 Chrome 的名片:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36

Chrome 为什么自称 Mozilla,还带着 Gecko、Safari 字样?这是「碰瓷」史:早年 Netscape(代号 Mozilla)最流行,服务器只对它开放高级功能,后来者为了被善待纷纷声明「我兼容前代」——Chrome 写上「用 KHTML 引擎、像 Gecko、像 Safari」,Edge 追加 Edg/,Opera 追加 OPR/。结果大家都以 Mozilla/5.0 开头,判断「真 Safari」要含 Safari/xxx 且不含 Chrome/xxx。

更关键的是,UA 可以一行命令伪造(归档记录已验证,未重跑)。下面 -A 后面是要冒充的 UA,-e 后面是 Referer(来源网址,两节之后再详讲):

curl -A "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" \
     -e "https://news.example.com/article?id=42" \
     http://127.0.0.1:9877/api/data

所以「UA 写着 Chrome」≠「请求来自 Chrome」;改了 UA 也不等于变成浏览器——头集合、头顺序、TLS 指纹都还是脚本的(见「指纹的四层」)。反向的新动向是「藏名片」:为抗追踪,Chrome 等 Blink 浏览器已实施 UA 精简(UA Reduction)——Android 一律写 Android 10、机型写 K、版本只留主版本:

旧:Mozilla/5.0 (Linux; Android 16; Pixel 9) ... Chrome/143.0.12.45 Mobile Safari/537.36
新:Mozilla/5.0 (Linux; Android 10; K) ... Chrome/143.0.0.0 Mobile Safari/537.36

需要细粒度信息的服务器可改用 Client Hints:发 Accept-CH 响应头,客户端按需回送 Sec-CH-UA-* 请求头。

内容协商三兄弟:Accept、Accept-Language、Accept-Encoding

同一个网址,新旧客户端支持的格式、用户语言、可解压的算法都可能不同。客户端用三个 Accept 开头的头「点单」,服务器照单供货并选一种返回,这就是内容协商(content negotiation)。

Accept 点数据格式(MIME 类型),用 q 权重(0~1,默认 1)表达偏好,像点菜说「微辣优先,中辣也行」:

curl 发请求:      Accept: */*
浏览器打开网页:    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
浏览器请求图片:    Accept: image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5

服务器选一种,用响应的 Content-Type 告知结果。

Accept-Language 点语言:按浏览器语言设置生成一列 q 值递减的偏好,如 en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7——只是提示,服务器可以不理;为减少可指纹化的信息,Safari 始终只报一个语言,Chrome 无痕模式也只报一个。

Accept-Encoding 点压缩算法:gzip、deflate、br(Brotli)、zstd(Zstandard),现代浏览器默认发 gzip, deflate, br, zstd;identity(不压缩)始终视为可接受。Accept-Encoding 由浏览器自动控制、脚本改不动——这类头叫「禁止修改头」,机制详见后文专门一节。

三者的默认值都随客户端而异——又是指纹的一部分。

Referer 与 Origin:你从哪里来

运营者想知道「用户从哪个网页点过来」,Referer 头携带这个来源。先澄清一个流传极广的误会:它是 referrer 的拼写错误,但已固化在规范中改不掉(RFC 7231 明文承认 misspelled);而后来的 Referrer-Policy 头名拼写正确。

Referer 按策略可含路径与查询串(如 https://news.example.com/article?id=42),但绝不包含 URL 片段(# 之后)和用户名密码。

这里先补一个贯穿全章的定义:两个 URL 的协议、域名、端口三项完全相同,才是同源(same-origin);否则就是跨源(cross-origin,口语也常说跨域,下文两种说法同义)。

带出路径有隐私风险,于是有了 Referrer-Policy,共 8 个策略值:no-referrer、no-referrer-when-downgrade、origin、origin-when-cross-origin、same-origin、strict-origin、strict-origin-when-cross-origin、unsafe-url。当前浏览器默认 strict-origin-when-cross-origin,顾名思义三条规则:

  • 同源请求:发完整 URL;
  • 跨源请求:只发 origin(协议+域名+端口);
  • HTTPS 到 HTTP 降级:一个字不发。

设置途径有四种:响应头、<meta name="referrer">、元素级 referrerpolicy 属性、rel="noreferrer"(无连字符)。

Origin 是相关却不同的头:值只有 scheme://hostname[:port],不含路径,可能为 null。浏览器在跨源的 CORS 请求(fetch/XHR 等)以及同源的非 GET/HEAD 请求(如 POST)时自动携带;而跨源的 no-cors GET/HEAD(如页面里 <img>、<script> 加载资源)不带 Origin。沙盒 iframe、data:/file:/blob: URL、跨源重定向等场景下 Origin 为 null。

对比项OriginReferer
内容协议+域名+端口按策略可含完整路径与查询串
永不包含路径片段(#)与用户名密码
主要用途跨源(CORS)判定统计分析

一条安全忠告(web.dev):别用 Referer 防 CSRF(跨站请求伪造)——发送方可设 no-referrer、攻击者可伪造。应使用 CSRF token(服务器发给页面的随机令牌,提交表单时须一并带回,攻击者在自己的站点上拿不到它),辅以 SameSite Cookie(SameSite 是 Cookie 的属性,可限制跨站携带,见下节)和 Origin、Sec-Fetch-Site 头(浏览器自动附加,标明请求发起方与目标资源是同源、同站还是跨站——只给关系,不透露站点名)。

HTTP 本身不记状态,服务器怎么认出老用户?第一次访问时,服务器用 Set-Cookie 响应头发给你一枚游乐园手环,之后浏览器每次请求自动出示:

Cookie: PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1

注意:Cookie 请求头里只有「名字=值」,没有任何属性——手环上只印编号,规则记在乐园登记簿上。属性只出现在服务器下发的 Set-Cookie 里,常用的有:

  • Expires / Max-Age:过期时间,Max-Age 优先;都不设即为会话 cookie(浏览器关掉即失效);
  • Domain:作用域包含所有子域;
  • Path:按路径前缀匹配,不是安全机制;
  • Secure:仅 HTTPS 发送(localhost 例外);
  • HttpOnly:禁止 document.cookie 读取(防脚本偷读),但请求照常携带;
  • SameSite:限制跨站发送,多数浏览器默认 Lax;跨站也要发须 SameSite=None 且必须同时 Secure。

另有附带强制约束的名字前缀 __Secure-/__Host- 与新属性 Partitioned,超出本章范围,用到时查 MDN 的 Set-Cookie 词条即可。

更通用的身份凭证是 Authorization 头,语法「方案 + 凭证」,典型流程是挑战-应答:客户端先裸请求,服务器回 401 加 WWW-Authenticate 列出可用方案,客户端再带凭证重试。最经典的 Basic 方案:

Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l

这串「乱码」不是加密,只是 Base64 编码,一条命令即可还原:

echo 'YWxhZGRpbjpvcGVuc2VzYW1l' | base64 -d    # 输出 aladdin:opensesame

所以 Basic 必须配合 HTTPS。现代 API 常用 Bearer 方案(RFC 6750):凭证是一串访问令牌(access token),通常先在服务的授权接口登录换取,之后每个请求原样放上——Authorization: Bearer mF_9.B5f-4.1JqM。还有一个容易踩坑的场景:请求被 302 重定向到另一个域名、浏览器跟随跳转时,会把 Authorization 剥掉,不带给新域名的服务器。

Host 与 Connection:门牌号和通话规则

一台服务器(一个 IP)上可能住着几十个网站——虚拟主机(virtual host),全靠 Host 头标明「这封信写给哪个域名」,像同一栋写字楼里靠收件公司名分拣邮件。Host 是 HTTP/1.1 强制头:按 RFC 7230,请求缺失 Host、出现多个 Host 或 Host 值无效时,服务器必须(MUST)回 400 Bad Request;端口省略时默认 80(HTTP)/443(HTTPS)。

原调研本地记录 curl -H "Host;" https://example.com/ 确实返回 400。这里的分号写法含义是发送一个空值的 Host 头:抓包可见报文里 Host 行仍在、只是值为空,正属于「Host 值无效」;真正把 Host 行从报文里删掉的写法是 -H "Host:"(冒号结尾),两种写法实测都会被回 400。

HTTP/2/3 起改由 :authority 伪头部(pseudo-header)表达同一信息——伪头部一律以冒号开头命名,这一前导冒号是伪头部名称的一部分。

Connection 管「说完挂不挂电话」。HTTP/1.1 默认持久连接(persistent connection):一条 TCP 连接可连续发多个请求;HTTP/1.0 才默认响应后关闭。curl 连续请求同一主机能看到 Re-using existing connection(归档记录已验证,未重跑)。Connection 还是逐跳(hop-by-hop)头——「跳」指请求路径上的中间人,典型就是代理(proxy):替客户端转发请求的中转服务器,很多网站都躲在代理后面(下一节的主角就是它)。Connection 的值可为逗号分隔的头名列表,列出的头只描述「发送方与第一个代理」之间的关系,由这个代理消费、不再向下游转发;Keep-Alive、Transfer-Encoding、TE、Upgrade 等皆属逐跳头。到了 HTTP/2,Connection 及一切连接特定头被明令禁止(唯一例外是取值 trailers 的 TE)——连接管理已由协议接管。

X-Forwarded-For 与「X- 前缀」的往事

上一节说很多网站躲在代理后面,这就带来新问题:服务器看到的来源 IP 永远是代理的。事实标准(de facto standard)头 X-Forwarded-For(XFF)传递真实来源,格式像快递转运单——每过一个代理追加一个 IP:

X-Forwarded-For: 203.0.113.195, 2001:db8:85a3:8d3:1319:8a2e:370:7348, 198.51.100.178

最左是「声称的」客户端 IP,最右是最近的代理。关键就在「声称」二字:客户端可自带伪造的 XFF,代理只往右追加、不核对左边——最左侧恰恰最不可信。安全用途(限流、封禁)只能信「你自己面前的可信代理追加的部分」:从右往左数,或按可信代理数量跳过,或按可信代理 IP 列表逐个匹配再取;且可能同时出现多个 XFF 头,必须先合并成一个列表处理。同族还有 X-Forwarded-Host、X-Forwarded-Proto;标准化版本叫 Forwarded(用得较少)。

顺带澄清老观念「X- 开头 = 实验性」:RFC 6648(2012 年)已废弃这一惯例,X- 不代表任何状态,一旦流行还会「泄漏」成事实标准(历史上 x-gzip 最终不得不等同 gzip);新自定义头应直接取有意义的名字,必要时加组织名或域名——X-Request-ID 这类名字今天没有任何「身份」含义。

指纹的四层:风控到底看什么

把前面的线索串起来:服务器评估客户端特征与自动化风险可以使用多层信号,这些信号需要结合服务端规则、已认证会话和统计模型解释。

第一层,头集合与顺序:原调研样本中 curl、Node.js 与浏览器呈现不同头集合和顺序;数量与顺序随版本、协议、请求和中间代理变化。第二层,一致性交叉验证:可以比较 UA 与实际编码能力是否相容;不能用单个版本的 zstd 特征推定所有 Chrome 请求——头与头要互相印证;curl -A 实验只改了 UA,其余特征原形毕露。

第三层,TLS 指纹。HTTPS 建连先做 TLS 握手(handshake,双方打招呼、协商加密参数的阶段),客户端在第一条握手消息 ClientHello 里列出自己支持的密码套件(cipher suite,一组加密算法的组合)和扩展(extension,附加功能选项),还顺带申报目标域名(SNI)和想用的协议(ALPN,比如 HTTP/1.1 还是 HTTP/2)。不同软件的 TLS 实现不同,把这些数据做哈希(hash,算成固定长度的摘要)就得到指纹。JA3(2017 年提出)按密码套件与扩展的原始顺序做哈希,曾广泛使用;2023 年初 Chrome 引入扩展顺序随机化后(据公开资料,默认启用它的目前只有 Chromium 系浏览器),同一浏览器每次握手的 JA3 都不同,按原始扩展顺序聚合的稳定性受到影响;接棒的 JA4(2023 年 9 月发布)改为先排序再哈希,专治随机化:

t13d1516h2_8daaf6152771_02713d6af862

开头一节就能读出:t = TLS over TCP、13 = TLS 1.3、d = 带 SNI、15 个密码套件、16 个扩展、h2 = ALPN 协商了 HTTP/2。JA4 属于 JA4+ 套件,同套件还有 JA4S(服务器指纹)、JA4H(HTTP 客户端指纹)。

第四层,跨请求信号。以 Cloudflare 为例:JA4 指纹配合基于过去一小时全球流量的统计信号——如 browser_ratio_1h(该指纹下浏览器 UA 占比)、h2h3_ratio_1h(HTTP/2+3 占比)——喂给两套判据:防火墙规则(人工设定的硬规则,比如「声称 Chrome 但 h2 占比为 0 就拦」)做确定性拦截,机器学习模型则给每个请求输出自动化评分(Bot Score,示例中人类请求得 95 分)。这类统计异常可以成为风险信号,单独一项仍不足以确定身份或自动化行为。指纹在 TLS 握手时计算,明文 HTTP 或 TLS 会话恢复(复用上次握手的结果、跳过重新协商)的连接上可能拿不到。

CORS:浏览器给跨域请求设的安检流程

浏览器的同源策略(same-origin policy,按前文定义:协议、域名、端口全同才算同源)默认禁止网页读取跨源响应,CORS(跨域资源共享,Cross-Origin Resource Sharing)是服务器「逐项放行」的标准机制,分两条路。

满足全部条件的是简单请求,浏览器直接发:方法为 GET/HEAD/POST;手动设置的头仅限安全列表(Accept、Accept-Language、Content-Language、Content-Type、单范围 Range);Content-Type 仅限 application/x-www-form-urlencoded、multipart/form-data、text/plain 三种表单值;未在 upload 挂监听、未用流式上传。这些古怪限制是为了兼容历史:HTML 表单时代就能这样跨源提交,新标准不能一刀切禁掉。

其余是「非简单请求」(带 Authorization 等自定义头、PUT/PATCH/DELETE、application/json 等),触发预检(preflight):浏览器自动先发一个 OPTIONS(不是开发者代码发的):

OPTIONS /doc HTTP/1.1
Host: bar.other
Origin: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-pingother

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

预检只是「问许可」,永远不带 Cookie 等凭据;服务器点头后浏览器才发真实请求。Access-Control-Max-Age 允许缓存预检结论:不写默认 5 秒,浏览器上限 Chromium 7200 秒、Firefox 86400 秒。

跨源 fetch 默认使用 same-origin 凭据模式。允许跨源携带凭据时用 credentials: 'include'(XHR 对应 withCredentials = true);Cookie 是否实际发送还受 SameSite、域路径及浏览器策略限制。让页面脚本读取这种响应还要求 Access-Control-Allow-Origin 为明确的请求源,并有 Access-Control-Allow-Credentials: true。Allow-Methods、Allow-Headers 和 Expose-Headers 在 include 模式下的 * 不作为通配符;Authorization 需要明确允许。发送凭据与分享响应是两个阶段,后者失败不证明前者未发生。Fetch Standard,CORS 与凭据

最后一个高频误会:CORS 报错 ≠ 服务器拒绝。除预检被拒外,请求会正常到达服务器、甚至拿到 200——只是响应缺 CORS 头时,是浏览器把响应拦下、不给页面 JS,错误细节只在控制台可见。

禁止修改的头:浏览器划下的安全红线

你可能试过用 fetch 设置 Cookie、Referer 或 Host,怎么设都不生效、也不报错——new Request(url, {headers: {Cookie: "a=b"}}).headers.get("Cookie") 返回 null。这是浏览器的「禁止修改头(forbidden request headers)」机制,不是框架的多余限制:Accept-Encoding、Connection、Content-Length、Cookie、Date、Host、Origin、Referer、所有 Proxy- 前缀头、所有 Sec- 前缀头等,用户代理保留完全控制权。原因很实际:脚本若能伪造 Origin/Cookie/Referer,CSRF 防御就全线击穿;伪造 Host/Content-Length/Transfer-Encoding 会引发请求走私(request smuggling,即让代理与服务器对「请求到哪里结束」产生分歧、把夹带数据当成额外请求的攻击);Sec- 前缀整个命名空间保留给浏览器,为防脚本注入伪头。User-Agent 曾在禁列,Fetch 规范现已放开,但 Chrome 仍会静默丢弃。被禁头静默失败而非报错;想验证某头到底发没发,用回显服务最直接:

curl -H "X-Request-ID: abc-123" https://httpbin.org/get

服务器会把收到的头原样回给你。另外,Set-Cookie 是禁止脚本读取的响应头。

请求观察方法

  1. 在终端运行 nc -l 9876 起假服务器,另开一个终端窗口分别用 curl 与 Node.js 请求它,对比报文的头数量;
  2. curl -A ... -e ... 伪装浏览器 UA 与 Referer,再用浏览器自带的开发者工具(右键页面选「检查」或按 F12 打开,Network/网络面板能看到每个请求的完整头)对比真实请求的头集合与顺序;
  3. curl -H "Host;" https://example.com/ 发送空值的 Host(分号语法,Host 行仍在、值为空),观察 400;改用 -H "Host:"(冒号语法)彻底删掉 Host 行,同样被回 400;
  4. curl -sv https://example.com/ https://example.com/ 连发两个请求,观察 Re-using existing connection;
  5. 用 https://httpbin.org/get 验证想设置的头是否真的发出。

这些方法分别取得客户端输出、接收方所见及连接行为的证据。头字段及排列可以提供实现线索,代理和调用者也能改变它们;身份和授权仍需受保护的凭据与服务端检查。上面的命令是观察方法,本轮没有执行这些外部请求。

最后更新于

本页目录