SockJS-node安全防护:CSRF攻击防范与授权机制设计
做Web实时通信开发的同学应该都有体会,SockJS在浏览器和服务器之间建立长连接这件事上确实省了不少心,特别是自动降级处理,比直接裸用WebSocket要稳妥得多。但框架帮你解决了连接问题,不代表安全问题也可以高枕无忧。生产环境里一旦涉及登录态、消息推送或者实时数据同步,CSRF和授权这两道关卡就是绕不过去的硬仗。
今天这篇就把我实际项目中积累的SockJS-node安全加固经验整理出来,围绕CSRF攻击防范和授权机制设计展开,从原理到代码再到排坑,尽量说透。适合已经会用SockJS做基础消息推送、想在生产环境把安全补齐的Node开发者。
1. 内容整体设计与思路拆解
1.1 SockJS的握手流程里藏着什么风险
先重新梳理一下SockJS的通信过程。浏览器发起的是HTTP请求,服务器返回的是一个模拟的WebSocket握手响应。SockJS的HTTP端点是/info,用来协商传输方式,而实际的消息通道则是/server/session/transport这样的路径。大多数教程里,这些端点注册好就直接用了,没人觉得哪里不对劲。
问题就出在握手阶段。SockJS握手本质上是一次HTTP GET请求,浏览器在发起这种请求时会自动携带当前域名下的Cookie。如果你的会话标识是放在Cookie里的,那么一个恶意网页只要向你的SockJS端点发起一个跨站请求,服务器就会认为这是一个合法的连接请求,因为Cookie会自动带上。
这正好是CSRF的经典利用场景。攻击者不需要读取你的响应,只需要让服务器建立一条真实的长连接,就能借助这条通道注入恶意消息——比如伪造一个下单指令、修改资料请求、或者往房间里推送垃圾消息。SockJS的消息帧格式简单,攻击者完全可以把恶意载荷塞进JSON格式里发出去。
更隐蔽的一点是,SockJS默认支持JSONP轮询作为降级传输。JSONP不受同源策略限制,响应会以JavaScript脚本的形式执行,这意味着跨站读取响应变成可能,信息泄露风险比普通CSRF更严重。如果服务端没有做额外的校验,JSONP这条通道等于给攻击者开了一扇侧门。
所以安全设计的第一步是:不要默认相信SockJS帮你处理的那些传输细节,必须自行验证每次握手请求的合法性。
1.2 双轨设计:握手校验与消息级鉴权
我的整体思路是把安全问题拆成两条线分头治理。
第一条线是传输层校验,核心任务是把攻击者挡在门外。所有SockJS连接在建立时都要通过身份验证,未登录或凭证无效的请求直接拒绝握手。这一层用Token而非Cookie作为主要凭证,从源头上规避浏览器自动携带凭证的问题。
第二条线是消息层鉴权,核心任务是保证即使连接建立成功,不同用户也只能操作自己权限范围内的资源。WebSocket一旦建立,后续就是全双工长连接,不能指望每次请求都走一遍HTTP标准的认证头,所以需要在消息协议层面定义一套权限控制规则。
传输层校验解决"你是谁"的问题,消息层鉴权解决"你能做什么"的问题。两层配合,既挡住跨站伪造的连接,也避免已登录用户越权操作。
1.3 为什么说"仅靠Origin检查"不够
很多团队觉得只要检查Origin头就够了,这是一个危险的误区。Origin头确实能拦截绝大多数浏览器发起的跨站请求,但它有几个绕不过的漏洞:非浏览器客户端可以任意伪造Origin头;部分老版本浏览器的实现存在不一致;即使Origin检查通过了,也无法区分操作者是不是本人,比如用户在另一个标签页访问了恶意站点,Origin是合法站点,但操作是攻击者诱导的。
CSRF的防御层次应当是立体的:Token校验是主力,Origin检查是辅助,自定义请求头是补充,SameSite Cookie策略是底线。单靠任何一层都有破绽,叠加起来才够稳。我在项目里把这四道防线全部启用,原则是:宁可多拦十个正常请求,也不放过一个异常连接。
2. 核心细节解析与实操要点
2.1 基于Token的CSRF防护方案
先说主力方案。与SockJS配合时,我采用的是双重提交Token模式:服务端生成随机Token下发到前端页面,前端在建立连接时把Token放在连接请求的查询参数或自定义Header里,服务端校验Token的合法性。
这里要注意一个关键点:Token不要放在Cookie里与请求一起自动发送。如果放在Cookie里,浏览器跨站请求会自动带上,等于没防。正确做法是页面从服务端接口获取Token,存储在sessionStorage或者内存变量里,然后每次建立SockJS连接时显式携带。
以下是我在项目中常用的生成和校验逻辑:
const crypto = require('crypto'); function generateCsrfToken(userId) { const raw = `${userId}:${Date.now()}:${crypto.randomBytes(24).toString('hex')}`; return crypto.createHmac('sha256', process.env.CSRF_SECRET) .update(raw) .digest('hex'); } function verifyCsrfToken(token, userId) { const expected = `${userId}:`; // 解析token中的userId前缀,与当前请求用户比对 const buf = Buffer.from(token, 'hex'); // 实际项目中可维护一份token映射表,校验后即失效 return true; // 占位,实际校验逻辑需要结合存储 }Token的存储方式我建议放在Redis里,带过期时间比如30分钟。连接建立时校验一次即可,不必每次消息都校验——但要结合消息层鉴权来兜底。校验通过后删除或标记这个Token,防止同一Token被重复利用,能有效降低重放攻击风险。
2.2 自定义Header与查询参数的选择
SockJS建立连接时使用的是HTTP GET。这意味着两种携带Token的方式都可行:自定义Header和URL查询参数。
自定义Header的好处是干净,不出现在日志里,也不容易被中间代理缓存。但有一个兼容性隐患:SockJS在降级到XHR轮询模式下,自定义Header是可以正常发送的;如果降级到JSONP模式,就有一个严重限制——JSONP请求无法自定义Header。当SockJS选择JSONP作为传输方式时,你只能把Token放到查询参数里,否则连接携带不了凭证。
我实测下来,生产环境最佳做法是:关闭JSONP传输方式,同时使用自定义Header携带Token。SockJS客户端初始化时可以通过transports配置来控制可用传输方式:
const socket = new SockJS('/realtime', null, { // 移除jsonp,只保留支持自定义Header的传输方式 transports: ['xhr-polling', 'websocket'] }); socket._sockjsTransport = 'xhr-polling';在transports配置里显式去掉jsonp之后,SockJS只会使用XHR和WebSocket两种方式,这两种都允许自定义Header。配合withCredentials设置,前端可以把Token放进Header里,服务端再从Header中取出校验。
服务端接收自定义Header时,注意大小写问题。Node的HTTP解析器会把Header名转为小写,所以取用时使用小写形式:
const token = req.headers['x-csrf-token'];2.3 服务端为什么必须显式处理CORS预检请求
设置自定义Header后,浏览器在正式发送SockJS连接请求之前,如果跨域,会先发一个OPTIONS预检请求。很多Node服务没有处理OPTIONS,默认返回404,导致SockJS握手全部失败。
处理预检请求时,必须显式返回允许的Header列表。否则即使用了CORS中间件,浏览器也会因为自定义Header不在允许列表里而阻断正式请求:
app.options('*', (req, res) => { res.setHeader('Access-Control-Allow-Origin', config.corsOrigin); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-CSRF-Token'); res.setHeader('Access-Control-Max-Age', '86400'); res.sendStatus(204); });这里还有一个细节容易被忽略:Access-Control-Allow-Origin不建议设置为*,因为SockJS传输需要携带凭证(Cookie或Authorization头),通配符会导致浏览器拒绝。
2.4 授权机制的分层设计思路
把授权机制设计成三层,每一层解决不同维度的控制问题。
第一层是连接级授权,决定用户能不能建立SockJS连接。标准是用户是否已登录、Token是否有效、用户状态是否被禁用。这一层在握手阶段完成,通过则连接建立,否则直接拒绝。
第二层是频道级授权,决定用户能订阅/加入哪些频道。SockJS本身没有频道概念,但实际项目中往往在消息体内定义roomId、channel之类的字段。服务端在用户加入频道时需要校验其权限,比如普通用户不能加入管理频道,被拉黑用户不能加入对方频道等。
第三层是操作级授权,决定用户能在频道内执行哪些操作。比如能发消息不等于能删除消息,能查看聊天记录不等于能导出数据。操作级授权通常在消息处理函数内做细粒度校验。
2.5 用户身份在长连接中的绑定方案
握手时校验了Token确认了用户身份,但后续消息怎么知道是哪个人发的?SockJS连接对象和HTTP请求对象是分离的,二者不会自动关联。我采用的方案是在握手成功后建立连接会话状态,把用户身份存储在session级的Map里:
const connections = new Map(); // sessionId -> { userId, roles, joinedChannels } function handleConnection(session) { // session是握手时创建的对象,包含userId、roles等信息 connections.set(session.id, session); } function getSessionBySockJSSession(sockjsSession) { return connections.get(sockjsSession.id); }这样每条消息进来时,通过sockjsSession.id从Map中取出对应的会话对象,再从会话对象中拿到userId和角色信息,完成权限判断。注意用Map管理连接时,要在连接关闭事件里清理对应条目,避免内存泄漏。
3. 实操过程与核心环节实现
3.1 搭建SockJS服务端与安全中间件架构
下面给出一个可直接参考的整体示例。这里我使用Express作为HTTP框架,sockjs-node作为SockJS服务端实现。
先看完整的服务端骨架:
const express = require('express'); const http = require('http'); const sockjs = require('sockjs'); const crypto = require('crypto'); const redis = require('redis'); const app = express(); const server = http.createServer(app); // ===== 1. CSRF Token存储(Redis) ===== const redisClient = redis.createClient({ url: process.env.REDIS_URL }); redisClient.connect(); // ===== 2. SockJS服务创建 ===== const realtime = sockjs.createServer({ prefix: '/realtime', // 生产环境建议关闭WebSocket压缩,避免安全漏洞 websocket: { compression: false } }); // ===== 3. 配置安全中间件 ===== app.use(express.json()); app.use((req, res, next) => { // 登录接口等白名单路径放行 const whitelist = ['/api/login', '/api/csrf-token']; if (whitelist.includes(req.path)) return next(); // 校验CSRF Token(非SockJS端点也要校验) const token = req.headers['x-csrf-token']; // 从Redis中校验token redisClient.get(`csrf:${token}`).then(userId => { if (!userId) return res.status(403).json({ error: 'CSRF token invalid' }); req.userId = userId; next(); }).catch(() => res.status(500).json({ error: 'server error' })); });这个示例里我做了两件事:普通HTTP API受CSRF中间件保护;SockJS握手请求同样经过这套校验,因为SockJS的握手本质也是HTTP请求。
3.2 握手阶段的CSRF校验与身份绑定
SockJS服务端有一个connection事件,在握手通过后触发。但注意,sockjs-node在触发connection之前,并不会对HTTP请求的Header做任何校验。所以需要在SockJS的回调里主动取到握手请求,提取Header中的Token并校验:
realtime.on('connection', function(conn) { // conn.request 是握手时的HTTP请求对象 const headers = conn.request.headers; const token = headers['x-csrf-token']; if (!token) { conn.close('missing-csrf-token'); return; } // 异步校验token validateCsrfToken(token).then(({ userId, roles }) => { if (!userId) throw new Error('invalid token'); // 建立会话,绑定用户身份 const session = { id: conn.id, userId, roles, joinedChannels: new Set(), conn }; connections.set(conn.id, session); // 给客户端发送连接成功消息 conn.write(JSON.stringify({ type: 'connected', message: 'ok' })); }).catch(() => { conn.close('unauthorized'); }); });这段代码的关键点在于:Token校验在连接建立的消息循环开始之前就完成。如果校验失败,直接close连接,后续任何消息都不会被处理。这样攻击者即使发出握手请求,也无法建立可用连接。
3.3 消息级授权:基于角色的操作拦截
握手绑定身份只是第一步,消息处理时的权限判断才是重点。我定义了一套通用的消息体协议:
{ "type": "join_channel", "data": { "channel": "room_1024" } }消息处理函数按照type分发,每个处理函数内部做权限检查:
realtime.on('connection', function(conn) { conn.on('data', function(message) { const session = getSessionBySockJSSession(conn.id); if (!session) { conn.close('session-expired'); return; } let parsed; try { parsed = JSON.parse(message); } catch (e) { conn.write(JSON.stringify({ type: 'error', message: 'invalid json' })); return; } const handlers = { join_channel: handleJoinChannel, send_message: handleSendMessage, delete_message: handleDeleteMessage }; const handler = handlers[parsed.type]; if (!handler) { conn.write(JSON.stringify({ type: 'error', message: 'unknown type' })); return; } handler(session, parsed.data, conn).catch(err => { conn.write(JSON.stringify({ type: 'error', message: err.message })); }); }); });每个处理函数里都要做角色和资源权限的校验。以删除消息为例:
async function handleDeleteMessage(session, data, conn) { // data = { messageId: '12345' } const message = await messageStore.findById(data.messageId); if (!message) { throw new Error('message not found'); } const isAuthor = message.userId === session.userId; const isModerator = session.roles.includes('moderator'); const isAdmin = session.roles.includes('admin'); if (!isAuthor && !isModerator && !isAdmin) { conn.write(JSON.stringify({ type: 'error', message: 'permission denied' })); return; } // 管理员可以删除,原作者只能删除自己的消息 await messageStore.removeById(data.messageId); conn.write(JSON.stringify({ type: 'ok', message: 'deleted' })); }这种每个操作都显式校验的做法看起来繁琐,但生产环境里安全就是靠繁琐换来的。一旦图省事把校验集中到连接建立时,后续消息就失去控制,等于把城门打开后放任不管。
3.4 前端接入的完整流程
前端这边,以浏览器原生JavaScript为例。初始化时的关键动作包括:获取Token、配置传输方式、设置Header:
async function initRealtime() { // 第一步:从服务端获取CSRF Token const resp = await fetch('/api/csrf-token', { credentials: 'same-origin' }); const { csrfToken } = await resp.json(); sessionStorage.setItem('csrfToken', csrfToken); // 第二步:创建SockJS连接,自定义Header携带Token const socket = new SockJS('/realtime', null, { transports: ['websocket', 'xhr-polling'], withCredentials: true }); // SockJS的options不支持直接带headers,需要在内部注入 // 通过装饰原始请求来实现 const originalTransport = socket._options.transport; socket._options.transport = function(transport) { const t = originalTransport.call(this, transport); // 对xhr做wrapper注入header return t; }; }这里有个坑我必须说明:SockJS官方客户端并没有直接暴露自定义Header的选项。在浏览器环境下,最简单的办法是使用xhr-polling传输时通过xhr对象的自定义属性注入,但更干净的做法是封装一个带Header注入能力的请求层。我在项目中是fork了SockJS客户端代码,在XHRTransport的_send方法里统一注入了X-CSRF-Token。如果不想改动源码,另一个变通办法是使用查询参数携带Token,但注意不要让Token出现在日志中,查询参数在Nginx日志和浏览器历史里都会留下痕迹。
3.5 使用查询参数携带Token的补充方案
实在需要快速部署、不打算修改客户端源码的场景下,可以使用查询参数。服务端从req.url中解析出Token,代码稍微多一点,但完全可行:
function extractTokenFromQuery(url) { const parsed = new URL(url, 'http://localhost'); const token = parsed.searchParams.get('csrf_token'); return token; } function validateAndExtractUser(token) { // 从Redis中取userId return redisClient.get(`csrf:${token}`).then(userId => { if (!userId) return null; return userId; }); }但我不建议把这种方法作为长期方案。查询参数里的Token容易通过Referrer头泄露给第三方,尤其是页面里还有其他第三方资源时风险更大。只适合内部系统或快速验证场景。
4. 常见问题与排查技巧实录
4.1 SockJS握手被CORS拦截,报403错误
现象:前端发起SockJS连接时,浏览器控制台出现Access to XMLHttpRequest has been blocked by CORS policy之类信息,连接始终建立不起来。
排查思路:首先看服务端是否正确处理了OPTIONS预检请求。如果你在之前引入了cors中间件但只配置了origin,那Access-Control-Allow-Headers可能没包含X-CSRF-Token,浏览器就会拒绝。
修复方法很简单:
app.use(require('cors')({ origin: config.clientOrigin, methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'X-CSRF-Token'] }));我遇到过一个特殊情况:服务端配置没问题,但开发环境下前端跑在localhost:3000,后端跑在localhost:8080,跨域请求带自定义Header,Chrome有时会缓存旧的预检结果。清理浏览器缓存或者把开发环境的Access-Control-Max-Age设小一点就能解决。
4.2 连接握手成功但3秒后被断开
现象:SockJS连接能建立,但过了一会儿就收到Connection closed事件,查看服务端日志有一条write after end或不明确的close记录。
这类问题多半是握手阶段Token校验通过后,异步逻辑里没有持有连接对象的引用。Node的异步回调执行完毕时,SockJS的服务端可能已经把连接标记为待清理状态——尤其是你在connection事件后立即写了一个conn.write,而客户端没有及时读取,触发超时断开。
正确做法是在握手时先发送一个ping帧保持活跃,同时在后端启用heartbeat_delay配置:
const realtime = sockjs.createServer({ prefix: '/realtime', heartbeat_delay: 25000, // 25秒心跳 disconnect_delay: 5000 // 5秒没响应才断开 });同时前端收到connected消息后,立刻send一条{'type': 'ack'}确认消息,确保连接进入活跃状态。
4.3 多个SockJS节点下会话不同步怎么办
如果服务是横向扩展的多实例部署,connections这个Map在每个实例上是独立的。用户连接到A节点,但后续消息被路由到B节点时,B节点不知道这个用户是谁,权限判断就无法执行。
解决方案有两种思路。第一种是使用Redis Pub/Sub做消息广播时,在消息体内携带userId和roles等身份信息,这样无论哪个节点收到消息都能完成权限判断。第二种是采用粘性会话,让同一用户的连接始终落在同一台节点上,这样本地Map就够了。
我倾向于第一种思路。因为即使有粘性会话,某个节点宕机后连接迁移到其他节点,本地Map依然是缺失的。在消息体内携带身份信息虽然多写几个字段,但幂等性和容错性都更好:
// 消息体规范 { "type": "send_message", "payload": { "channel": "room_1024", "content": "hello" }, "meta": { "userId": "u_1001", "roles": ["member"] } }注意meta字段要由服务端在握手时写入Session,前端传上来的meta不可信,必须用Session里保存的身份覆盖。
4.4 测试环境一切正常,生产环境频繁被顶下线
这种现象大部分不是代码逻辑问题,而是负载均衡层的配置问题。生产环境通常用Nginx做反向代理,Nginx默认对HTTP长连接有超时设置,如果proxy_read_timeout小于SockJS的心跳间隔,连接会被Nginx主动掐断。
解决方法是调整Nginx配置:
location /realtime { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_buffering off; }另外,如果整个内网部署了多层代理或者安全设备,要确认这些设备不会对长时间空闲连接做清理。
4.5 关于CSRF Token的并发使用问题
用户可能打开多个标签页,每个标签页都会请求一个新的CSRF Token。如果Token设计成一次性使用,那么后打开的标签页把前面的Token给顶掉了,前面的连接就会中断。
我的处理方案是:Token不设一次性失效,但设置有效期(30分钟),并且同一用户的多个Token在Redis里以csrf:{userId}:{token}的形式存储,互不干扰。连接建立时校验是否存在、是否过期即可。这样能兼顾安全性和多标签页体验。
同时,每次成功建立连接后,我会要求客户端立即请求一个新的Token用于后续操作,避免一个Token长时间有效被攻击者利用。具体逻辑是:服务端在connected消息里带上new_csrf_token字段,前端收到后更新本地存储。
4.6 安全响应头是最后一层防线
除了主动防御CSRF,我还习惯在服务端加上一组安全响应头,作为纵深防御的一部分:
app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Cache-Control', 'no-store'); next(); });这组响应头不是为了专门防CSRF,但能避免一些与CSRF关联的辅助攻击,比如点击劫持、内容嗅探等。防CSRF时大家常忽略一点:如果响应可被缓存在浏览器里,攻击者可能会诱导用户访问缓存页面,从而在后续操作中复用旧Token。Cache-Control: no-store可以有效规避这个问题。
5. 安全补强清单与性能平衡建议
5.1 完整的安全检查清单
我在项目中整理了一份上线前必须逐项确认的安全清单,分享出来供参考:
- 是否在SockJS握手前完成了CSRF Token校验?
- 是否移除了JSONP传输方式?
Access-Control-Allow-Origin是否配置为具体域名而非*?- 是否使用了自定义Header携带Token?
- 是否对每个消息类型逐一做了权限校验?
- 连接关闭时是否正常清理了会话Map条目?
- Token是否有有效期设置?
- Nginx或其他反向代理的超时配置是否匹配心跳间隔?
- 是否有完整的连接数与消息频率监控?
每一条看起来都很基础,但我见过太多上线事故就栽在其中一条上面。尤其是第2条移除JSONP,很多开发压根不知道SockJS默认会尝试JSONP,结果Token从Header传到服务端全是undefined,排查半天才发现是JSONP请求根本带不了自定义Header。
5.2 Token过一次校验还是每次校验
一个很常见的性能取舍问题。如果在每条消息上都校验CSRF Token,高频消息场景下Redis请求量会跟着翻倍,延迟和资源开销都上去了。
我的建议是分场景处理。对于连接型的SockJS场景(比如通知推送、聊天),只在握手时校验一次就够,因为连接一旦建立,后续消息都来自同一经过校验的连接。但对于消息价值高、风险也不小的场景(比如实时交易、修改配置),可以在关键操作上再加一次校验。
具体做法是消息体里带上requestToken字段,每次操作前校验这个Token与用户身份是否匹配。这会增加一次Redis查询,但安全性提升明显,高价值场景值得付出这个成本。
5.3 日志与监控:别等被攻击了才想起来看
安全措施做得好不好,缺乏可观测性等于白做。我在项目中为SockJS增加了三类日志:
第一类是握手日志。记录每次连接请求的来源IP、User-Agent、Token校验结果,失败的请求单独落一份security.log,方便事后溯源。
第二类是权限拒绝日志。记录用户请求了哪些无权操作,通常这类日志能提前暴露暴力破解或者权限扫描的行为特征。
第三类是连接生命周期日志。记录连接建立时间、断开原因、在线时长等。异常短生命周期连接(比如每秒建立一次然后立刻断开)往往是攻击者扫描端点的特征。
function logSecurityEvent(type, message) { const entry = { type, message, timestamp: new Date().toISOString() }; fs.appendFileSync('logs/security.log', JSON.stringify(entry) + '\n'); }这些日志再接到监控告警里,比如每分钟权限拒绝超过20次就告警。这一层防护做扎实了,很多问题在造成实质危害之前就能被发现。
6. 我的实操体会
在把SockJS安全这一整套折腾完之后,我最深的感触是:不要因为SockJS在传输层帮你解决了兼容性问题,就想当然地认为安全也能自动搞定。传输兼容性是一个框架的功能价值,而CSRF防范和授权机制是每一个接入方都必须亲自负责的事情,框架不会替你设计业务层面的权限模型。
项目里投入时间最多的其实不是写校验代码,而是排查那些"看起来正常但连接总是断"的边界场景。比如自定义Header在降级传输时的丢失、多标签页导致Token相互覆盖、反向代理悄悄关闭空闲连接等等,任何一个环节没处理好,用户端的表现都是迷之断线。这些问题最后都能在日志里找到线索,建议各位上线前把三类日志(握手、拒绝、生命周期)提前做好布点,排障效率能提升一个量级。
安全这件事没有一次性做完的说法。Token过期时间要不要缩短,权限模型要不要细化到资源实例级别,都是后续跟着业务一起演进的课题。至少先把CSRF和授权这两道最基础的关卡扎扎实实做好,你的SockJS服务才算真正具备上生产的底气。