WebSocket CSWSH 跨站劫持防御配置实战

适用场景

WebSocket CSWSH 跨站劫持防御适用于所有以 Cookie 作为鉴权凭据的实时通信服务,典型场景包括:

  • 站内私信、客服会话、在线工单等基于 WebSocket 的即时通讯模块;
  • 后台管理系统的实时日志推送、任务进度、监控大屏数据流;
  • 交易、行情、协作编辑等需要长连接的高价值业务通道;
  • 使用 Nginx、HAProxy 或云负载均衡做 WebSocket 反向代理的网关架构。

与传统 XSS、CSRF 不同,CSWSH(Cross-Site WebSocket Hijacking,跨站 WebSocket 劫持)的入口是一次握手请求,攻击者页面无需读取任何响应体,只要服务端在握手时未校验来源,就能借用受害者已登录的 Cookie 建立长连接并持续收发消息。

前置条件

  • 服务端使用 WebSocket(ws://wss://),且握手阶段仅依赖 Cookie/Session 识别用户;
  • 网关层可直接修改 Nginx 配置,或应用层可修改握手回调逻辑(Python、Node.js、Java 均可);
  • 具备一条可在外部网络访问的测试域名,用于模拟恶意源站发起握手;
  • 验证环节需要 wscatnpm i -g wscat)或 Python websockets 库。

原理说明

为什么同源策略拦不住

浏览器同源策略约束的是脚本对响应的读取。WebSocket 握手使用 HTTP 完成,但它不走 CORS 预检流程:

  • 握手请求由浏览器直接发出,自动携带目标域的 Cookie(同站 Cookie 未设置 SameSite 或设为 Lax/None 时尤其明显);
  • 服务端返回 101 Switching Protocols 后,连接即建立,浏览器侧不对消息内容做跨域限制;
  • 攻击页面通过 new WebSocket("wss://target.example/ws/") 即可触发,且能对返回的消息调用 onmessage 完整读取。

关键结论

只要服务端在握手时没有校验 Origin 头,也没有绑定一次性凭据,那么「用户登录态有效」就等价于「任意站点都能代表该用户建立连接」。因此防御必须落在两个位置:Origin 白名单握手前的一次性票据校验,二者缺一不可——前者挡浏览器发起的跨站握手,后者挡住非浏览器客户端直接伪造 Origin 的情况。

操作步骤

步骤一:网关层 Origin 白名单

在 Nginx 的 http 块中使用 map 做精确匹配(不要用 contains,否则 https://evil.example/?x=www.wafai.cn 这类后缀绕过会命中)。

# 1) 定义白名单,注意正则中使用 \. 转义点号
map $http_origin $ws_origin_ok {
    default                        0;
    "https://www.wafai.cn"         1;
    "https://m.wafai.cn"           1;
}

# 2) WebSocket 升级映射(保持标准写法,勿改动)
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name www.wafai.cn;

    location /ws/ {
        # 3) 非白名单来源直接拒绝,返回 403 且不进入上游
        if ($ws_origin_ok = 0) {
            return 403;
        }

        proxy_pass http://127.0.0.1:8081;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host       $host;
        proxy_set_header X-Real-IP  $remote_addr;
        proxy_set_header Origin     $http_origin;

        # 4) 长连接超时按业务调整,避免被空闲回收误判断线
        proxy_read_timeout  300s;
        proxy_send_timeout  300s;

        # 5) 单 IP 并发连接限制,防止单点建立大量长连接
        limit_conn perip_ws 20;
    }
}

# http 块中定义连接区
limit_conn_zone $binary_remote_addr zone=perip_ws:10m;

注意:缺少 Origin 头的请求(非浏览器客户端、部分移动端 SDK)在上述规则下 $ws_origin_ok0,会被一并拒绝。若业务确实存在这类客户端,应为其单独开放子路径并改用票据鉴权,而不是放宽整个 /ws/ 的 Origin 校验。

步骤二:握手前一次性票据(推荐作为主防线)

票据方案的核心是:握手请求不携带 Cookie 鉴权,而是先在普通 HTTPS 接口申请一个短时效、一次性的票据,在握手 URL 中回传。跨站页面既拿不到票据,也无法伪造,因为它受正常接口的 CSRF 防护与 CORS 约束保护。

# ticket.py —— 用 HMAC 生成一次性票据(无需额外存储)
import hmac, hashlib, time, base64, secrets

SECRET = secrets.token_bytes(32)   # 生产环境从密钥管理服务读取

def issue_ticket(user_id, ttl=60):
    exp = int(time.time()) + ttl
    nonce = secrets.token_hex(8)
    raw = "%s.%s.%s" % (user_id, exp, nonce)
    sig = hmac.new(SECRET, raw.encode(), hashlib.sha256).hexdigest()[:32]
    return base64.urlsafe_b64encode(("%s.%s" % (raw, sig)).encode()).decode()

def verify_ticket(ticket, now=None):
    now = now or int(time.time())
    try:
        raw = base64.urlsafe_b64decode(ticket.encode()).decode()
        user_id, exp, nonce, sig = raw.split(".")
    except Exception:
        return None
    expect = hmac.new(SECRET, ("%s.%s.%s" % (user_id, exp, nonce)).encode(),
                      hashlib.sha256).hexdigest()[:32]
    if not hmac.compare_digest(sig, expect):
        return None
    if int(exp) < now:
        return None
    return user_id

前端在建立连接前先调用 GET /api/ws-ticket 拿到票据,再以 wss://www.wafai.cn/ws/?ticket=... 发起握手。服务端校验通过后立即作废票据(写入 Redis 并设置 SETNX + EXPIRE 60),确保同一票据无法二次使用。

