CVE-2023-2850 (published 2023-07-25T12:15Z)

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.

CVSS v3 base score: 4.7, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-346
cwe_name: Origin Validation Error
description: The product does not properly verify that the source of data or communication is valid.
extended_description: 
            
            
            
         
         

Fix information:
repo_url: https://github.com/NodeBB/NodeBB
commit_details: repo_url: https://github.com/NodeBB/NodeBB
author: Barış Soner Uşaklı
author_date: 2023-05-15 11:12:46-04:00
msg: poc: use csrf_token in ws handshake (#11573)
repository: repo_url: https://github.com/NodeBB/NodeBB
repo_name: NodeBB/NodeBB
description: Node.js based forum software built for the modern web
repo_language: JavaScript
owner: NodeBB
file_changes: filename: sockets.js
change_type: ModificationType.MODIFY
diff: @@ -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);

programming_language: JavaScript
filename: csrf.js
change_type: ModificationType.MODIFY
diff: @@ -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,
 };

programming_language: TypeScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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) {

programming_language: JavaScript
filename: index.js
change_type: ModificationType.MODIFY
diff: @@ -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', () => {

programming_language: TypeScript
filename: socket.io.js
change_type: ModificationType.MODIFY
diff: @@ -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);
 

programming_language: TypeScript