feat(scheduling): pluggable captcha (Turnstile) on public booking

This commit is contained in:
Ronni Baslund
2026-06-07 09:02:35 +02:00
parent e1a77b085f
commit e33b7f18a3
7 changed files with 197 additions and 2 deletions
@@ -0,0 +1,85 @@
import { ForbiddenException, Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
// Pluggable abuse guard for the public booking surface (spec §6.1). The default
// is a no-op so unconfigured environments behave exactly as before. When a
// captcha provider is configured (currently Cloudflare Turnstile via
// TURNSTILE_SECRET) the guard verifies the visitor-supplied token and rejects
// requests it can't validate. We deliberately do NOT solve captchas — we only
// forward the token the browser widget produced to the provider's siteverify.
export const ABUSE_GUARD = Symbol('ABUSE_GUARD')
export interface AbuseGuard {
// Whether a captcha token is expected from the client. The booking app uses
// this to decide whether to render a widget (mirrored client-side via the
// public site-key env), but the server is the source of truth on enforcement.
readonly enabled: boolean
// Verify a request. `token` is the captcha token from the client, if any.
// `remoteIp` is the visitor IP for providers that score on it. Throws a 4xx
// when the token is missing/invalid; resolves silently when acceptable.
verify(token: string | undefined, remoteIp?: string): Promise<void>
}
// No-op guard — accepts everything. Used when no captcha provider is configured.
@Injectable()
export class NoopAbuseGuard implements AbuseGuard {
readonly enabled = false
async verify(): Promise<void> {
// Intentionally does nothing.
}
}
interface TurnstileVerifyResponse {
success: boolean
'error-codes'?: string[]
}
// Cloudflare Turnstile verifier. Posts the client token to siteverify with the
// secret; any non-success (missing token, network error, provider rejection)
// becomes a 403 so we fail closed once captcha is switched on.
@Injectable()
export class TurnstileAbuseGuard implements AbuseGuard {
readonly enabled = true
private readonly logger = new Logger(TurnstileAbuseGuard.name)
private static readonly SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
constructor(private readonly secret: string) {}
async verify(token: string | undefined, remoteIp?: string): Promise<void> {
if (!token) throw new ForbiddenException('Captcha verification required.')
const form = new URLSearchParams()
form.set('secret', this.secret)
form.set('response', token)
if (remoteIp) form.set('remoteip', remoteIp)
let body: TurnstileVerifyResponse
try {
const res = await fetch(TurnstileAbuseGuard.SITEVERIFY, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: form,
})
body = (await res.json()) as TurnstileVerifyResponse
} catch (err) {
// Fail closed: if we can't reach the verifier we don't let the request through.
this.logger.error(`Turnstile siteverify request failed: ${String(err)}`)
throw new ForbiddenException('Captcha verification unavailable. Please try again.')
}
if (!body.success) {
this.logger.warn(`Turnstile rejected token: ${(body['error-codes'] ?? []).join(', ') || 'unknown'}`)
throw new ForbiddenException('Captcha verification failed.')
}
}
}
// Factory used by the module: pick Turnstile when its secret is set, else no-op.
export function abuseGuardFactory(config: ConfigService): AbuseGuard {
const secret = config.get<string>('TURNSTILE_SECRET')?.trim()
if (secret) return new TurnstileAbuseGuard(secret)
return new NoopAbuseGuard()
}
@@ -20,6 +20,13 @@ export class SlotsQueryDto {
export class CreateHoldDto {
@IsISO8601()
startUtc!: string
// Optional captcha token (e.g. Cloudflare Turnstile). Enforced server-side
// only when a captcha provider is configured; ignored otherwise.
@IsOptional()
@IsString()
@MaxLength(4096)
captchaToken?: string
}
export class CreateBookingDto {
@@ -48,6 +55,13 @@ export class CreateBookingDto {
@IsString()
@MaxLength(64)
holdId?: string
// Optional captcha token (e.g. Cloudflare Turnstile). Enforced server-side
// only when a captcha provider is configured; ignored otherwise.
@IsOptional()
@IsString()
@MaxLength(4096)
captchaToken?: string
}
export class CancelBookingDto {
@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
import { Body, Controller, Get, Inject, Ip, Param, Post, Query, UseGuards } from '@nestjs/common'
import { Throttle, ThrottlerGuard } from '@nestjs/throttler'
import { TenantsService } from '../../tenants/tenants.service.js'
import { ABUSE_GUARD, type AbuseGuard } from '../abuse/abuse-guard.js'
import type { BookingContext } from '../bookings/bookings.service.js'
import { BookingsService } from '../bookings/bookings.service.js'
import { EventTypesService } from '../event-types/event-types.service.js'
@@ -29,6 +30,7 @@ export class PublicSchedulingController {
private readonly tenants: TenantsService,
private readonly hosts: HostsService,
private readonly eventTypes: EventTypesService,
@Inject(ABUSE_GUARD) private readonly abuseGuard: AbuseGuard,
) {}
@Get(':tenantSlug/:hostSlug/:eventTypeSlug')
@@ -65,7 +67,9 @@ export class PublicSchedulingController {
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateHoldDto,
@Ip() ip: string,
) {
await this.abuseGuard.verify(dto.captchaToken, ip)
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const { holdId, expiresAt } = await this.bookings.hold(ctx, new Date(dto.startUtc))
return { holdId, expiresAt: expiresAt.toISOString() }
@@ -78,7 +82,9 @@ export class PublicSchedulingController {
@Param('hostSlug') hostSlug: string,
@Param('eventTypeSlug') eventTypeSlug: string,
@Body() dto: CreateBookingDto,
@Ip() ip: string,
) {
await this.abuseGuard.verify(dto.captchaToken, ip)
const ctx = await this.publicSvc.resolveContext(tenantSlug, hostSlug, eventTypeSlug)
const booking = await this.bookings.confirm(ctx, {
startUtc: new Date(dto.startUtc),
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { MongooseModule } from '@nestjs/mongoose'
import { ScheduleModule } from '@nestjs/schedule'
import { ThrottlerModule } from '@nestjs/throttler'
@@ -12,6 +13,7 @@ import { SlotLock, SlotLockSchema } from '../schemas/slot-lock.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { TenantsModule } from '../tenants/tenants.module.js'
import { ABUSE_GUARD, abuseGuardFactory } from './abuse/abuse-guard.js'
import { AvailabilityService } from './availability/availability.service.js'
import { BookingsService } from './bookings/bookings.service.js'
import { CalendarRetryWorker } from './bookings/calendar-retry.worker.js'
@@ -61,6 +63,9 @@ import { StalwartCalendarModule } from './stalwart-calendar/stalwart-calendar.mo
JmapMailer,
BookingReminderWorker,
CalendarRetryWorker,
// Pluggable captcha guard for the public booking surface (Turnstile when
// TURNSTILE_SECRET is set, otherwise a no-op).
{ provide: ABUSE_GUARD, useFactory: abuseGuardFactory, inject: [ConfigService] },
],
})
export class SchedulingModule {}