What happened?
handleMessage in apps/ws-server/src/utils/handleMessages.ts never verifies
that the sender is an organizer before acting on host-only messages.
On join, the client's role is taken directly from its own payload:
const { sessionId, role, participantId, userId } = payload;
// ...
client.role = role; // whatever the client claimed
Nothing checks this against the QuizOrganizer table. Then quiz:start,
quiz:next-question and quiz:end act on payload.sessionId without looking
at client.role at all.
Impact. Any participant in a live quiz — or anyone who knows a session id —
can:
- start a quiz before the host is ready (
quiz:start)
- skip to any question, or jump to the end (
quiz:next-question)
- end the quiz for everyone mid-question (
quiz:end)
quiz:next-question also accepts an arbitrary questionIndex, so a participant
can request the next question early and see it before others.
Rexial is deployed at rexial.in, so this is exploitable now with a browser
console:
ws.send(JSON.stringify({ type: 'quiz:end', payload: { sessionId: '<id>' } }))
Why the current design cannot catch it. The else if (role === 'PARTICIPANT' && participantId) branch only decides which sync message to send back. It is
not an authorization check, and no later handler revisits the question.
Steps to reproduce
Suggested fix. Two parts, and the first matters most:
- On
join with role: 'ORGANIZER', verify it. The client should send its
auth token, and the server should confirm a quizOrganizer row exists for
that user and quiz with inviteStatus: 'ACCEPTED' — the same check
generateAccessCodeController already does in apps/http-server. Set
client.role from the database result, never from the payload.
- In
quiz:start, quiz:next-question and quiz:end, reject the message
unless client.role === 'ORGANIZER'.
Please also use client.sessionId (set at join) rather than
payload.sessionId, so a client cannot act on a session it never joined.
Definition of done:
- A client claiming
role: 'ORGANIZER' without a valid organizer token is
treated as a participant
quiz:start, quiz:next-question, quiz:end are no-ops for participants
- A test covers at least the "participant sends quiz:end" case
Discuss the approach in a comment before starting — this touches the join
handshake, so it needs a matching frontend change in LiveQuiz.tsx.
Which part of Rexial?
ws-server (WebSockets)
How are you running Rexial?
Docker Compose (docker-compose up)
Logs or error output
Environment
No response
Before submitting
What happened?
handleMessageinapps/ws-server/src/utils/handleMessages.tsnever verifiesthat the sender is an organizer before acting on host-only messages.
On
join, the client's role is taken directly from its own payload:Nothing checks this against the
QuizOrganizertable. Thenquiz:start,quiz:next-questionandquiz:endact onpayload.sessionIdwithout lookingat
client.roleat all.Impact. Any participant in a live quiz — or anyone who knows a session id —
can:
quiz:start)quiz:next-question)quiz:end)quiz:next-questionalso accepts an arbitraryquestionIndex, so a participantcan request the next question early and see it before others.
Rexial is deployed at rexial.in, so this is exploitable now with a browser
console:
Why the current design cannot catch it. The
else if (role === 'PARTICIPANT' && participantId)branch only decides which sync message to send back. It isnot an authorization check, and no later handler revisits the question.
Steps to reproduce
Suggested fix. Two parts, and the first matters most:
joinwithrole: 'ORGANIZER', verify it. The client should send itsauth token, and the server should confirm a
quizOrganizerrow exists forthat user and quiz with
inviteStatus: 'ACCEPTED'— the same checkgenerateAccessCodeControlleralready does inapps/http-server. Setclient.rolefrom the database result, never from the payload.quiz:start,quiz:next-questionandquiz:end, reject the messageunless
client.role === 'ORGANIZER'.Please also use
client.sessionId(set at join) rather thanpayload.sessionId, so a client cannot act on a session it never joined.Definition of done:
role: 'ORGANIZER'without a valid organizer token istreated as a participant
quiz:start,quiz:next-question,quiz:endare no-ops for participantsDiscuss the approach in a comment before starting — this touches the join
handshake, so it needs a matching frontend change in
LiveQuiz.tsx.Which part of Rexial?
ws-server (WebSockets)
How are you running Rexial?
Docker Compose (docker-compose up)
Logs or error output
Environment
No response
Before submitting