@centinel/nextjs
Version:
Package designed to add Centinel Analytica functionality to Next.js applications
313 lines • 13.3 kB
JavaScript
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