- 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
116 lines
3.6 KiB
TypeScript
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 });
|
|
});
|
|
});
|
|
}
|