import * as plugins from '../plugins.js'; import type { IChallengeAssessRequest, IChallengeDecision, IChallengeRenderRequest, IChallengeRenderResponse, IChallengeType, IChallengeTypeManifest, IChallengeVerifyRequest, IChallengeVerifyResult, TChallengeSettings, } from '../interfaces.js'; interface IWaitChallengeState { challengeId: string; issuedAt: number; notBefore: number; expiresAt: number; waitSeconds: number; } const NODE_MAX_TIMER_SECONDS = Math.floor(2_147_000_000 / 1000); export interface IWaitChallengeTypeOptions { defaultWaitSeconds?: number; defaultTtlSeconds?: number; maxWaitSeconds?: number; maxTtlSeconds?: number; maxStates?: number; } export class WaitChallengeType implements IChallengeType { public readonly challengeType = 'wait'; private readonly defaultWaitSeconds: number; private readonly defaultTtlSeconds: number; private readonly maxWaitSeconds: number; private readonly maxTtlSeconds: number; private readonly maxStates: number; private states = new Map(); private cleanupTimer?: ReturnType; private nextCleanupAt?: number; constructor(optionsArg: IWaitChallengeTypeOptions = {}) { this.maxWaitSeconds = this.normalizeNumberOption(optionsArg.maxWaitSeconds, 0, NODE_MAX_TIMER_SECONDS, 3600); this.maxTtlSeconds = this.normalizeNumberOption(optionsArg.maxTtlSeconds, 1, NODE_MAX_TIMER_SECONDS, 86_400); this.defaultWaitSeconds = this.normalizeNumberOption(optionsArg.defaultWaitSeconds, 0, this.maxWaitSeconds, 5); this.defaultTtlSeconds = this.normalizeNumberOption(optionsArg.defaultTtlSeconds, 1, this.maxTtlSeconds, 300); this.maxStates = Math.trunc(this.normalizeNumberOption(optionsArg.maxStates, 1, 1_000_000, 10_000)); } public getManifest(): IChallengeTypeManifest { return { challengeType: this.challengeType, label: 'Wait Page', description: 'Requires the client to wait for a configured time before clearance can be issued.', settingsSchema: { waitSeconds: this.defaultWaitSeconds, ttlSeconds: this.defaultTtlSeconds, maxWaitSeconds: this.maxWaitSeconds, maxTtlSeconds: this.maxTtlSeconds, }, }; } public async assess(requestArg: IChallengeAssessRequest): Promise { if (requestArg.clearance?.valid) { return { action: 'allow', reason: 'valid clearance' }; } const waitSeconds = this.boundNumber( this.getNumberSetting(requestArg.challenge.settings, 'waitSeconds', this.defaultWaitSeconds), 0, this.maxWaitSeconds, ); const ttlSeconds = this.boundNumber( this.getNumberSetting(requestArg.challenge.settings, 'ttlSeconds', this.defaultTtlSeconds), 1, this.maxTtlSeconds, ); const now = Date.now(); const state: IWaitChallengeState = { challengeId: plugins.crypto.randomUUID(), issuedAt: now, notBefore: now + waitSeconds * 1000, expiresAt: now + ttlSeconds * 1000, waitSeconds, }; this.states.set(state.challengeId, state); this.enforceMaxStates(); this.scheduleCleanup(state.expiresAt); return { action: 'challenge', challengeId: state.challengeId, statusCode: 403, format: 'html', reason: 'wait required', data: this.stateToSettings(state), }; } public async render(requestArg: IChallengeRenderRequest): Promise { const state = this.states.get(requestArg.challengeId) || this.settingsToState(requestArg.challengeId, requestArg.data); const waitSeconds = state ? Math.max(0, Math.ceil((state.notBefore - Date.now()) / 1000)) : 0; const body = this.renderHtml(waitSeconds, requestArg.context.url, requestArg.challengeId); return { statusCode: 403, format: 'html', headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', }, body, }; } public async verify(requestArg: IChallengeVerifyRequest): Promise { const now = Date.now(); const state = this.states.get(requestArg.challengeId); if (!state) { return { success: false, reason: 'challenge not found or expired' }; } if (now > state.expiresAt) { this.states.delete(requestArg.challengeId); return { success: false, reason: 'challenge expired' }; } if (now < state.notBefore) { return { success: false, reason: 'wait time has not elapsed', data: this.stateToSettings(state), }; } this.states.delete(requestArg.challengeId); if (this.states.size === 0) { this.clearCleanupTimer(); } return { success: true, reason: 'wait completed', clearanceTtlSeconds: requestArg.challenge.clearance?.ttlSeconds, data: this.stateToSettings(state), }; } private getNumberSetting( settingsArg: TChallengeSettings | undefined, keyArg: string, fallbackArg: number, ): number { const value = settingsArg?.[keyArg]; return typeof value === 'number' && Number.isFinite(value) ? value : fallbackArg; } private boundNumber(valueArg: number, minArg: number, maxArg: number): number { if (!Number.isFinite(valueArg)) { return minArg; } return Math.min(maxArg, Math.max(minArg, valueArg)); } private normalizeNumberOption( valueArg: number | undefined, minArg: number, maxArg: number, fallbackArg: number, ): number { const value = typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : fallbackArg; return this.boundNumber(value, minArg, maxArg); } private cleanupExpired(nowArg: number): void { for (const [challengeId, state] of this.states.entries()) { if (state.expiresAt <= nowArg) { this.states.delete(challengeId); } } this.clearCleanupTimer(); this.scheduleNextCleanupFromStates(); } private enforceMaxStates(): void { while (this.states.size > this.maxStates) { const oldestChallengeId = this.states.keys().next().value; if (!oldestChallengeId) { return; } this.states.delete(oldestChallengeId); } } private scheduleCleanup(expiresAtArg: number): void { if (this.cleanupTimer && this.nextCleanupAt && this.nextCleanupAt <= expiresAtArg) { return; } this.clearCleanupTimer(); this.nextCleanupAt = expiresAtArg; const delayMs = Math.max(1, expiresAtArg - Date.now()); this.cleanupTimer = setTimeout(() => this.cleanupExpired(Date.now()), delayMs); this.cleanupTimer.unref?.(); } private scheduleNextCleanupFromStates(): void { let nextExpiresAt: number | undefined; for (const state of this.states.values()) { if (nextExpiresAt === undefined || state.expiresAt < nextExpiresAt) { nextExpiresAt = state.expiresAt; } } if (nextExpiresAt !== undefined) { this.scheduleCleanup(nextExpiresAt); } } private clearCleanupTimer(): void { if (this.cleanupTimer) { clearTimeout(this.cleanupTimer); } this.cleanupTimer = undefined; this.nextCleanupAt = undefined; } private stateToSettings(stateArg: IWaitChallengeState): TChallengeSettings { return { issuedAt: stateArg.issuedAt, notBefore: stateArg.notBefore, expiresAt: stateArg.expiresAt, waitSeconds: stateArg.waitSeconds, }; } private settingsToState( challengeIdArg: string, settingsArg: TChallengeSettings | undefined, ): IWaitChallengeState | undefined { if (!settingsArg) { return undefined; } const issuedAt = settingsArg.issuedAt; const notBefore = settingsArg.notBefore; const expiresAt = settingsArg.expiresAt; const waitSeconds = settingsArg.waitSeconds; if ( typeof issuedAt !== 'number' || typeof notBefore !== 'number' || typeof expiresAt !== 'number' || typeof waitSeconds !== 'number' ) { return undefined; } return { challengeId: challengeIdArg, issuedAt, notBefore, expiresAt, waitSeconds, }; } private renderHtml(waitSecondsArg: number, originalUrlArg: string, challengeIdArg: string): string { const safeChallengeId = this.escapeHtml(challengeIdArg); return ` Please wait

Please wait

This route is protected by a wait challenge.

Continue after ${waitSecondsArg} second(s).

Challenge ${safeChallengeId}

`; } private escapeHtml(valueArg: string): string { return valueArg .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } private jsonForHtmlScript(valueArg: string): string { return JSON.stringify(valueArg) .replace(//g, '\\u003e') .replace(/&/g, '\\u0026'); } }