步骤三:应用层双重校验参考实现

网关只覆盖一层,应用层仍需独立校验 Origin 与票据,避免绕过 Nginx 直连上游端口。

import asyncio, websockets
from ticket import verify_ticket

ALLOWED = {"https://www.wafai.cn", "https://m.wafai.cn"}

async def handler(ws):
    path = ws.request.path
    query = dict(p.split("=", 1) for p in path.split("?", 1)[1].split("&")) if "?" in path else {}
    user_id = verify_ticket(query.get("ticket", ""))
    if not user_id:
        await ws.close(code=4401, reason="invalid ticket")
        return
    async for msg in ws:
        await ws.send("echo:" + msg)

async def origin_check(path, headers):
    origin = headers.get("Origin", "")
    if origin not in ALLOWED:
        return False, {"X-Deny-Reason": "origin"}

async def main():
    async with websockets.serve(handler, "127.0.0.1", 8081,
                                process_request=origin_check,
                                max_size=65536, ping_interval=30):
        await asyncio.Future()

asyncio.run(main())

三项关键参数:max_size 限制单帧大小防止内存耗尽,ping_interval 保活并识别半开连接,process_request 在握手阶段完成 Origin 判定(拒绝时不会分配业务连接资源)。

步骤四:消息层与会话层加固

# Redis:票据一次性消费
SETNX ws:ticket:<nonce> <user_id>
EXPIRE ws:ticket:<nonce> 60

# SQL 审计:记录握手来源,便于事后回溯
INSERT INTO ws_conn_log(user_id, origin, ip, ua, created_at)
VALUES (?, ?, ?, ?, NOW());

另外建议把会话 Cookie 显式设为 SameSite=Strict(或至少 Lax),这能显著降低跨站握手时携带凭据的概率,属于纵深防御中的低成本一环。

配置验证

第一步,用 wscat 从命令行伪造恶意 Origin,预期得到 403 或握手失败:

wscat -c "wss://www.wafai.cn/ws/?ticket=TEST" -H "Origin: https://evil.example"
# 预期输出类似:
# error: Unexpected server response: 403

第二步,使用 Python 脚本做自动回归,覆盖三种情形:

# ws_check.py
import asyncio, websockets

CASES = [
    ("https://evil.example", None,   "expect-deny"),
    ("https://www.wafai.cn", None,   "expect-deny"),   # 无票据
    ("https://www.wafai.cn", "GOOD", "expect-allow"),  # 有效票据
]

async def main():
    for origin, ticket, expect in CASES:
        url = "wss://www.wafai.cn/ws/"
        if ticket:
            url += "?ticket=" + ticket
        try:
            async with websockets.connect(url, origin=origin) as ws:
                print(origin, ticket, "-> CONNECTED", "(want %s)" % expect)
        except Exception as e:
            print(origin, ticket, "-> BLOCKED:", type(e).__name__, e, "(want %s)" % expect)

asyncio.run(main())

第三步,用 curl 做无依赖的握手探测,观察状态码与响应头:

curl -i -N -s -m 5 \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" \
  -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  -H "Origin: https://evil.example" \
  "https://www.wafai.cn/ws/?ticket=TEST" | head -12

第四步,核对网关日志与限流是否生效:

# 确认 403 请求未进入上游
grep -c " 403 " /var/log/nginx/access.log
# 确认单 IP 连接数限制触发过(超出 20 条长连接时)
grep "limiting connections" /var/log/nginx/error.log | tail

常见问题

FAQ 1:校验 Origin 之后,压测工具和移动端 SDK 连不上,是否要放开?

不要放开整个路径。正确做法是让压测与原生客户端走票据鉴权通道:新增 /ws-native/ 子路径,该路径不校验 Origin 但强制校验一次性票据与签名,并单独施加更严格的连接数与频率限制。这样浏览器侧仍受 Origin 保护,非浏览器侧的安全强度不低于 Cookie 方案。

FAQ 2:票据放在 URL 里会不会被日志记录,造成泄露?

会。因此需要三项配套措施:票据有效期压到 60 秒以内且一次性消费,泄露窗口极短;Nginx 侧对 /ws/ 使用自定义 log_format,用 $uri 替代 $request,不落 query string;票据绑定申请时的用户 ID 与客户端 IP,即使被他人拿到,跨 IP 使用也会被拒绝。

FAQ 3:已经设置了 SameSite=Strict,是否可以不再校验 Origin?

不能。Cookie 属性只影响浏览器是否携带凭据,若服务端在握手时把「无凭据」当作匿名会话处理,攻击者页面仍可能建立匿名连接并消耗资源;反过来,部分浏览器与企业内网的兼容模式对 SameSite 实现存在差异,历史上多次出现 Strict 未被严格执行的情况。Origin 校验属于服务端强制策略,不依赖客户端行为,必须保留。

总结

  • 握手即鉴权:WebSocket 的安全边界在握手请求上,Origin 白名单与一次性票据必须同时存在,缺一不可;
  • 精确匹配:Origin 用 Nginx map 精确列举,禁止使用包含式匹配,防止后缀绕过;
  • 票据替代 Cookie:短时效、一次性、绑定用户与 IP,把凭据从长连接握手阶段移出;
  • 双层校验:Nginx 与应用层各做一次,避免上游端口被绕过直达;
  • 可验证:用 wscat 与脚本化回归覆盖「恶意来源」「无票据」「有效票据」三类用例,纳入发布前检查。

对以 Cookie 鉴权的既有实时服务,建议先上线步骤一的 Origin 白名单(改动最小,风险最低),再逐步推进票据方案,最终把 /ws/ 的注册入口收敛到「先取票据、再建连接」的单一链路上。