CVE-2023-2850
Description
NodeBB is affected by a Cross-Site WebSocket Hijacking vulnerability due to missing validation of the request origin. Exploitation of this vulnerability allows certain user information to be extracted by attacker.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
NodeBB lacks WebSocket origin validation, allowing attackers to hijack connections and extract user data via CSRF-like attack.
Vulnerability
Description CVE-2023-2850 is a Cross-Site WebSocket Hijacking vulnerability in NodeBB, a Node.js forum platform. The vulnerability arises because the application does not validate the Origin header during WebSocket handshakes, allowing an attacker to initiate a WebSocket connection from an untrusted site [1]. This is a classic CSRF-like attack vector targeting the WebSocket protocol.
Attack
Vector To exploit this vulnerability, an attacker must trick a logged-in NodeBB user into visiting a malicious webpage while the user's browser has a valid session cookie for the target NodeBB instance. The malicious page can then open a WebSocket connection to the vulnerable server, and because the server does not check origin, the connection is accepted [1]. The attacker can then send and receive messages over this WebSocket, impersonating the victim user.
Impact
Successful exploitation allows the attacker to extract certain user information, such as profile data or private messages, by leveraging the hijacked WebSocket session [1]. The attacker gains the same privileges as the victim user on the forum, potentially leading to further compromise.
Mitigation
The NodeBB development team has addressed this issue by backporting a fix that incorporates a CSRF token in the WebSocket connection setup [2]. The fix replaces the deprecated csurf library with csrf-sync and validates the token during the allowRequest callback of Socket.IO [2][4]. Users are advised to update to the latest patched version. No workaround is available for unpatched instances.
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
nodebbnpm | >= 3.0.0, < 3.1.3 | 3.1.3 |
nodebbnpm | < 2.8.13 | 2.8.13 |
Affected products
2- NodeBB/NodeBBv5Range: < 2.8.13
Patches
362e162cf1e73fix: backport ws token fix
6 files changed · +47 −18
public/src/sockets.js+3 −0 modified@@ -15,6 +15,9 @@ app = window.app || {}; reconnectionDelay: config.reconnectionDelay, transports: config.socketioTransports, path: config.relative_path + '/socket.io', + query: { + _csrf: config.csrf_token, + }, }; window.socket = io(config.websocketAddress, ioParams);
src/middleware/csrf.js+13 −2 modified@@ -5,11 +5,22 @@ const { csrfSync } = require('csrf-sync'); const { generateToken, csrfSynchronisedProtection, + isRequestValid, } = csrfSync({ - size: 64 + getTokenFromRequest: (req) => { + if (req.headers['x-csrf-token']) { + return req.headers['x-csrf-token']; + } else if (req.body && req.body.csrf_token) { + return req.body.csrf_token; + } else if (req.query) { + return req.query._csrf; + } + }, + size: 64, }); module.exports = { generateToken, csrfSynchronisedProtection, -}; + isRequestValid, +}; \ No newline at end of file
src/middleware/index.js+1 −1 modified@@ -2,11 +2,11 @@ const async = require('async'); const path = require('path'); -const { csrfSynchronisedProtection } = require('./csrf'); const validator = require('validator'); const nconf = require('nconf'); const toobusy = require('toobusy-js'); const util = require('util'); +const { csrfSynchronisedProtection } = require('./csrf'); const plugins = require('../plugins'); const meta = require('../meta');
src/socket.io/index.js+25 −13 modified@@ -34,13 +34,25 @@ Sockets.init = async function (server) { } } - io.use(authorize); - io.on('connection', onConnection); const opts = { transports: nconf.get('socket.io:transports') || ['polling', 'websocket'], cookie: false, + allowRequest: (req, callback) => { + authorize(req, (err) => { + if (err) { + return callback(err); + } + const csrf = require('../middleware/csrf'); + const isValid = csrf.isRequestValid({ + session: req.session || {}, + query: req._query, + headers: req.headers, + }); + callback(null, isValid); + }); + }, }; /* * Restrict socket.io listener to cookie domain. If none is set, infer based on url. @@ -62,7 +74,11 @@ Sockets.init = async function (server) { }; function onConnection(socket) { - socket.ip = (socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress || '').split(',')[0]; + socket.uid = socket.request.uid; + socket.ip = ( + socket.request.headers['x-forwarded-for'] || + socket.request.connection.remoteAddress || '' + ).split(',')[0]; socket.request.ip = socket.ip; logger.io_one(socket, socket.uid); @@ -231,9 +247,7 @@ async function validateSession(socket, errorMsg) { const cookieParserAsync = util.promisify((req, callback) => cookieParser(req, {}, err => callback(err))); -async function authorize(socket, callback) { - const { request } = socket; - +async function authorize(request, callback) { if (!request) { return callback(new Error('[[error:not-authorized]]')); } @@ -246,15 +260,13 @@ async function authorize(socket, callback) { }); const sessionData = await getSessionAsync(sessionId); - + request.session = sessionData; + let uid = 0; if (sessionData && sessionData.passport && sessionData.passport.user) { - request.session = sessionData; - socket.uid = parseInt(sessionData.passport.user, 10); - } else { - socket.uid = 0; + uid = parseInt(sessionData.passport.user, 10); } - request.uid = socket.uid; - callback(); + request.uid = uid; + callback(null, uid); } Sockets.in = function (room) {
test/helpers/index.js+4 −1 modified@@ -95,7 +95,7 @@ helpers.logoutUser = function (jar, callback) { }); }; -helpers.connectSocketIO = function (res, callback) { +helpers.connectSocketIO = function (res, csrf_token, callback) { const io = require('socket.io-client'); let cookies = res.headers['set-cookie']; cookies = cookies.filter(c => /express.sid=[^;]+;/.test(c)); @@ -106,6 +106,9 @@ helpers.connectSocketIO = function (res, callback) { Origin: nconf.get('url'), Cookie: cookie, }, + query: { + _csrf: csrf_token, + }, }); socket.on('connect', () => {
test/socket.io.js+1 −1 modified@@ -73,7 +73,7 @@ describe('socket.io', () => { }, (err, res) => { assert.ifError(err); - helpers.connectSocketIO(res, (err, _io) => { + helpers.connectSocketIO(res, body.csrf_token, (err, _io) => { io = _io; assert.ifError(err);
51096ad2345fpoc: use csrf_token in ws handshake (#11573)
5 files changed · +38 −16
public/src/sockets.js+3 −0 modified@@ -15,6 +15,9 @@ app = window.app || {}; reconnectionDelay: config.reconnectionDelay, transports: config.socketioTransports, path: config.relative_path + '/socket.io', + query: { + _csrf: config.csrf_token, + }, }; window.socket = io(config.websocketAddress, ioParams);
src/middleware/csrf.js+5 −1 modified@@ -5,12 +5,15 @@ const { csrfSync } = require('csrf-sync'); const { generateToken, csrfSynchronisedProtection, + isRequestValid, } = csrfSync({ getTokenFromRequest: (req) => { if (req.headers['x-csrf-token']) { return req.headers['x-csrf-token']; - } else if (req.body.csrf_token) { + } else if (req.body && req.body.csrf_token) { return req.body.csrf_token; + } else if (req.query) { + return req.query._csrf; } }, size: 64, @@ -19,4 +22,5 @@ const { module.exports = { generateToken, csrfSynchronisedProtection, + isRequestValid, };
src/socket.io/index.js+25 −13 modified@@ -34,13 +34,25 @@ Sockets.init = async function (server) { } } - io.use(authorize); - io.on('connection', onConnection); const opts = { transports: nconf.get('socket.io:transports') || ['polling', 'websocket'], cookie: false, + allowRequest: (req, callback) => { + authorize(req, (err) => { + if (err) { + return callback(err); + } + const csrf = require('../middleware/csrf'); + const isValid = csrf.isRequestValid({ + session: req.session || {}, + query: req._query, + headers: req.headers, + }); + callback(null, isValid); + }); + }, }; /* * Restrict socket.io listener to cookie domain. If none is set, infer based on url. @@ -62,7 +74,11 @@ Sockets.init = async function (server) { }; function onConnection(socket) { - socket.ip = (socket.request.headers['x-forwarded-for'] || socket.request.connection.remoteAddress || '').split(',')[0]; + socket.uid = socket.request.uid; + socket.ip = ( + socket.request.headers['x-forwarded-for'] || + socket.request.connection.remoteAddress || '' + ).split(',')[0]; socket.request.ip = socket.ip; logger.io_one(socket, socket.uid); @@ -231,9 +247,7 @@ async function validateSession(socket, errorMsg) { const cookieParserAsync = util.promisify((req, callback) => cookieParser(req, {}, err => callback(err))); -async function authorize(socket, callback) { - const { request } = socket; - +async function authorize(request, callback) { if (!request) { return callback(new Error('[[error:not-authorized]]')); } @@ -246,15 +260,13 @@ async function authorize(socket, callback) { }); const sessionData = await getSessionAsync(sessionId); - + request.session = sessionData; + let uid = 0; if (sessionData && sessionData.passport && sessionData.passport.user) { - request.session = sessionData; - socket.uid = parseInt(sessionData.passport.user, 10); - } else { - socket.uid = 0; + uid = parseInt(sessionData.passport.user, 10); } - request.uid = socket.uid; - callback(); + request.uid = uid; + callback(null, uid); } Sockets.in = function (room) {
test/helpers/index.js+4 −1 modified@@ -96,7 +96,7 @@ helpers.logoutUser = function (jar, callback) { }); }; -helpers.connectSocketIO = function (res, callback) { +helpers.connectSocketIO = function (res, csrf_token, callback) { const io = require('socket.io-client'); let cookies = res.headers['set-cookie']; cookies = cookies.filter(c => /express.sid=[^;]+;/.test(c)); @@ -107,6 +107,9 @@ helpers.connectSocketIO = function (res, callback) { Origin: nconf.get('url'), Cookie: cookie, }, + query: { + _csrf: csrf_token, + }, }); socket.on('connect', () => {
test/socket.io.js+1 −1 modified@@ -73,7 +73,7 @@ describe('socket.io', () => { }, (err, res) => { assert.ifError(err); - helpers.connectSocketIO(res, (err, _io) => { + helpers.connectSocketIO(res, body.csrf_token, (err, _io) => { io = _io; assert.ifError(err);
5 files changed · +22 −5
install/package.json+1 −1 modified@@ -55,7 +55,7 @@ "cookie-parser": "1.4.6", "cron": "2.3.0", "cropperjs": "1.5.13", - "csurf": "1.11.0", + "csrf-sync": "4.0.0", "daemon": "1.1.0", "diff": "5.1.0", "esbuild": "0.16.10",
src/controllers/api.js+2 −1 modified@@ -9,6 +9,7 @@ const categories = require('../categories'); const plugins = require('../plugins'); const translator = require('../translator'); const languages = require('../languages'); +const { generateToken } = require('../middleware/csrf'); const apiController = module.exports; @@ -64,7 +65,7 @@ apiController.loadConfig = async function (req) { 'cache-buster': meta.config['cache-buster'] || '', topicPostSort: meta.config.topicPostSort || 'oldest_to_newest', categoryTopicSort: meta.config.categoryTopicSort || 'newest_to_oldest', - csrf_token: req.uid >= 0 && req.csrfToken && req.csrfToken(), + csrf_token: req.uid >= 0 ? generateToken(req) : undefined, searchEnabled: plugins.hooks.hasListeners('filter:search.query'), searchDefaultInQuick: meta.config.searchDefaultInQuick || 'titles', bootswatchSkin: meta.config.bootswatchSkin || '',
src/middleware/csrf.js+15 −0 added@@ -0,0 +1,15 @@ +'use strict'; + +const { csrfSync } = require('csrf-sync'); + +const { + generateToken, + csrfSynchronisedProtection, +} = csrfSync({ + size: 64 +}); + +module.exports = { + generateToken, + csrfSynchronisedProtection, +};
src/middleware/index.js+2 −2 modified@@ -2,7 +2,7 @@ const async = require('async'); const path = require('path'); -const csrf = require('csurf'); +const { csrfSynchronisedProtection } = require('./csrf'); const validator = require('validator'); const nconf = require('nconf'); const toobusy = require('toobusy-js'); @@ -34,7 +34,7 @@ middleware.regexes = { timestampedUpload: /^\d+-.+$/, }; -const csrfMiddleware = csrf(); +const csrfMiddleware = csrfSynchronisedProtection; middleware.applyCSRF = function (req, res, next) { if (req.uid >= 0) {
src/routes/authentication.js+2 −1 modified@@ -10,6 +10,7 @@ const meta = require('../meta'); const controllers = require('../controllers'); const helpers = require('../controllers/helpers'); const plugins = require('../plugins'); +const { generateToken } = require('../middleware/csrf'); let loginStrategies = []; @@ -108,7 +109,7 @@ Auth.reloadRoutes = async function (params) { }; if (strategy.checkState !== false) { - req.session.ssoState = req.csrfToken && req.csrfToken(); + req.session.ssoState = generateToken(req, true); opts.state = req.session.ssoState; }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-4qcv-qf38-5j3jghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-2850ghsaADVISORY
- github.com/NodeBB/NodeBB/commit/51096ad2345fb1d1380bec0a447113489ef6c359ghsaWEB
- github.com/NodeBB/NodeBB/commit/62e162cf1e735e42462be1db9b4954b5a69accdfghsaWEB
- github.com/NodeBB/NodeBB/commit/a5d92da9ddac5607ab7f737520a66eaed6d3ddeeghsaWEB
- github.com/NodeBB/NodeBB/releases/tag/v3.1.3ghsaWEB
- github.com/NodeBB/NodeBB/security/advisories/GHSA-4qcv-qf38-5j3jghsaWEB
News mentions
0No linked articles in our index yet.