UNPKG

@centinel/nextjs

Version:

Package designed to add Centinel Analytica functionality to Next.js applications

313 lines 13.3 kB
import { NextResponse } from 'next/server'; import debug from 'debug'; const log = debug('centinel:middleware'); const logError = debug('centinel:error'); const logValidation = debug('centinel:validation'); const logPerformance = debug('centinel:performance'); const DEFAULT_TIMEOUT_MS = 500; const BACKOFF_BASE_DELAY_MS = 1000; const BACKOFF_MAX_DELAY_MS = 300000; const BACKOFF_MULTIPLIER = 2; export class CentinelMiddleware { config; defaultTimeout = DEFAULT_TIMEOUT_MS; backoffState; constructor(config) { this.backoffState = { consecutiveFailures: 0, lastFailureTime: null, isInBackoff: false, }; this.config = { ...config, timeout: config.timeout ?? this.defaultTimeout, }; log('Initializing CentinelMiddleware with config: %O', { siteKey: config.siteKey ? '***' : 'missing', secretKey: config.secretKey ? '***' : 'missing', timeout: this.config.timeout, debugMode: config.debugMode, }); } async validate(event) { const startTime = Date.now(); const request = event.request; log('Validating request: %s %s', request.method, request.url); try { if (this.shouldSkipValidation()) { logPerformance('Skipped validation due to backoff in %dms', Date.now() - startTime); return { response: NextResponse.next(), decision: 'allow', should_intercept: false, }; } const clientInfo = this.getClientInfo(request); const payload = this.createPayload(request, clientInfo); logValidation('Validation payload: %O', { url: payload.url, ip: payload.ip, method: payload.method, hasHeaders: Object.keys(payload.headers).length > 0, hasCookie: Boolean(payload.cookie), }); const validationResult = await this.callValidator(payload); logValidation('Validation result: %O', validationResult); logPerformance('Validation completed in %dms', Date.now() - startTime); return this.handleValidationResult(validationResult, request); } catch (error) { logError('Validation failed with error: %O', error); logPerformance('Failed validation completed in %dms', Date.now() - startTime); return { response: NextResponse.next(), decision: 'allow', should_intercept: false, }; } } handleValidationResult(validationResult, request) { switch (validationResult.decision) { case 'block': { log('Blocking request to %s', request.url); const blockResponse = NextResponse.redirect(new URL('/block', request.url)); if (validationResult.headers) { for (const [name, value] of Object.entries(validationResult.headers)) { blockResponse.headers.set(name, value); } } if (validationResult.cookies && validationResult.cookies.length > 0) { this.applyCookies(blockResponse, validationResult.cookies, request); } return { response: blockResponse, decision: 'block', should_intercept: true, }; } case 'redirect': { if (!validationResult.response_html) { log('No response_html provided, falling back to default redirect'); const defaultRedirectResponse = NextResponse.redirect(new URL('/interstitial', request.url)); if (validationResult.cookies && validationResult.cookies.length > 0) { this.applyCookies(defaultRedirectResponse, validationResult.cookies, request); } return { response: defaultRedirectResponse, decision: 'redirect', should_intercept: true, }; } log('Returning HTML interstitial page'); const decodedHtml = Buffer.from(validationResult.response_html, 'base64').toString('utf-8'); const responseHeaders = {}; if (validationResult.headers) { Object.assign(responseHeaders, validationResult.headers); } const htmlResponse = new NextResponse(decodedHtml, { status: validationResult.status_code || 403, headers: responseHeaders, }); if (validationResult.cookies && validationResult.cookies.length > 0) { this.applyCookies(htmlResponse, validationResult.cookies, request); } return { response: htmlResponse, decision: 'redirect', should_intercept: true, }; } case 'allow': case 'not_matched': default: { const realHost = this.getRealHost(request); const requestUrl = `${request.nextUrl.protocol}//${realHost}${request.nextUrl.pathname}${request.nextUrl.search}`; log('Allowing request to %s', requestUrl); const allowResponse = NextResponse.next(); if (validationResult.headers) { for (const [name, value] of Object.entries(validationResult.headers)) { allowResponse.headers.set(name, value); } } if (validationResult.cookies && validationResult.cookies.length > 0) { this.applyCookies(allowResponse, validationResult.cookies, request); } return { response: allowResponse, decision: validationResult.decision, should_intercept: false, }; } } } getRealHost(request) { let realHost = request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? request.nextUrl.hostname; if (realHost === 'localhost' && !realHost.includes(':')) { realHost = `localhost:${process.env.PORT ?? '3000'}`; } return realHost; } getClientInfo(request) { const forwardedFor = request.headers.get('x-forwarded-for'); const realIp = request.headers.get('x-real-ip'); const cfConnectingIp = request.headers.get('cf-connecting-ip'); const ip = cfConnectingIp ?? forwardedFor?.split(',')[0]?.trim() ?? realIp ?? 'unknown'; const referer = request.headers.get('referer') ?? request.headers.get('referrer') ?? ''; log('Client info extracted: ip=%s, referer=%s', ip, referer); return { ip, referer }; } createPayload(request, clientInfo) { const headers = {}; request.headers.forEach((value, key) => { headers[key] = value; }); const realHost = this.getRealHost(request); const requestUrl = `${request.nextUrl.protocol}//${realHost}${request.nextUrl.pathname}${request.nextUrl.search}`; const payload = { cookie: request.cookies.get('_centinel')?.value, url: requestUrl, ip: clientInfo.ip, referrer: clientInfo.referer, method: request.method, headers, }; return payload; } async callValidator(payload) { const controller = new AbortController(); const timeoutId = setTimeout(() => { logError('Request timeout after %dms', this.config.timeout); controller.abort(); }, this.config.timeout); try { logValidation('Calling validator service'); const response = await fetch('https://validator.centinelanalytica.com/validate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': this.config.secretKey, 'User-Agent': 'centinel-nextjs/1.2.2', }, body: JSON.stringify(payload), signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { const errorMessage = `Validator service responded with ${response.status}: ${response.statusText}`; logError(errorMessage); this.recordFailure(); throw new Error(errorMessage); } const result = await response.json(); logValidation('Raw API response: %O', result); this.recordSuccess(); return this.parseValidationResponse(result); } catch (error) { clearTimeout(timeoutId); if (error instanceof Error) { if (error.name === 'AbortError') { logError('Request was aborted due to timeout'); this.recordFailure(); } else { logError('Validator request failed: %s', error.message); if (!error.message.includes('Validator service responded with')) { this.recordFailure(); } } } else { logError('Unknown error occurred: %O', error); this.recordFailure(); } return { success: false, decision: 'allow', }; } } parseValidationResponse(apiResponse) { const decision = this.parseDecision(apiResponse.decision); return { success: apiResponse.success !== false, decision, status_code: apiResponse.status_code, response_html: apiResponse.response_html, cookies: apiResponse.cookies ?? [], headers: apiResponse.headers, }; } parseDecision(decision) { switch (decision) { case 'allow': case 'block': case 'redirect': case 'not_matched': return decision; default: logError('Unknown decision from API: %s, defaulting to allow', decision); return 'allow'; } } applyCookies(response, cookies, request) { const realHost = this.getHost(request); log('Applying %d cookies', cookies.length); cookies.forEach(cookie => { const cookieOptions = { path: cookie.path ?? '/', domain: cookie.domain ?? realHost, }; log('Setting cookie %s with options: %O', cookie.name, cookieOptions); response.cookies.set(cookie.name, cookie.value, cookieOptions); }); } getHost(request) { const host = request.headers.get('host'); if (host === null || host === undefined || host.length === 0) { return undefined; } return (host?.includes('localhost') ?? false) ? undefined : host; } shouldSkipValidation() { if (!this.backoffState.isInBackoff || this.backoffState.lastFailureTime === null) { return false; } const backoffDuration = Math.min(BACKOFF_BASE_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, this.backoffState.consecutiveFailures - 1), BACKOFF_MAX_DELAY_MS); const timeSinceLastFailure = Date.now() - this.backoffState.lastFailureTime; if (timeSinceLastFailure >= backoffDuration) { log('Backoff period elapsed (%dms), attempting validation again', backoffDuration); this.backoffState.isInBackoff = false; return false; } const remainingBackoff = backoffDuration - timeSinceLastFailure; log('Skipping validation due to backoff (failures: %d, remaining: %dms)', this.backoffState.consecutiveFailures, remainingBackoff); return true; } recordFailure() { this.backoffState.consecutiveFailures++; this.backoffState.lastFailureTime = Date.now(); this.backoffState.isInBackoff = true; const backoffDuration = Math.min(BACKOFF_BASE_DELAY_MS * Math.pow(BACKOFF_MULTIPLIER, this.backoffState.consecutiveFailures - 1), BACKOFF_MAX_DELAY_MS); logError('Validator failure recorded (consecutive: %d, backoff: %dms)', this.backoffState.consecutiveFailures, backoffDuration); } recordSuccess() { if (this.backoffState.consecutiveFailures > 0) { log('Validator success - resetting backoff state (was %d failures)', this.backoffState.consecutiveFailures); } this.backoffState = { consecutiveFailures: 0, lastFailureTime: null, isInBackoff: false, }; } } //# sourceMappingURL=CentinelMiddleware.js.map