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
@@ -1,8 +1,16 @@
import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server";
import { runEventReminders } from "@/lib/meeting/reminders";
export const dynamic = "force-dynamic";
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
/**
* Intended to be called by an external cron job every few minutes, e.g.:
* curl -H "Authorization: Bearer $CRON_SECRET" https://anouma.org/api/cron/event-reminders
@@ -14,7 +22,7 @@ export async function GET(request: Request) {
}
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${secret}`) {
if (!authHeader || !safeEqual(authHeader, `Bearer ${secret}`)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
@@ -20,7 +20,7 @@ type Args = { params: Promise<{ slug: string }> };
export async function POST(request: Request, { params }: Args) {
const { slug } = await params;
let body: { name?: unknown; password?: unknown };
let body: { name?: unknown; password?: unknown; clientId?: unknown };
try {
body = await request.json();
} catch {
@@ -29,6 +29,7 @@ export async function POST(request: Request, { params }: Args) {
const name = typeof body.name === "string" ? body.name.trim() : "";
const password = typeof body.password === "string" ? body.password.trim() : "";
const clientId = typeof body.clientId === "string" ? body.clientId.trim() : undefined;
if (!name) {
return NextResponse.json({ error: "Bitte gib deinen Namen ein." }, { status: 400 });
}
@@ -85,6 +86,10 @@ export async function POST(request: Request, { params }: Args) {
eventSlug: slug,
name,
role: isHost ? "host" : "participant",
// Only meaningful for participants — a host authenticates via a real
// session already, so there's no need to anchor a kick to it (and
// hosts can't be kicked in the first place, see signalingServer.ts).
clientId: isHost ? undefined : clientId,
});
return NextResponse.json({