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
View File
@@ -23,6 +23,7 @@ jobs:
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
+25 -8
View File
@@ -19,6 +19,7 @@ jobs:
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
PAYLOAD_SECRET: ci-only-not-a-real-secret
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
CRON_SECRET: ci-only-not-a-real-secret
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
steps:
@@ -67,27 +68,43 @@ jobs:
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
# mechanism (the `gitea.*` vs `github.*` expression contexts are
# just aliases of each other for `${{ }}` templating).
#
# The tag name (github.ref_name / $GITHUB_REF_NAME) is
# attacker-influenced: anyone with tag-push rights controls it, and
# git allows shell metacharacters in tag names. It's read here via
# the automatic $GITHUB_REF_NAME env var rather than `${{ github.ref_name }}`
# so its value reaches bash as data (argv/envp), never spliced into
# the script text — `${{ }}` expressions are substituted into the
# YAML before bash ever sees the script, which is what makes
# interpolating attacker-controlled values that way a script-injection
# vector (arbitrary code execution in a job that holds the registry
# and production deploy credentials).
run: |
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "version=$GITHUB_REF_NAME" >> "$GITHUB_OUTPUT"
env:
REGISTRY: git.maro.run
REGISTRY_REPO: maro/anouma
- name: Log in to registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${{ steps.image.outputs.registry }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
env:
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
- name: Build image
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
- name: Push image
env:
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
VERSION: ${{ steps.image.outputs.version }}
run: |
IMAGE="${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}"
VERSION="${{ steps.image.outputs.version }}"
docker push "$IMAGE:$VERSION"
docker push "$IMAGE:latest"
+11
View File
@@ -13,6 +13,17 @@ export const isAdmin: Access = ({ req: { user } }) =>
export const isAdminFieldLevel: FieldAccess = ({ req: { user } }) =>
user?.collection === "users" && user.role === "admin";
/**
* Admins always see Analytics; any other "users" account additionally needs
* its own `canViewAnalytics` flag (see collections/Users.ts) — this is the
* granular permission the single "admin" role doesn't otherwise provide.
* Used both as a Payload Access/FieldAccess function and called directly
* (with a minimal `{ req: { user } }` shape) from the custom admin nav-link
* and view components, which Payload does not gate via collection access.
*/
export const canViewAnalytics: Access = ({ req: { user } }) =>
user?.collection === "users" && (user.role === "admin" || user.canViewAnalytics === true);
/**
* Admins can read every document (including drafts); everyone else may only
* read documents whose Payload draft/publish status is "published".
@@ -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({
+29 -14
View File
@@ -1,24 +1,40 @@
import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdminFieldLevel } from "@/access";
import { isAdmin, isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user;
// Routes through the shared isAdmin check (role === "admin", not just
// collection === "users") so this stays correct if a non-admin role is ever
// added to Users — see the comment on isAdmin in access/index.ts.
const isSelfOrAdmin: typeof isAdmin = (args) => {
const user = args.req.user;
if (!user) return false;
if (user.collection === "users") return true;
if (user.collection === "users") return isAdmin(args);
if (user.collection === "customers") return { id: { equals: user.id } };
return false;
};
// Runs on every new customer account: issues a random, single-use,
// time-limited email-verification token. Only its hash is ever persisted
// (see the emailVerificationTokenHash field below) — the plaintext exists
// only in req.context for the afterChange hook below to email out, and is
// never written to the database or logged.
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation !== "create") return data;
// Runs on every new customer account, and again whenever an existing
// customer changes their email address: issues a random, single-use,
// time-limited email-verification token and resets emailVerified to false.
// Only the token's hash is ever persisted (see emailVerificationTokenHash
// below) — the plaintext exists only in req.context for the afterChange
// hook below to email out, and is never written to the database or logged.
//
// The email-change case matters because `update` access on this collection
// is isSelfOrAdmin and the auto-added `email` field carries no field-level
// access of its own — without this, a customer could verify a throwaway
// address once and then repoint `email` at an address they don't own while
// keeping emailVerified: true (which gates booking-confirmation emails).
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, originalDoc, operation, req }) => {
const emailChanged =
operation === "update" &&
typeof data.email === "string" &&
typeof originalDoc?.email === "string" &&
data.email !== originalDoc.email;
if (operation !== "create" && !emailChanged) return data;
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
@@ -27,8 +43,7 @@ const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, r
return data;
};
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create") return doc;
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, req }) => {
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
try {
@@ -69,7 +84,7 @@ export const Customers: CollectionConfig = {
create: () => true,
read: isSelfOrAdmin,
update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users",
delete: isAdmin,
},
hooks: {
beforeChange: [issueVerificationToken],
+8 -1
View File
@@ -1,5 +1,5 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access";
import { isAdmin, isAdminFieldLevel, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password";
@@ -162,6 +162,13 @@ export const Events: CollectionConfig = {
name: "meetingPassword",
type: "text",
label: "Meeting-Passwort",
// Events are publicly readable (publishedOrAdmin) via the REST/
// GraphQL API, so without this the plaintext password would be
// returned to any visitor fetching the event — defeating the
// password check in app/(payload)/api/meetings/[slug]/join. Only
// exposed to admins there; the join route reads it via the Local
// API, which bypasses field access by default.
access: { read: isAdminFieldLevel },
admin: {
readOnly: true,
condition: (data) => Boolean(data?.isOnline),
+4 -1
View File
@@ -24,7 +24,10 @@ export const Media: CollectionConfig = {
},
upload: {
staticDir: path.resolve(dirname, "..", "media"),
mimeTypes: ["image/jpeg", "image/png", "image/webp", "image/svg+xml"],
// SVG deliberately excluded: it can carry inline <script>/event-handler
// payloads that execute if the raw file is ever opened directly (not via
// <img>) — not currently needed by any part of the site.
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
imageSizes: [
{ name: "thumbnail", width: 400, position: "centre" },
{ name: "card", width: 800, position: "centre" },
+5 -1
View File
@@ -2,7 +2,11 @@ import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
function normalizePath(value: string): string {
const trimmed = value.trim();
// Lowercased to match the slugs it points at/from (fields/slug.ts always
// lowercases via slugify) — without this, "/Angebote/Doula" and
// "/angebote/doula" count as different values to the `unique` constraint
// on fromPath, letting near-duplicate redirects coexist.
const trimmed = value.trim().toLowerCase();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}
+20 -2
View File
@@ -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 () => {
+43 -39
View File
@@ -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}
+13 -1
View File
@@ -32,7 +32,19 @@ export function checkRateLimit(key: string, opts: { max: number; windowMs: numbe
}
export function getClientIp(request: Request): string {
// Deployment is always behind exactly one trusted reverse proxy (see
// DEPLOYMENT.md — Nginx Proxy Manager; the app port isn't published
// directly to the host). nginx's default `$proxy_add_x_forwarded_for`
// *appends* the real client IP to any X-Forwarded-For it already
// received rather than replacing it, so a directly-connecting client
// could prepend arbitrary spoofed entries of their own. Only the
// right-most (last) entry — the one the trusted proxy itself appended —
// is trustworthy; taking the first entry would let a client bypass this
// rate limit entirely by sending a fake X-Forwarded-For header.
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0]!.trim();
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]!.trim();
}
return "unknown";
}
+70 -9
View File
@@ -3,6 +3,8 @@ import { WEEKDAYS } from "@/collections/Availability";
export type TimeSlot = { start: Date; end: Date };
const BERLIN_TZ = "Europe/Berlin";
const WEEKDAY_BY_INDEX = [
"sunday",
"monday",
@@ -13,18 +15,73 @@ const WEEKDAY_BY_INDEX = [
"saturday",
] as const;
type CalendarDate = { year: number; month: number; day: number };
// Availability/AvailabilityOverrides times are entered by the admin in their
// browser (Europe/Berlin) and stored as UTC instants. All wall-clock reads
// and writes here go through Intl's Berlin timezone data (not local Date
// accessors, and not manual UTC+1/+2 arithmetic), so the server process's
// own timezone (this app runs in UTC in production) never leaks into the
// computed availability — see lib/calendar/timezone.ts for the same pattern.
function berlinParts(date: Date): CalendarDate & { hour: number; minute: number } {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
return { year: get("year"), month: get("month"), day: get("day"), hour: get("hour"), minute: get("minute") };
}
function berlinOffsetMinutes(date: Date): number {
const tzName = new Intl.DateTimeFormat("en-US", { timeZone: BERLIN_TZ, timeZoneName: "shortOffset" })
.formatToParts(date)
.find((p) => p.type === "timeZoneName")?.value ?? "GMT+1";
const match = tzName.match(/GMT([+-]\d+)(?::(\d+))?/);
const hours = match ? Number(match[1]) : 1;
const minutes = match?.[2] ? Number(match[2]) : 0;
return hours * 60 + (hours < 0 ? -minutes : minutes);
}
/** Converts a Europe/Berlin wall-clock date+time into the UTC instant it represents. */
function berlinWallTimeToUTC(date: CalendarDate, hour: number, minute: number): Date {
const guess = Date.UTC(date.year, date.month - 1, date.day, hour, minute);
return new Date(guess - berlinOffsetMinutes(new Date(guess)) * 60_000);
}
function dateKey(date: Date): string {
return date.toISOString().slice(0, 10);
const { year, month, day } = berlinParts(date);
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function calendarDate(date: Date): CalendarDate {
const { year, month, day } = berlinParts(date);
return { year, month, day };
}
function addCalendarDays({ year, month, day }: CalendarDate, delta: number): CalendarDate {
// Pure calendar-rollover arithmetic (month/year boundaries) — no timezone
// conversion happens here, so using the UTC constructor is safe and simple.
const utc = new Date(Date.UTC(year, month - 1, day + delta));
return { year: utc.getUTCFullYear(), month: utc.getUTCMonth() + 1, day: utc.getUTCDate() };
}
function weekdayOf({ year, month, day }: CalendarDate): (typeof WEEKDAY_BY_INDEX)[number] {
return WEEKDAY_BY_INDEX[new Date(Date.UTC(year, month - 1, day)).getUTCDay()];
}
function timeOfDayMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
const { hour, minute } = berlinParts(date);
return hour * 60 + minute;
}
function atMinutesOfDay(day: Date, minutes: number): Date {
const d = new Date(day);
d.setHours(0, Math.round(minutes), 0, 0);
return d;
function atMinutesOfDay(day: CalendarDate, minutes: number): Date {
const rounded = Math.round(minutes);
return berlinWallTimeToUTC(day, Math.floor(rounded / 60), rounded % 60);
}
function overlaps(aStart: Date, aEnd: Date, bStart: Date, bEnd: Date): boolean {
@@ -67,8 +124,12 @@ export async function getAvailableSlots(
const overridesByDate = new Map(overrides.map((o) => [dateKey(new Date(o.date)), o]));
const now = new Date();
for (let day = new Date(from); day <= to; day = new Date(day.getTime() + 24 * 60 * 60 * 1000)) {
const key = dateKey(day);
for (
let day = calendarDate(from);
atMinutesOfDay(day, 0) <= to;
day = addCalendarDays(day, 1)
) {
const key = `${day.year}-${String(day.month).padStart(2, "0")}-${String(day.day).padStart(2, "0")}`;
const override = overridesByDate.get(key);
let windows: { startMinutes: number; endMinutes: number }[] = [];
@@ -83,7 +144,7 @@ export async function getAvailableSlots(
},
];
} else {
const weekdayName = WEEKDAY_BY_INDEX[day.getDay()];
const weekdayName = weekdayOf(day);
windows = weekly
.filter((w) => w.weekday === weekdayName)
.map((w) => ({
+5
View File
@@ -15,6 +15,11 @@ function escapeICSText(value: string): string {
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
// \r must be stripped (not just \n escaped): many ICS parsers split
// lines on a bare CR too, so leaving it unescaped lets untrusted text
// (e.g. a customer's booking message ending up in DESCRIPTION) break
// out of the property value and inject extra iCalendar lines/properties.
.replace(/\r\n?/g, "\\n")
.replace(/\n/g, "\\n");
}
+3 -1
View File
@@ -7,6 +7,7 @@ export type ParticipantSummary = {
};
export type ClientToServerMessage =
| { type: "auth"; token: string }
| { type: "offer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; to: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; to: string; payload: RTCIceCandidateInit }
@@ -19,4 +20,5 @@ export type ServerToClientMessage =
| { type: "offer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "answer"; from: string; payload: RTCSessionDescriptionInit }
| { type: "ice-candidate"; from: string; payload: RTCIceCandidateInit }
| { type: "kicked" };
| { type: "kicked" }
| { type: "auth-error"; reason: string };
+19 -3
View File
@@ -4,6 +4,7 @@ import type { MeetingRole } from "./token";
export type Participant = {
ws: WebSocket;
participantId: string;
clientId?: string;
name: string;
role: MeetingRole;
};
@@ -11,6 +12,11 @@ export type Participant = {
type Room = {
participants: Map<string, Participant>;
kickedParticipantIds: Set<string>;
// Keyed by the client-declared, localStorage-persisted clientId (see
// token.ts) rather than the per-connection participantId, so a kick
// survives the participant simply reconnecting (which always mints a
// fresh participantId) — see isKicked below.
kickedClientIds: Set<string>;
};
// In-memory only — this server process is the single source of truth for
@@ -22,19 +28,27 @@ const rooms = new Map<string, Room>();
function getOrCreateRoom(eventSlug: string): Room {
let room = rooms.get(eventSlug);
if (!room) {
room = { participants: new Map(), kickedParticipantIds: new Set() };
room = { participants: new Map(), kickedParticipantIds: new Set(), kickedClientIds: new Set() };
rooms.set(eventSlug, room);
}
return room;
}
export function isKicked(eventSlug: string, participantId: string): boolean {
return rooms.get(eventSlug)?.kickedParticipantIds.has(participantId) ?? false;
export function isKicked(eventSlug: string, participantId: string, clientId?: string): boolean {
const room = rooms.get(eventSlug);
if (!room) return false;
if (room.kickedParticipantIds.has(participantId)) return true;
return clientId ? room.kickedClientIds.has(clientId) : false;
}
export function joinRoom(eventSlug: string, participant: Participant): Participant[] {
const room = getOrCreateRoom(eventSlug);
const existing = [...room.participants.values()];
// Two connections claiming the same participantId (e.g. two tabs replaying
// the same stored session) would otherwise silently overwrite the map
// entry here and orphan the first socket — close it first so it doesn't
// leak as an unreachable broadcast target.
room.participants.get(participant.participantId)?.ws.close(4002, "replaced-by-new-connection");
room.participants.set(participant.participantId, participant);
return existing;
}
@@ -50,6 +64,8 @@ export function kickFromRoom(eventSlug: string, participantId: string) {
const room = rooms.get(eventSlug);
if (!room) return;
room.kickedParticipantIds.add(participantId);
const target = room.participants.get(participantId);
if (target?.clientId) room.kickedClientIds.add(target.clientId);
room.participants.delete(participantId);
}
+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 });
});
});
}
+16
View File
@@ -5,6 +5,16 @@ export type MeetingRole = "host" | "participant";
export type MeetingTokenPayload = {
eventSlug: string;
participantId: string;
// Self-declared, persisted client-side (localStorage, not sessionStorage)
// identity for participants — participantId is fresh per /join call, so
// without this a kick could always be undone by simply rejoining. Only
// used to make a kick stick across a rejoin (see rooms.ts kickedClientIds);
// never used to key live room membership, so a client choosing an
// arbitrary/colliding value can only affect their own future kick status,
// never another participant's connection. Still bypassable by clearing
// localStorage or joining from a different browser — there's no real
// identity to anchor to for an unauthenticated participant.
clientId?: string;
name: string;
role: MeetingRole;
exp: number; // unix ms
@@ -29,12 +39,18 @@ export function createMeetingToken(args: {
eventSlug: string;
name: string;
role: MeetingRole;
clientId?: string;
ttlMs?: number;
}): { token: string; participantId: string } {
const participantId = randomUUID();
const clientId =
typeof args.clientId === "string" && args.clientId.length > 0 && args.clientId.length <= 100
? args.clientId
: undefined;
const payload: MeetingTokenPayload = {
eventSlug: args.eventSlug,
participantId,
clientId,
name: args.name,
role: args.role,
exp: Date.now() + (args.ttlMs ?? 6 * 60 * 60 * 1000), // 6h default
+47
View File
@@ -6,6 +6,47 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(__filename);
// Applied to every route, including /admin and /api — none of these
// constrain what the page can load/render, so they can't break the Payload
// admin bundle. Camera/microphone/display-capture are allowed for the
// site's own origin because the WebRTC call pages (app/(call)/**) need them.
const baseSecurityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=()" },
// Browsers only ever honor this over an actual HTTPS connection, so it's
// harmless to send in local http:// dev too.
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" },
];
// Deliberately NOT applied to /admin or /api: Payload's admin bundle isn't
// audited here for CSP compatibility, and API routes serve data, not HTML,
// so a content policy doesn't apply to them anyway. connect-src stays broad
// (rather than 'self') because the WebRTC call pages connect to whatever
// STUN/TURN server is configured via env vars at runtime (see
// lib/meeting/iceServers.ts) — that host isn't known here. style-src needs
// 'unsafe-inline' because framer-motion drives its animations through
// inline style attributes. script-src allows 'unsafe-eval' only in dev,
// matching Next's own documented CSP guidance — React uses eval there for
// server-error-stack reconstruction; neither React nor Next use it in a
// production build.
const isDev = process.env.NODE_ENV === "development";
const frontendCSP = [
"default-src 'self'",
`script-src 'self'${isDev ? " 'unsafe-eval'" : ""}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
"connect-src 'self' https: wss: stun: turn: turns:",
"media-src 'self' blob:",
"frame-ancestors 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
"upgrade-insecure-requests",
].join("; ");
const nextConfig: NextConfig = {
// NOT "standalone": this project uses a custom server (server.ts) for the
// WebRTC signaling WebSocket, and standalone output can't be combined with
@@ -25,6 +66,12 @@ const nextConfig: NextConfig = {
turbopack: {
root: path.resolve(dirname),
},
async headers() {
return [
{ source: "/:path*", headers: baseSecurityHeaders },
{ source: "/((?!admin|api).*)", headers: [{ key: "Content-Security-Policy", value: frontendCSP }] },
];
},
};
export default withPayload(nextConfig, { devBundleServerPackages: false });