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:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+13
-1
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user