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:
@@ -29,6 +29,11 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const sessionRef = useRef<StoredMeetingSession | null>(null);
|
||||
const remotesRef = useRef<Map<string, RemoteEntry>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
remotesRef.current = remotes;
|
||||
}, [remotes]);
|
||||
|
||||
const send = useCallback((message: ClientToServerMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
@@ -149,9 +154,15 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
if (cancelled) return;
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling?token=${encodeURIComponent(parsed.token)}`);
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/signaling`);
|
||||
wsRef.current = ws;
|
||||
|
||||
// Token travels as the first message, not a `?token=` query param, so
|
||||
// it doesn't end up persisted in reverse-proxy/access logs.
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({ type: "auth", token: parsed.token }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message: ServerToClientMessage = JSON.parse(event.data);
|
||||
handleServerMessage(message);
|
||||
@@ -207,11 +218,18 @@ export function CallRoom({ slug }: { slug: string }) {
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
case "auth-error": {
|
||||
setPhase("error");
|
||||
setErrorMessage("Die Meeting-Sitzung ist abgelaufen oder ungültig.");
|
||||
cleanup();
|
||||
sessionStorage.removeItem(MEETING_SESSION_KEY);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remotesLookupName(participantId: string): string {
|
||||
return remotes.get(participantId)?.name ?? "Teilnehmer:in";
|
||||
return remotesRef.current.get(participantId)?.name ?? "Teilnehmer:in";
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
|
||||
import { TextField, FormError } from "@/components/FormField";
|
||||
|
||||
export const MEETING_SESSION_KEY = "anouma-meeting-session";
|
||||
const MEETING_CLIENT_ID_KEY = "anouma-meeting-client-id";
|
||||
|
||||
// A stable per-browser identity so a host's "kick" survives the removed
|
||||
// participant simply reconnecting (which always gets a fresh participantId
|
||||
// — see lib/meeting/token.ts). Deliberately in localStorage, not
|
||||
// sessionStorage: it must outlive the "kicked" screen clearing the session
|
||||
// and the "Erneut beitreten" link going back through this form again.
|
||||
function getOrCreateClientId(): string {
|
||||
try {
|
||||
const existing = localStorage.getItem(MEETING_CLIENT_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const created = crypto.randomUUID();
|
||||
localStorage.setItem(MEETING_CLIENT_ID_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
// Storage unavailable (private browsing, etc.) — fall back to a
|
||||
// one-off id; kicks just won't stick across reconnects in that case.
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
}
|
||||
|
||||
export type StoredMeetingSession = {
|
||||
token: string;
|
||||
@@ -33,7 +51,7 @@ export function JoinForm({ slug }: { slug: string }) {
|
||||
const res = await fetch(`/api/meetings/${slug}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, password }),
|
||||
body: JSON.stringify({ name, password, clientId: getOrCreateClientId() }),
|
||||
credentials: "include",
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -62,41 +80,27 @@ export function JoinForm({ slug }: { slug: string }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Dein Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
|
||||
Meeting-Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="text"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
className={fieldClass}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<TextField
|
||||
label="Dein Name"
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name eingeben"
|
||||
autoComplete="name"
|
||||
/>
|
||||
<TextField
|
||||
label="Meeting-Passwort"
|
||||
id="password"
|
||||
type="text"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Passwort eingeben"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{error && <FormError>{error}</FormError>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
|
||||
Reference in New Issue
Block a user