Cookie、Session、CORS、WebSocket、SSE 与代理边界
浏览器访问后端时,同时受到 HTTP、浏览器安全策略和连接生命周期的约束。Cookie 让后续请求携带会话标识;CORS 决定脚本能否读取跨源响应;SSE 与 WebSocket 则让一条连接持续传递消息。反向代理加入后,还要处理转发信息、缓冲和超时。
会话与跨源请求怎样配合
Cookie、Session 与令牌
服务器:Set-Cookie: sid=随机标识; Path=/; HttpOnly; Secure; SameSite=Lax
↓ 浏览器按属性保存
浏览器:Cookie: sid=随机标识
↓ 服务器查询会话
会话:用户、权限关联、到期时间与撤销状态Cookie 是浏览器保存并按规则发送的键值数据,Session 是服务端维护的会话状态,两者常通过一个随机标识关联。服务器也可以使用令牌表达身份信息;签名令牌的完整性、到期和撤销仍需处理,不能因为不查 Session 就省掉授权。
| Cookie 属性 | 控制的行为 |
|---|---|
| 未设置 Domain | 通常形成 host-only Cookie,只发送给设置它的主机 |
| Domain | 扩大到符合规则的域及子域,不能任意指定其他站点 |
| Path | 按请求路径匹配;不构成可靠的权限隔离 |
| Secure | 仅在符合安全传输规则的请求中发送,生产使用 HTTPS |
| HttpOnly | 阻止脚本通过 Cookie API 直接读取,浏览器请求仍可携带 |
| SameSite | 控制部分跨站请求的 Cookie 发送 |
| Max-Age / Expires | 决定客户端保留期限,服务器还需独立检查会话到期 |
Cookie 不按端口隔离。http://127.0.0.1:18088 与 :18089 的端口不同,仍可能携带匹配同一主机的 Cookie。多个应用共享父域 Cookie 时,也要考虑同名覆盖、会话固定和子域被接管的风险。Set-Cookie
登录后应按框架能力更换会话标识,退出时撤销服务端状态并让客户端删除匹配属性的 Cookie。HttpOnly 可以减少直接读取令牌的风险,但 XSS 仍可能在页面中发起已认证请求;客户端属性不能替代服务端安全控制。
同源与同站不是同一判断
源由 scheme、host、port 组成。站点判定主要依据 scheme 与可注册域,通常不按端口区分。相同站点的两个子域也可能不同源。同源策略
| 地址对照 | 源的关系 |
|---|---|
| http://127.0.0.1:18088 与 http://127.0.0.1:18089 | 不同源,端口不同 |
| https://app.example.com 与 https://api.example.com | 不同源,主机不同 |
| http://api.example.com 与 https://api.example.com | 不同源,scheme 不同 |
CORS 使用 Origin 判断脚本读取许可;SameSite 判断 Cookie 的跨站发送。允许 CORS 不会自动覆盖浏览器第三方 Cookie 策略,credentials: include 也不能强迫发送被 Cookie 规则禁止的凭据。Request.credentials
预检和响应读取分别发生
跨源 fetch 不一定先发 OPTIONS。满足 CORS 安全列出条件的请求可以直接发送;使用 application/json、自定义非安全列出字段或某些方法时,浏览器通常先预检。完整判定由 Fetch 标准定义。
需要预检
OPTIONS + Origin + 希望使用的方法/字段
├─ 获得许可 → 再发送实际请求
└─ 未获许可 → 不发送实际请求
无需预检
直接发送实际请求 → 服务端可能已执行
├─ 响应许可通过 → 脚本可读取
└─ 响应许可失败 → 脚本读取被拒绝携带凭据时,Access-Control-Allow-Origin 必须是允许的具体源,不能使用 *,同时需要 Access-Control-Allow-Credentials: true。动态返回允许源时应设置 Vary: Origin。不要把任意 Origin 原样反射成许可。CORS 指南
CORS 失败可能发生在实际请求已经执行之后。状态修改接口仍需认证、授权和适当的 CSRF 防护,例如校验 Origin、使用 CSRF Token、设置合适的 SameSite,并避免安全方法触发业务写入。
在真实浏览器中观察完整结果
启动本地实验
下载完整实验包,解压进入 state-streaming-proxy。Linux 宿主需要 Docker 和浏览器,使用有 Docker 权限的普通账号;先按 网络工具环境构建 local/network-web-tools:1。
Java 程序提供页面、Cookie、CORS 和 SSE,Python websockets 15.0.1 提供 WebSocket。所有运行进程使用 UID 10001,源码只读:
docker run -d --name state-streaming-lab \
--user 10001:10001 --read-only --cap-drop ALL \
--tmpfs /tmp:rw,exec,size=256m \
-p 127.0.0.1:18088:18088 \
-p 127.0.0.1:18089:18089 \
-p 127.0.0.1:18090:18090 \
-v "$PWD:/src:ro" local/network-web-tools:1 bash /src/run.sh
docker logs state-streaming-lab日志出现 ui=18088、api=18089 和 websocket=18090 后,浏览器打开 http://127.0.0.1:18088,点击“运行本地实验”。使用精确的 127.0.0.1,不替换为 localhost;实验按完整 Origin 白名单检查来源。
这是受控回环实验,没有真实账号。/login 创建随机、五分钟到期的演示 Session;Cookie 使用 HttpOnly 与 SameSite=Lax,HTTP 回环没有配置 Secure。/blind-write 故意省略 CSRF 防护,只增加容器内的演示计数,不能把这份配置部署到公网。
首次运行得到:
Cookie session=present
CORS 简单 POST 已发送,浏览器拒绝读取响应
Preflight OPTIONS 被拒绝,实际 POST 未发送
SSE ids=1,2,3,断开后从 Last-Event-ID 续接
WebSocket echo:hello close=1000
服务端 blindWrites=1 deniedPosts=0 resumed=1下图是浏览器实际运行实验页面后的截图,展示来自真实请求与服务端计数的结果,不是 DevTools 面板示意图。

