From 00e4d20bfbd52f856865b1255762ab6be9673faa Mon Sep 17 00:00:00 2001 From: Ankesh N Bhoi Date: Tue, 4 Aug 2026 10:55:25 -0700 Subject: [PATCH] Require caller authorization for ACS token and room endpoints The dev server holds the ACS resource connection string, but its routes had no authorization layer, and `npm start` binds 0.0.0.0 with `.azurewebsites.net` in allowedHosts. Any remote caller could mint a VoIP token for an arbitrary communicationUserId, create rooms, or re-role participants in a known room. Add a server-side session keyed by an opaque HttpOnly cookie and use it to authorize the privileged routes: - POST /getCommunicationUserToken still provisions new identities anonymously, but only re-mints a token for an identity the session already owns. - POST /createRoom requires a session holding an ACS identity, records the creator, validates participant IDs, caps participant count, and coerces pstnDialOutEnabled to a strict boolean. - PATCH /updateParticipant requires the session to be the room creator or to hold an identity that is already a Presenter in the room, and validates the requested role against an allowlist. The cookie is SameSite=Strict so these cookie-authorized, state-changing routes are not CSRF-able, and Secure when served over HTTPS. Also replace the Math.random() GUID used for OneSignal registration tokens with crypto.randomUUID(), since those tokens can be exchanged for ACS access tokens, and return JSON 500s from the room handlers instead of rethrowing, which surfaced stack traces through default error handling. Addresses CWE-639 and CWE-862 alerts on Project/webpack.config.js. --- Project/src/Utils/Utils.js | 23 +++-- Project/webpack.config.js | 180 ++++++++++++++++++++++++++++--------- 2 files changed, 154 insertions(+), 49 deletions(-) diff --git a/Project/src/Utils/Utils.js b/Project/src/Utils/Utils.js index 50ce748..42c8d40 100644 --- a/Project/src/Utils/Utils.js +++ b/Project/src/Utils/Utils.js @@ -21,14 +21,19 @@ export const utils = { if (isJoinOnlyToken) { data.isJoinOnlyToken = isJoinOnlyToken; } - let response = await axios({ - url: 'getCommunicationUserToken', - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - data: JSON.stringify(data) - }) + let response; + try { + response = await axios({ + url: 'getCommunicationUserToken', + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + data: JSON.stringify(data) + }); + } catch (error) { + throw new Error(error.response?.data?.message ?? 'Failed to get ACS User Access token'); + } if (response.status === 200) { return response.data; } @@ -197,7 +202,7 @@ export const utils = { } catch (error) { console.error('Error creating room:', error); - throw error.response.data.message; + throw error.response?.data?.message ?? error.message; } }, updateParticipant: async (patchRoomId, patchParticipantId, patchParticipantRole) => { diff --git a/Project/webpack.config.js b/Project/webpack.config.js index 54676a9..9977257 100644 --- a/Project/webpack.config.js +++ b/Project/webpack.config.js @@ -6,6 +6,7 @@ const clientConfig = require("./clientConfig.json"); const axios = require("axios"); const bodyParser = require('body-parser'); const msal = require('@azure/msal-node'); +const crypto = require('crypto'); const {authConfig, entraCredentialConfig} = require('./oAuthConfig'); const clientId = authConfig.configuration.auth.clientId; @@ -39,11 +40,9 @@ const registerCommunicationUserForOneSignal = async (communicationAccessToken, c return oneSignalRegistrationToken; } +// Registration tokens are exchanged for ACS access tokens, so they must not be guessable. const generateGuid = function () { - function s4() { - return Math.floor((Math.random() + 1) * 0x10000).toString(16).substring(1); - } - return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + return crypto.randomUUID(); } function parseJWT (token) { @@ -77,6 +76,81 @@ const getACSAccessTokenInfo = async (aadToken, userObjectId) => { return tokenResponse; } +// This server holds the ACS resource connection string, so any route that mints tokens or +// manages rooms must first prove the caller owns the identity/room it is asked to act on. +// Sessions are held server-side and keyed by an opaque HttpOnly cookie. +const SESSION_COOKIE_NAME = 'acsSampleSession'; +const SESSION_TTL_MS = 8 * 60 * 60 * 1000; +const MAX_SESSIONS = 1000; +const MAX_ROOM_PARTICIPANTS = 50; +const ROOM_ROLES = new Set(['Presenter', 'Collaborator', 'Attendee', 'Consumer']); +const sessions = new Map(); + +const pruneSessions = () => { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.createdAt > SESSION_TTL_MS) { + sessions.delete(id); + } + } + while (sessions.size > MAX_SESSIONS) { + sessions.delete(sessions.keys().next().value); + } +}; + +const readSessionId = (req) => { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) { + return undefined; + } + for (const pair of cookieHeader.split(';')) { + const separatorIndex = pair.indexOf('='); + if (separatorIndex !== -1 && pair.slice(0, separatorIndex).trim() === SESSION_COOKIE_NAME) { + return pair.slice(separatorIndex + 1).trim(); + } + } + return undefined; +}; + +const getSession = (req) => { + pruneSessions(); + const sessionId = readSessionId(req); + return sessionId ? sessions.get(sessionId) : undefined; +}; + +const startSession = (req, res) => { + const existingSession = getSession(req); + if (existingSession) { + return existingSession; + } + const sessionId = crypto.randomBytes(32).toString('base64url'); + const session = { createdAt: Date.now(), acsUserIds: new Set(), roomIds: new Set() }; + sessions.set(sessionId, session); + pruneSessions(); + const isSecure = req.secure || req.headers['x-forwarded-proto'] === 'https'; + // SameSite=Strict is what keeps these cookie-authorized, state-changing routes from being CSRF-able. + res.setHeader('Set-Cookie', `${SESSION_COOKIE_NAME}=${sessionId}; Path=/; HttpOnly; SameSite=Strict${isSecure ? '; Secure' : ''}`); + return session; +}; + +// A caller may manage a room it created through this server, or one where an identity it +// owns is already a Presenter. +const isAuthorizedForRoom = async (roomsClient, session, roomId) => { + if (session.roomIds.has(roomId)) { + return true; + } + try { + for await (const participant of roomsClient.listParticipants(roomId)) { + if (participant.role === 'Presenter' && session.acsUserIds.has(participant.id?.communicationUserId)) { + return true; + } + } + } catch (e) { + console.error('Failed to verify room membership', e); + } + return false; +}; + const path = require('path'); // Base configuration shared between ESM and UMD builds @@ -191,13 +265,19 @@ const devServerSettings = { devServer.app.use(bodyParser.json()); devServer.app.post('/getCommunicationUserToken', async (req, res) => { try { + const session = startSession(req, res); const communicationUserId = req.body.communicationUserId; const isJoinOnlyToken = req.body.isJoinOnlyToken === true; let CommunicationUserIdentifier; if (!communicationUserId) { CommunicationUserIdentifier = await communicationIdentityClient.createUser(); - } else { + session.acsUserIds.add(CommunicationUserIdentifier.communicationUserId); + } else if (typeof communicationUserId === 'string' && session.acsUserIds.has(communicationUserId)) { CommunicationUserIdentifier = { communicationUserId: communicationUserId }; + } else { + // Minting a token for an arbitrary identity would let any caller impersonate it. + res.status(403).json({ message: 'This session does not own the requested ACS identity. Request a new identity, or sign in with an access token you already hold.' }); + return; } const communicationUserToken = await communicationIdentityClient.getToken(CommunicationUserIdentifier, [isJoinOnlyToken ? "voip.join" : "voip"]); let oneSignalRegistrationToken; @@ -317,39 +397,38 @@ const devServerSettings = { }); devServer.app.post('/createRoom', async (req, res) => { try { - let participants = []; - console.log('req.body:', req.body); - if (req.body.presenterUserIds && Array.isArray(req.body.presenterUserIds)) { - req.body.presenterUserIds.forEach(presenterUserId => { - participants.push({ - id: { communicationUserId: presenterUserId }, - role: "Presenter" - }); - }); - } - if (req.body.collaboratorUserIds && Array.isArray(req.body.collaboratorUserIds)) { - req.body.collaboratorUserIds.forEach(collaboratorUserId => { - participants.push({ - id: { communicationUserId: collaboratorUserId }, - role: "Collaborator" - }); - }); + const session = getSession(req); + if (!session || session.acsUserIds.size === 0) { + res.status(401).json({ message: 'Provision an ACS identity from this app before creating a room.' }); + return; } - if (req.body.attendeeUserIds && Array.isArray(req.body.attendeeUserIds)) { - req.body.attendeeUserIds.forEach(attendeeUserId => { + let participants = []; + let invalidParticipant = false; + const addParticipants = (userIds, role) => { + if (!Array.isArray(userIds)) { + return; + } + userIds.forEach(userId => { + if (typeof userId !== 'string' || !userId.trim()) { + invalidParticipant = true; + return; + } participants.push({ - id: { communicationUserId: attendeeUserId }, - role: "Attendee" + id: { communicationUserId: userId.trim() }, + role }); }); - } - if (req.body.consumerUserIds && Array.isArray(req.body.consumerUserIds)) { - req.body.consumerUserIds.forEach(consumerUserId => { - participants.push({ - id: { communicationUserId: consumerUserId }, - role: "Consumer" - }); + }; + addParticipants(req.body.presenterUserIds, "Presenter"); + addParticipants(req.body.collaboratorUserIds, "Collaborator"); + addParticipants(req.body.attendeeUserIds, "Attendee"); + addParticipants(req.body.consumerUserIds, "Consumer"); + + if (invalidParticipant) { + res.status(400).json({ + message: "Every participant ID must be a non-empty string." }); + return; } if (participants.length === 0) { @@ -359,10 +438,16 @@ const devServerSettings = { return; } - console.log('participants:', participants); + if (participants.length > MAX_ROOM_PARTICIPANTS) { + res.status(400).json({ + message: `A room cannot be created with more than ${MAX_ROOM_PARTICIPANTS} participants.` + }); + return; + } + const validFrom = new Date(Date.now()); const validUntil = new Date(validFrom.getTime() + 60 * 60 * 1000); - const pstnDialOutEnabled = req.body.pstnDialOutEnabled; + const pstnDialOutEnabled = req.body.pstnDialOutEnabled === true; const roomsClient = new RoomsClient(config.connectionString); const createRoom = await roomsClient.createRoom({ validFrom, @@ -371,9 +456,9 @@ const devServerSettings = { participants }); const roomId = createRoom.id; + session.roomIds.add(roomId); console.log('\nRoom successfully created'); console.log('Room ID:', roomId); - console.log('Participants:', participants); res.setHeader('Content-Type', 'application/json'); res.status(200).json({ @@ -381,27 +466,42 @@ const devServerSettings = { }); } catch (e) { console.error(e); - throw e; + res.status(500).json({ message: 'Failed to create room.' }); } }); devServer.app.patch('/updateParticipant', async (req, res) => { try { + const session = getSession(req); + if (!session || session.acsUserIds.size === 0) { + res.status(401).json({ message: 'Provision an ACS identity from this app before updating a room.' }); + return; + } const roomId = req.body.patchRoomId; const participantId = req.body.patchParticipantId; const participantRole = req.body.patchParticipantRole; + if (typeof roomId !== 'string' || !roomId.trim() || + typeof participantId !== 'string' || !participantId.trim() || + !ROOM_ROLES.has(participantRole)) { + res.status(400).json({ message: `Provide a room ID, a participant ID, and one of these roles: ${[...ROOM_ROLES].join(', ')}.` }); + return; + } const roomsClient = new RoomsClient(config.connectionString); + if (!await isAuthorizedForRoom(roomsClient, session, roomId.trim())) { + res.status(403).json({ message: 'This session is not authorized to manage the requested room.' }); + return; + } const participant = [ { - id: { communicationUserId: participantId}, + id: { communicationUserId: participantId.trim() }, role: participantRole, }, ]; - await roomsClient.addOrUpdateParticipants(roomId, participant); + await roomsClient.addOrUpdateParticipants(roomId.trim(), participant); res.setHeader('Content-Type', 'application/json'); res.status(200).json({message: 'Participant updated successfully'}); } catch (e) { console.error(e); - throw e; + res.status(500).json({ message: 'Failed to update participant.' }); } });