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
This commit is contained in:
2026-09-06 21:59:39 +02:00
parent 1b7dbad843
commit 82c4261a81
19 changed files with 399 additions and 122 deletions
+65 -40
View File
@@ -1,4 +1,3 @@
import type { IncomingMessage } from "node:http";
import type { WebSocket, WebSocketServer } from "ws";
import { verifyMeetingToken } from "./token";
import {
@@ -12,46 +11,61 @@ import {
} 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, req: IncomingMessage) => {
const url = new URL(req.url ?? "", "http://internal");
const token = url.searchParams.get("token") ?? "";
const payload = verifyMeetingToken(token);
wss.on("connection", (ws: WebSocket) => {
let self: Participant | null = null;
let eventSlug: string | null = null;
if (!payload) {
ws.close(4000, "invalid-token");
return;
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,
});
}
if (isKicked(payload.eventSlug, payload.participantId)) {
ws.close(4001, "removed-by-host");
return;
}
const self: Participant = {
ws,
participantId: payload.participantId,
name: payload.name,
role: payload.role,
};
const existing = joinRoom(payload.eventSlug, self);
ws.send(
JSON.stringify({
type: "welcome",
participantId: self.participantId,
role: self.role,
participants: existing.map(participantSummary),
}),
);
broadcast(payload.eventSlug, { type: "peer-joined", ...participantSummary(self) }, {
exclude: self.participantId,
});
ws.on("message", (raw) => {
let message: ClientToServerMessage;
@@ -61,8 +75,17 @@ export function attachSignalingServer(wss: WebSocketServer) {
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(payload.eventSlug, message.to);
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 }),
@@ -73,18 +96,20 @@ export function attachSignalingServer(wss: WebSocketServer) {
if (message.type === "kick") {
if (self.role !== "host") return; // server-side authority, not client-claimed
const target = getParticipant(payload.eventSlug, message.targetParticipantId);
const target = getParticipant(eventSlug, message.targetParticipantId);
if (!target) return;
kickFromRoom(payload.eventSlug, target.participantId);
kickFromRoom(eventSlug, target.participantId);
target.ws.send(JSON.stringify({ type: "kicked" }));
target.ws.close(4001, "removed-by-host");
broadcast(payload.eventSlug, { type: "peer-left", participantId: target.participantId });
broadcast(eventSlug, { type: "peer-left", participantId: target.participantId });
}
});
ws.on("close", () => {
leaveRoom(payload.eventSlug, self.participantId);
broadcast(payload.eventSlug, { type: "peer-left", participantId: self.participantId });
clearTimeout(authTimeout);
if (!self || !eventSlug) return;
leaveRoom(eventSlug, self.participantId);
broadcast(eventSlug, { type: "peer-left", participantId: self.participantId });
});
});
}