重复点击会继续增加本实例的计数,页面刷新不会清空服务端状态;需要重新得到 1/0/1 时停止并重建实验容器。脚本超时时会显示失败,不将未收到的消息补成成功结果。
对照 F12 和服务端计数
先打开 F12 Network,再点击运行。/login 的 JSON POST 会触发预检,返回 204 后浏览器保存 Cookie;/who 携带 Cookie 得到 session=present。HttpOnly Cookie 不会出现在 document.cookie,但可以在浏览器存储面板查看其属性,分享截图时仍需隐藏值。
/blind-write 使用 text/plain 的简单 POST。实际请求到达服务端,使 blindWrites 增加,但响应没有允许跨源读取的 Header,fetch 因而失败。/preflight-denied 使用 JSON 和 X-Lab 字段,OPTIONS 被拒绝,服务端 deniedPosts 仍为 0。只看 JavaScript 中两个相似的 TypeError,会漏掉这次差别。
Network 工具可能显示被阻止的请求条目;是否真正到达后端,还需结合预检和服务端记录。本实验使用计数作为直接对照,没有把浏览器“创建了请求对象”当作已经传输。
浏览器的跨源策略不会由 curl 执行。curl 可以检查 Header,但无法独立证明浏览器将接受响应。F12 的 Headers、Cookies、EventStream 或 WebSocket 消息面板应分别查看实际字段和事件。Network 面板
用 curl 观察 SSE 原始内容
在 Linux 宿主执行。Cookie jar 只保存在本次新建的临时目录,不输出其中的值:
COOKIE_DIR=$(mktemp -d /tmp/stream-cookie.XXXXXX)
umask 077
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-c "$COOKIE_DIR/cookies.txt" \
-H 'Origin: http://127.0.0.1:18088' \
-H 'Content-Type: application/json' --data-binary '{}' \
http://127.0.0.1:18089/login
curl -q --noproxy '*' --fail-with-body --max-time 5 -N \
-b "$COOKIE_DIR/cookies.txt" \
-H 'Origin: http://127.0.0.1:18088' \
http://127.0.0.1:18089/events-N 禁止 curl 输出缓冲。正常能依次看到 retry、id 与 data 行,三个事件后服务端关闭;curl 不会像 EventSource 那样自动续接。手动增加 Last-Event-ID: 1 可读取后续 2、3。实验结束确认 COOKIE_DIR 为本次 /tmp/stream-cookie.* 目录,再删除它。
服务器会拒绝超出 0–3 的游标。生产事件 ID 不应直接作为越权读取任意历史消息的钥匙,仍要校验订阅主体与数据范围。
SSE 与 WebSocket 的消息生命周期
SSE 的格式、重连和关闭
SSE 是 text/event-stream 的 UTF-8 文本流,服务器向客户端单向发送事件。浏览器用 EventSource 接收,需要向服务端提交命令时仍可配合普通 HTTP 请求。
retry: 200
id: 1
data: item-1
id: 2
event: update
data: 第一行
data: 第二行空行结束一个事件块,多行 data 合并为带换行的内容;event 指定事件类型,未指定时触发 message;冒号开头的注释可作心跳。JSON 可以放进 data,但 SSE 自身不会验证 JSON。
EventSource 在可恢复的连接中断后重连,并在适用时发送 Last-Event-ID。实验第一次仅发送 id=1 后关闭,后续请求带游标取得 2、3;页面收到第三条后主动 close。服务端返回 204 可以通知客户端停止重连。SSE 标准
客户端保存游标、服务器保留可重放事件、业务处理去重需要分别实现。断线窗口超过服务器保留期时,应明确返回需要重新同步的结果,或提供快照后再订阅;不能把自动重连等同于永不丢消息。
EventSource 原生构造器不提供任意 Header 设置接口。跨源 Cookie 可用 withCredentials: true;需要 Authorization Header 的场景可选择支持流读取的 fetch 客户端或受控认证方案,不把长期令牌放进 URL 方便传参。EventSource API
WebSocket 的双向交换
WebSocket 建连后双方都可以发送文本或二进制消息。传统 HTTP/1.1 建立方式使用 Upgrade,并成功切换到 101;HTTP/2、HTTP/3 的扩展 CONNECT 是另行定义的方式,不能要求它们也出现同一组逐跳 Header。
协议有消息、分片、Ping/Pong 和 Close 帧。客户端 masking 用于协议安全,不是加密;公网使用 wss,让 TLS 保护传输。WebSocket 不执行普通 fetch 的 CORS 读取流程,服务端仍应验证 Origin,并对会话和每项消息操作授权。WebSocket 规范
实验服务器的核心配置是:
async with serve(
echo, "0.0.0.0", 18090,
origins=["http://127.0.0.1:18088"],
max_size=1024,
max_queue=4,
ping_interval=10,
ping_timeout=10,
close_timeout=2,
) as server:
await asyncio.Future()max_size 限制接收消息字节数;websockets 15 的 max_queue 是接收帧缓冲的高水位,不是“最多四条业务消息”的承诺。参数应按所用库版本理解。websockets 15.0.1 Server API
运行独立协议测试:
docker run --rm --user 10001:10001 --network none \
-v "$PWD:/src:ro" local/network-web-tools:1 \
python3 /src/websocket_lab.py test输出 echo=hello originRejected=403 oversizeClosed=1009。服务端可能同时打印预期的 Origin 拒绝日志;测试实际校验了握手 403 和超大消息关闭码。浏览器正常回显后使用 1000 关闭。1006 是异常关闭的本地表示,不作为普通 Close 帧发送码使用。
慢消费者与断线恢复
连接仍存活时,消费者也可能已经跟不上。应给每个连接限制待发送字节、消息数和等待时间。浏览器 WebSocket 的 bufferedAmount 表示尚未发出的排队字节;持续增长时应暂停生产或按业务策略合并、丢弃、断开,不能无限调用 send。WebSocket API
广播服务最好让各连接独立排队,避免一个慢连接拖住全体接收者。行情快照可覆盖旧值,订单事件可能需要持久化与重放,两者的丢弃策略不同。背压必须贯穿应用队列、协议库和下游处理,调大 socket 缓冲只是把等待移到另一个位置。
重连采用有上限的退避和抖动,避免服务重启后客户端同时连接。身份过期、权限撤销和服务发布也需要关闭或重新认证机制。已有 WebSocket 连接不会因为浏览器 Cookie 到期就自动获得完整的业务撤销处理。
反向代理中的地址、缓冲与超时
先确认真实的连接对端
客户端 → 受控入口代理 → 应用
应用 socket 对端通常是代理
原始客户端信息由代理另行传递Forwarded 或 X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host 可携带原始信息,但普通客户端也能发送同名字段。应用先验证直接连接对端是否为受信代理,再按既定链路解析;不能无条件取逗号分隔的第一个地址。Forwarded 规范
多个代理串联时,应从可信的直接对端向前核验既定跳数或地址集合,在遇到不可信来源处停止。入口还要清洗外部同名字段;对 Host、scheme 的信任会影响重定向、回调 URL、Cookie Secure 与审计记录。
实验没有配置可信代理,/peer 始终使用实际 socket 对端。可以发送伪造字段:
curl -q --noproxy '*' --fail-with-body --max-time 5 \
-H 'X-Forwarded-For: 198.51.100.77' \
http://127.0.0.1:18089/peer结果包含 forwardedTrusted=false,client 为实际连接地址,而不是注入的 198.51.100.77。Docker 下可能显示网桥或发布端口转发后的地址,不能要求它必然是宿主回环。该负例验证“未配置代理时不采信 Header”,没有宣称已经覆盖完整的多跳代理实现。
SSE 为什么会积攒后一起出现
服务端 flush 后,数据还可能经过代理缓冲、压缩器和客户端缓冲。Nginx 的 proxy_buffering、上游 X-Accel-Buffering 响应字段及配置中的忽略规则都会影响实际行为。对要求及时交付的 SSE,应明确关闭该路径的响应缓冲,并核对压缩层。Nginx proxy 模块
请求缓冲与响应缓冲是两个设置。关闭 SSE 响应缓冲不会自动让上传文件逐块传给上游。大文件上传还需要独立限制 body 大小、临时磁盘、上游发送时间与失败清理。
可先用 curl -N 直连受控测试服务,再通过代理请求同一事件源,比较事件到达间隔;只有代理路径出现集中输出时,再看缓冲和压缩。不要拿最终全文相同来判断流式交付已经正确。
WebSocket 转发与空闲时间
HTTP/1.1 WebSocket 代理要正确转发 Upgrade 与 Connection,普通逐跳字段不会自动穿过所有代理。升级成功后,连接保持、空闲超时和关闭传播仍需配置。Nginx WebSocket 转发
proxy_read_timeout 通常约束两次上游读取之间的等待,不是整条 SSE/WebSocket 会话的总时长。心跳间隔应小于相关空闲超时,并为网络波动留余量;心跳只能说明某一层有响应,不能证明业务订阅已经推进。
| 现象 | 检查方向 | 修复与复测 |
|---|---|---|
| curl 成功,浏览器 fetch 失败 | Origin、预检、凭据、Cookie 策略 | 用真实页面重跑,区分请求没发与响应不可读 |
| 登录后仍 401 | Cookie Domain/Path/SameSite/Secure、credentials 与会话存储 | 对照实际 Cookie 发送和服务端到期 |
| SSE 事件集中出现 | 服务端 flush、代理/压缩/客户端缓冲 | 同事件源对照直连与代理的到达间隔 |
| SSE 重连后漏事件 | 游标、保存期限、重放和消费去重 | 断在指定事件后,验证恢复范围 |
| WebSocket 周期断开 | Ping/Pong、代理空闲期限、网络与身份到期 | 对照关闭码和双方日志 |
| 长连接内存上涨 | 每连接队列、消息大小、慢消费者 | 限制资源并重复相同消费速度测试 |
完成浏览器观察后,关闭页面连接,再执行 docker stop -t 10 state-streaming-lab 与 docker rm state-streaming-lab。容器内会话和计数随进程退出消失,源码与镜像保留用于复跑。
权威资料与规范地址
按协议、系统调用与 Java API 查阅完整定义。
