Files
anouma/lib/meeting/signalingServer.ts
T
maro 82c4261a81 Fix security and correctness issues found in full-codebase audit
- Restrict meetingPassword field so it isn't leaked via the public REST/GraphQL API
- Fix booking slot computation to use Europe/Berlin time instead of the server's UTC clock
- Reset email verification when a customer changes their email address
- Fix ICS calendar injection via unescaped carriage returns in event descriptions
- Bind WebRTC meeting kicks to a persistent client id so they survive a rejoin
- Move the meeting signaling token out of the WebSocket query string into the first message
- Prevent duplicate signaling connections from leaking when the same participant reconnects
- Use the rightmost X-Forwarded-For entry for rate limiting (single trusted reverse proxy)
- Use constant-time comparison for the cron endpoint's bearer token
- Remove SVG from allowed Media upload types (stored-XSS risk, unused anyway)
- Add baseline security headers and a CSP to next.config.ts
- Fix CI/CD script injection via unsanitized git ref names in release.yml
2026-09-06 21:59:39 +02:00

116 lines
3.6 KiB
TypeScript

import type { WebSocket, WebSocketServer } from "ws";
import { verifyMeetingToken } from "./token";
import {
broadcast,
getParticipant,
isKicked,
joinRoom,
kickFromRoom,
leaveRoom,
type Participant,
} from "./rooms";
import type { ClientToServerMessage, ParticipantSummary } from "./protocol";
// A connection has this long to send its "auth" message before being
// dropped. The token travels as the first WS message rather than a
// `?token=` query parameter so it doesn't end up persisted in reverse-proxy
// or access logs; it's still HMAC-signed and short-lived either way.
const AUTH_TIMEOUT_MS = 10_000;
function participantSummary(p: Participant): ParticipantSummary {
return { participantId: p.participantId, name: p.name, role: p.role };
}
export function attachSignalingServer(wss: WebSocketServer) {
wss.on("connection", (ws: WebSocket) => {
let self: Participant | null = null;
let eventSlug: string | null = null;
const authTimeout = setTimeout(() => {
if (!self) ws.close(4000, "auth-timeout");
}, AUTH_TIMEOUT_MS);
function handleAuth(token: string) {
const payload = verifyMeetingToken(token);
if (!payload) {
ws.send(JSON.stringify({ type: "auth-error", reason: "invalid-token" }));
ws.close(4000, "invalid-token");
return;
}
if (isKicked(payload.eventSlug, payload.participantId, payload.clientId)) {
ws.close(4001, "removed-by-host");
return;
}
eventSlug = payload.eventSlug;
self = {
ws,
participantId: payload.participantId,
clientId: payload.clientId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
}
ws.on("message", (raw) => {
let message: ClientToServerMessage;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.type === "auth") {
clearTimeout(authTimeout);
if (self) return; // already authenticated on this connection
handleAuth(message.token);
return;
}
if (!self || !eventSlug) return; // no messages accepted before auth
if (message.type === "offer" || message.type === "answer" || message.type === "ice-candidate") {
const target = getParticipant(eventSlug, message.to);
if (target && target.ws.readyState === target.ws.OPEN) {
target.ws.send(
JSON.stringify({ type: message.type, from: self.participantId, payload: message.payload }),
);
}
return;
}
if (message.type === "kick") {
if (self.role !== "host") return; // server-side authority, not client-claimed
const target = getParticipant(eventSlug, message.targetParticipantId);
if (!target) return;
kickFromRoom(eventSlug, target.participantId);
target.ws.send(JSON.stringify({ type: "kicked" }));
target.ws.close(4001, "removed-by-host");
broadcast(eventSlug, { type: "peer-left", participantId: target.participantId });
}
});
ws.on("close", () => {
clearTimeout(authTimeout);
if (!self || !eventSlug) return;
leaveRoom(eventSlug, self.participantId);
broadcast(eventSlug, { type: "peer-left", participantId: self.participantId });
});
});
}