961 lines
28 KiB
JavaScript
961 lines
28 KiB
JavaScript
import { registerPlugin, loadConfig, rootDir } from './index.js';
|
|
import crypto from 'crypto';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { promises as fsPromises } from 'fs';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import { Level } from 'level';
|
|
import cookie from 'cookie';
|
|
import { parseDuration } from './utils/time.js';
|
|
import { getRealIP } from './utils/network.js';
|
|
import ttl from 'level-ttl';
|
|
import { Readable } from 'stream';
|
|
import {
|
|
challengeStore,
|
|
generateRequestID as proofGenerateRequestID,
|
|
getChallengeParams,
|
|
deleteChallenge,
|
|
verifyPoW,
|
|
verifyPoS,
|
|
} from './utils/proof.js';
|
|
// Import recordEvent dynamically to avoid circular dependency issues
|
|
let recordEvent;
|
|
let statsLoadPromise = import('./plugins/stats.js')
|
|
.then((stats) => {
|
|
recordEvent = stats.recordEvent;
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to import stats module:', err);
|
|
recordEvent = null;
|
|
});
|
|
|
|
function sanitizePath(inputPath) {
|
|
let pathOnly = inputPath.replace(/[\x00-\x1F\x7F]/g, '');
|
|
|
|
pathOnly = pathOnly.replace(/[<>;"'`|]/g, '');
|
|
|
|
const parts = pathOnly.split('/').filter((seg) => seg && seg !== '.' && seg !== '..');
|
|
|
|
return '/' + parts.map((seg) => encodeURIComponent(seg)).join('/');
|
|
}
|
|
|
|
const checkpointConfig = {};
|
|
let hmacSecret = null;
|
|
const usedNonces = new Map();
|
|
const ipRateLimit = new Map();
|
|
|
|
const tokenCache = new Map();
|
|
|
|
let db;
|
|
|
|
const tokenExpirations = new Map();
|
|
|
|
let interstitialTemplate = null;
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
function simpleTemplate(str) {
|
|
return function (data) {
|
|
return str.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => {
|
|
let value = data;
|
|
|
|
for (const part of key.trim().split('.')) {
|
|
value = value?.[part];
|
|
if (value == null) break;
|
|
}
|
|
return value != null ? String(value) : '';
|
|
});
|
|
};
|
|
}
|
|
|
|
async function initConfig() {
|
|
await loadConfig('checkpoint', checkpointConfig);
|
|
|
|
// Handle new nested configuration structure
|
|
// Map nested structure to flat structure for internal use
|
|
checkpointConfig.Enabled = checkpointConfig.Core.Enabled;
|
|
checkpointConfig.CookieName = checkpointConfig.Core.CookieName;
|
|
checkpointConfig.CookieDomain = checkpointConfig.Core.CookieDomain;
|
|
checkpointConfig.SanitizeURLs = checkpointConfig.Core.SanitizeURLs;
|
|
|
|
// Proof of Work settings
|
|
checkpointConfig.Difficulty = checkpointConfig.ProofOfWork.Difficulty;
|
|
checkpointConfig.SaltLength = checkpointConfig.ProofOfWork.SaltLength;
|
|
checkpointConfig.ChallengeExpiration = parseDuration(
|
|
checkpointConfig.ProofOfWork.ChallengeExpiration,
|
|
);
|
|
checkpointConfig.MaxAttemptsPerHour = checkpointConfig.ProofOfWork.MaxAttemptsPerHour;
|
|
|
|
// Proof of Space-Time settings
|
|
checkpointConfig.CheckPoSTimes = checkpointConfig.ProofOfSpaceTime.Enabled;
|
|
checkpointConfig.PoSTimeConsistencyRatio = checkpointConfig.ProofOfSpaceTime.ConsistencyRatio;
|
|
|
|
// Token settings
|
|
checkpointConfig.TokenExpiration = parseDuration(checkpointConfig.Token.Expiration);
|
|
checkpointConfig.MaxNonceAge = parseDuration(checkpointConfig.Token.MaxNonceAge);
|
|
|
|
// Storage settings
|
|
checkpointConfig.SecretConfigPath = checkpointConfig.Storage.SecretPath;
|
|
checkpointConfig.TokenStoreDBPath = checkpointConfig.Storage.TokenDBPath;
|
|
checkpointConfig.InterstitialPaths = checkpointConfig.Storage.InterstitialTemplates;
|
|
|
|
// Process exclusions
|
|
checkpointConfig.ExclusionRules = checkpointConfig.Exclusion || [];
|
|
|
|
// Process bypass keys
|
|
checkpointConfig.BypassQueryKeys = [];
|
|
checkpointConfig.BypassHeaderKeys = [];
|
|
checkpointConfig.BypassKeys.forEach((key) => {
|
|
if (key.Type === 'query') {
|
|
checkpointConfig.BypassQueryKeys.push({
|
|
Key: key.Key,
|
|
Value: key.Value,
|
|
Domains: key.Hosts || [],
|
|
});
|
|
} else if (key.Type === 'header') {
|
|
checkpointConfig.BypassHeaderKeys.push({
|
|
Name: key.Key,
|
|
Value: key.Value,
|
|
Domains: key.Hosts || [],
|
|
});
|
|
}
|
|
});
|
|
|
|
// Extension handling
|
|
checkpointConfig.HTMLCheckpointIncludedExtensions = checkpointConfig.Extensions.IncludeOnly;
|
|
checkpointConfig.HTMLCheckpointExcludedExtensions = checkpointConfig.Extensions.Exclude;
|
|
|
|
// Remove legacy arrays
|
|
checkpointConfig.HTMLCheckpointExclusions = [];
|
|
checkpointConfig.UserAgentValidationExclusions = [];
|
|
checkpointConfig.UserAgentRequiredPrefixes = {};
|
|
checkpointConfig.ReverseProxyMappings = {};
|
|
}
|
|
|
|
function addReadStreamSupport(dbInstance) {
|
|
if (!dbInstance.createReadStream) {
|
|
dbInstance.createReadStream = (opts) =>
|
|
Readable.from(
|
|
(async function* () {
|
|
for await (const [key, value] of dbInstance.iterator(opts)) {
|
|
yield { key, value };
|
|
}
|
|
})(),
|
|
);
|
|
}
|
|
return dbInstance;
|
|
}
|
|
|
|
function initTokenStore() {
|
|
try {
|
|
const storePath = join(rootDir, checkpointConfig.TokenStoreDBPath || 'db/tokenstore');
|
|
fs.mkdirSync(storePath, { recursive: true });
|
|
|
|
let rawDB = new Level(storePath, { valueEncoding: 'json' });
|
|
|
|
addReadStreamSupport(rawDB);
|
|
db = ttl(rawDB, { defaultTTL: checkpointConfig.TokenExpiration });
|
|
|
|
addReadStreamSupport(db);
|
|
console.log('Token store initialized with TTL');
|
|
} catch (err) {
|
|
console.error('Failed to initialize token store:', err);
|
|
}
|
|
}
|
|
|
|
function getFullClientIP(request) {
|
|
const ip = getRealIP(request) || '';
|
|
const h = crypto.createHash('sha256').update(ip).digest();
|
|
return h.slice(0, 8).toString('hex');
|
|
}
|
|
|
|
function hashUserAgent(ua) {
|
|
if (!ua) return '';
|
|
const h = crypto.createHash('sha256').update(ua).digest();
|
|
return h.slice(0, 8).toString('hex');
|
|
}
|
|
|
|
function extractBrowserFingerprint(request) {
|
|
const headers = [
|
|
'sec-ch-ua',
|
|
'sec-ch-ua-platform',
|
|
'sec-ch-ua-mobile',
|
|
'sec-ch-ua-platform-version',
|
|
'sec-ch-ua-arch',
|
|
'sec-ch-ua-model',
|
|
];
|
|
const parts = headers.map((h) => request.headers.get(h)).filter(Boolean);
|
|
if (!parts.length) return '';
|
|
const buf = Buffer.from(parts.join('|'));
|
|
const h = crypto.createHash('sha256').update(buf).digest();
|
|
return h.slice(0, 12).toString('hex');
|
|
}
|
|
|
|
async function getInterstitialTemplate() {
|
|
if (!interstitialTemplate) {
|
|
for (const p of checkpointConfig.InterstitialPaths) {
|
|
try {
|
|
let templatePath = join(__dirname, p);
|
|
if (fs.existsSync(templatePath)) {
|
|
const raw = await fsPromises.readFile(templatePath, 'utf8');
|
|
interstitialTemplate = simpleTemplate(raw);
|
|
break;
|
|
}
|
|
|
|
templatePath = join(rootDir, p);
|
|
if (fs.existsSync(templatePath)) {
|
|
const raw = await fsPromises.readFile(templatePath, 'utf8');
|
|
interstitialTemplate = simpleTemplate(raw);
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to load interstitial template from path ${p}:`, e);
|
|
}
|
|
}
|
|
|
|
if (!interstitialTemplate) {
|
|
// Create a minimal fallback template
|
|
console.warn('Could not find interstitial HTML template, using minimal fallback');
|
|
interstitialTemplate = simpleTemplate(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Security Verification</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
</head>
|
|
<body>
|
|
<h1>Security Verification Required</h1>
|
|
<p>Please wait while we verify your request...</p>
|
|
<div id="verification-data"
|
|
data-target="{{TargetPath}}"
|
|
data-request-id="{{RequestID}}">
|
|
</div>
|
|
<script src="/js/c.js"></script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
}
|
|
}
|
|
return interstitialTemplate;
|
|
}
|
|
|
|
// Helper function for safe stats recording
|
|
function safeRecordEvent(metric, data) {
|
|
// If recordEvent is not yet loaded, try to wait for it
|
|
if (!recordEvent && statsLoadPromise) {
|
|
statsLoadPromise.then(() => {
|
|
if (recordEvent) {
|
|
try {
|
|
recordEvent(metric, data);
|
|
} catch (err) {
|
|
console.error(`Failed to record ${metric} event:`, err);
|
|
}
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (typeof recordEvent === 'function') {
|
|
try {
|
|
recordEvent(metric, data);
|
|
} catch (err) {
|
|
console.error(`Failed to record ${metric} event:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function serveInterstitial(request) {
|
|
const ip = getRealIP(request);
|
|
const requestPath = new URL(request.url).pathname;
|
|
safeRecordEvent('checkpoint.sent', { ip, path: requestPath });
|
|
let tpl;
|
|
try {
|
|
tpl = await getInterstitialTemplate();
|
|
} catch (err) {
|
|
console.error('Interstitial template error:', err);
|
|
return new Response('Security verification required.', {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/plain' },
|
|
});
|
|
}
|
|
|
|
const requestID = proofGenerateRequestID(request, checkpointConfig);
|
|
const url = new URL(request.url);
|
|
const host = request.headers.get('host') || url.hostname;
|
|
const targetPath = url.pathname;
|
|
const fullURL = request.url;
|
|
|
|
const html = tpl({
|
|
TargetPath: targetPath,
|
|
RequestID: requestID,
|
|
Host: host,
|
|
FullURL: fullURL,
|
|
});
|
|
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
});
|
|
}
|
|
|
|
async function handleGetCheckpointChallenge(request) {
|
|
const url = new URL(request.url);
|
|
const requestID = url.searchParams.get('id');
|
|
if (!requestID) {
|
|
return new Response(JSON.stringify({ error: 'Missing request ID' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const ip = getRealIP(request);
|
|
const attempts = (ipRateLimit.get(ip) || 0) + 1;
|
|
ipRateLimit.set(ip, attempts);
|
|
|
|
if (attempts > checkpointConfig.MaxAttemptsPerHour) {
|
|
return new Response(
|
|
JSON.stringify({ error: 'Too many challenge requests. Try again later.' }),
|
|
{
|
|
status: 429,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
}
|
|
|
|
const params = getChallengeParams(requestID);
|
|
if (!params) {
|
|
return new Response(JSON.stringify({ error: 'Challenge not found or expired' }), {
|
|
status: 404,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (ip !== params.ClientIP) {
|
|
return new Response(JSON.stringify({ error: 'IP address mismatch for challenge' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const payload = {
|
|
a: params.Challenge,
|
|
b: params.Salt,
|
|
c: params.Difficulty,
|
|
d: params.PoSSeed,
|
|
};
|
|
return new Response(JSON.stringify(payload), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function calculateTokenHash(token) {
|
|
const data = `${token.Nonce}:${token.Entropy}:${token.Created.getTime()}`;
|
|
return crypto.createHash('sha256').update(data).digest('hex');
|
|
}
|
|
|
|
function computeTokenSignature(token) {
|
|
const copy = { ...token, Signature: '' };
|
|
const serialized = JSON.stringify(copy);
|
|
return crypto.createHmac('sha256', hmacSecret).update(serialized).digest('hex');
|
|
}
|
|
|
|
function verifyTokenSignature(token) {
|
|
if (!token.Signature) return false;
|
|
const expected = computeTokenSignature(token);
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(token.Signature, 'hex'),
|
|
Buffer.from(expected, 'hex'),
|
|
);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function issueToken(request, token) {
|
|
const tokenHash = calculateTokenHash(token);
|
|
const storedData = {
|
|
ClientIPHash: token.ClientIP,
|
|
UserAgentHash: token.UserAgent,
|
|
BrowserHint: token.BrowserHint,
|
|
LastVerified: new Date(token.LastVerified).toISOString(),
|
|
ExpiresAt: new Date(token.ExpiresAt).toISOString(),
|
|
};
|
|
|
|
try {
|
|
await addToken(tokenHash, storedData);
|
|
} catch (err) {
|
|
console.error('Failed to store token:', err);
|
|
}
|
|
|
|
token.Signature = computeTokenSignature(token);
|
|
|
|
const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64');
|
|
|
|
const url = new URL(request.url);
|
|
const cookieDomain = checkpointConfig.CookieDomain || '';
|
|
const sameSite = cookieDomain ? 'Lax' : 'Strict';
|
|
const secure = url.protocol === 'https:';
|
|
const expires = new Date(token.ExpiresAt).toUTCString();
|
|
|
|
const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : '';
|
|
const securePart = secure ? '; Secure' : '';
|
|
const cookieStr =
|
|
`${checkpointConfig.CookieName}=${tokenStr}; Path=/` +
|
|
`${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`;
|
|
return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Set-Cookie': cookieStr,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleVerifyCheckpoint(request) {
|
|
let body;
|
|
try {
|
|
body = await request.json();
|
|
} catch (e) {
|
|
safeRecordEvent('checkpoint.failure', { reason: 'invalid_json', ip: getRealIP(request) });
|
|
return new Response(JSON.stringify({ error: 'Invalid JSON' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const ip = getRealIP(request);
|
|
const params = getChallengeParams(body.request_id);
|
|
|
|
if (!params) {
|
|
safeRecordEvent('checkpoint.failure', { reason: 'invalid_or_expired_request', ip });
|
|
return new Response(JSON.stringify({ error: 'Invalid or expired request ID' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
if (ip !== params.ClientIP) {
|
|
safeRecordEvent('checkpoint.failure', { reason: 'ip_mismatch', ip });
|
|
return new Response(JSON.stringify({ error: 'IP address mismatch' }), {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const challenge = params.Challenge;
|
|
const salt = params.Salt;
|
|
|
|
if (!body.g || !verifyPoW(challenge, salt, body.g, params.Difficulty)) {
|
|
safeRecordEvent('checkpoint.failure', { reason: 'invalid_pow', ip });
|
|
return new Response(JSON.stringify({ error: 'Invalid proof-of-work solution' }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
const nonceKey = body.g + challenge;
|
|
usedNonces.set(nonceKey, Date.now());
|
|
|
|
if (body.h?.length === 3 && body.i?.length === 3) {
|
|
try {
|
|
verifyPoS(body.h, body.i, checkpointConfig);
|
|
} catch (e) {
|
|
safeRecordEvent('checkpoint.failure', { reason: 'invalid_pos', ip });
|
|
return new Response(JSON.stringify({ error: e.message }), {
|
|
status: 400,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
deleteChallenge(body.request_id);
|
|
safeRecordEvent('checkpoint.success', { ip });
|
|
const now = new Date();
|
|
const expiresAt = new Date(now.getTime() + checkpointConfig.TokenExpiration);
|
|
|
|
const token = {
|
|
Nonce: body.g,
|
|
ExpiresAt: expiresAt,
|
|
ClientIP: getFullClientIP(request),
|
|
UserAgent: hashUserAgent(request.headers.get('user-agent')),
|
|
BrowserHint: extractBrowserFingerprint(request),
|
|
Entropy: crypto.randomBytes(8).toString('hex'),
|
|
Created: now,
|
|
LastVerified: now,
|
|
TokenFormat: 2,
|
|
};
|
|
|
|
token.Signature = computeTokenSignature(token);
|
|
const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64');
|
|
|
|
const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex');
|
|
try {
|
|
await db.put(tokenKey, true);
|
|
tokenCache.set(tokenKey, true);
|
|
|
|
tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime());
|
|
console.log(`checkpoint: token stored in DB and cache key=${tokenKey}`);
|
|
} catch (e) {
|
|
console.error('checkpoint: failed to store token in DB:', e);
|
|
}
|
|
|
|
return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function generateUpdatedCookie(token, secure) {
|
|
token.Signature = computeTokenSignature(token);
|
|
const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64');
|
|
const cookieDomain = checkpointConfig.CookieDomain || '';
|
|
const sameSite = cookieDomain ? 'Lax' : 'Strict';
|
|
const expires = new Date(token.ExpiresAt).toUTCString();
|
|
|
|
const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : '';
|
|
const securePart = secure ? '; Secure' : '';
|
|
const cookieStr =
|
|
`${checkpointConfig.CookieName}=${tokenStr}; Path=/` +
|
|
`${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`;
|
|
return cookieStr;
|
|
}
|
|
|
|
async function validateToken(tokenStr, request) {
|
|
if (!tokenStr) return false;
|
|
let token;
|
|
try {
|
|
token = JSON.parse(Buffer.from(tokenStr, 'base64').toString());
|
|
} catch {
|
|
console.log('checkpoint: invalid token format');
|
|
return false;
|
|
}
|
|
|
|
if (Date.now() > new Date(token.ExpiresAt).getTime()) {
|
|
console.log('checkpoint: token expired');
|
|
return false;
|
|
}
|
|
|
|
if (!verifyTokenSignature(token)) {
|
|
console.log('checkpoint: invalid token signature');
|
|
return false;
|
|
}
|
|
|
|
const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex');
|
|
|
|
if (tokenCache.has(tokenKey)) return true;
|
|
|
|
try {
|
|
await db.get(tokenKey);
|
|
tokenCache.set(tokenKey, true);
|
|
|
|
tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime());
|
|
return true;
|
|
} catch {
|
|
console.log('checkpoint: token not found in DB');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function createProxyResponse(targetURL, request) {
|
|
const url = new URL(request.url);
|
|
const targetUrl = new URL(url.pathname + url.search, targetURL);
|
|
|
|
const headers = Object.fromEntries(request.headers.entries());
|
|
delete headers.host;
|
|
|
|
try {
|
|
const method = request.method;
|
|
const options = {
|
|
method,
|
|
headers,
|
|
redirect: 'manual',
|
|
};
|
|
|
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
options.body = await request.blob();
|
|
}
|
|
|
|
const response = await fetch(targetUrl.toString(), options);
|
|
|
|
const responseHeaders = new Headers(response.headers);
|
|
const hopByHopHeaders = [
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
];
|
|
|
|
hopByHopHeaders.forEach((h) => responseHeaders.delete(h));
|
|
|
|
return new Response(response.body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: responseHeaders,
|
|
});
|
|
} catch (err) {
|
|
console.error('Proxy error:', err);
|
|
return new Response('Bad Gateway', { status: 502 });
|
|
}
|
|
}
|
|
|
|
async function handleTokenRedirect(request) {
|
|
const url = new URL(request.url);
|
|
const tokenStr = url.searchParams.get('token');
|
|
if (!tokenStr) return undefined;
|
|
|
|
let token;
|
|
try {
|
|
token = JSON.parse(Buffer.from(tokenStr, 'base64').toString());
|
|
|
|
if (Date.now() > new Date(token.ExpiresAt).getTime()) {
|
|
console.log('checkpoint: token in URL parameter expired');
|
|
return undefined;
|
|
}
|
|
|
|
if (!verifyTokenSignature(token)) {
|
|
console.log('checkpoint: invalid token signature in URL parameter');
|
|
return undefined;
|
|
}
|
|
|
|
const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex');
|
|
try {
|
|
await db.get(tokenKey);
|
|
} catch {
|
|
console.log('checkpoint: token in URL parameter not found in DB');
|
|
return undefined;
|
|
}
|
|
} catch (e) {
|
|
console.log('checkpoint: invalid token format in URL parameter', e);
|
|
return undefined;
|
|
}
|
|
|
|
const expires = new Date(token.ExpiresAt).toUTCString();
|
|
const cookieDomain = checkpointConfig.CookieDomain || '';
|
|
const sameSite = cookieDomain ? 'Lax' : 'Strict';
|
|
const securePart = url.protocol === 'https:' ? '; Secure' : '';
|
|
const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : '';
|
|
const cookieStr =
|
|
`${checkpointConfig.CookieName}=${tokenStr}; Path=/` +
|
|
`${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`;
|
|
|
|
url.searchParams.delete('token');
|
|
const cleanUrl = url.pathname + (url.search || '');
|
|
return new Response(null, {
|
|
status: 302,
|
|
headers: {
|
|
'Set-Cookie': cookieStr,
|
|
Location: cleanUrl,
|
|
},
|
|
});
|
|
}
|
|
|
|
function CheckpointMiddleware() {
|
|
return async (request) => {
|
|
// Check if checkpoint is enabled
|
|
if (checkpointConfig.Enabled === false) {
|
|
return undefined;
|
|
}
|
|
|
|
const urlObj = new URL(request.url);
|
|
const host = request.headers.get('host')?.split(':')[0];
|
|
const userAgent = request.headers.get('user-agent') || '';
|
|
|
|
// 1) Bypass via query keys
|
|
for (const { Key, Value, Domains } of checkpointConfig.BypassQueryKeys) {
|
|
if (urlObj.searchParams.get(Key) === Value) {
|
|
if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) {
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2) Bypass via header keys
|
|
for (const { Name, Value, Domains } of checkpointConfig.BypassHeaderKeys) {
|
|
const headerVal = request.headers.get(Name);
|
|
if (headerVal === Value) {
|
|
if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) {
|
|
return undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle token redirect for URL-token login
|
|
const tokenResponse = await handleTokenRedirect(request);
|
|
if (tokenResponse) return tokenResponse;
|
|
|
|
// Setup request context
|
|
const url = new URL(request.url);
|
|
let path = url.pathname;
|
|
if (checkpointConfig.SanitizeURLs) {
|
|
path = sanitizePath(path);
|
|
}
|
|
const method = request.method;
|
|
|
|
// Always allow challenge & verify endpoints
|
|
if (method === 'GET' && path === '/api/challenge') {
|
|
return handleGetCheckpointChallenge(request);
|
|
}
|
|
if (method === 'POST' && path === '/api/verify') {
|
|
return handleVerifyCheckpoint(request);
|
|
}
|
|
|
|
// Check new exclusion rules
|
|
if (checkpointConfig.ExclusionRules && checkpointConfig.ExclusionRules.length > 0) {
|
|
for (const rule of checkpointConfig.ExclusionRules) {
|
|
// Check if path matches
|
|
if (!rule.Path || !path.startsWith(rule.Path)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if host matches (if specified)
|
|
if (rule.Hosts && rule.Hosts.length > 0 && !rule.Hosts.includes(host)) {
|
|
continue;
|
|
}
|
|
|
|
// Check if user agent matches (if specified)
|
|
if (rule.UserAgents && rule.UserAgents.length > 0) {
|
|
const matchesUA = rule.UserAgents.some((ua) => userAgent.includes(ua));
|
|
if (!matchesUA) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// All conditions match - exclude this request
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Check file extensions
|
|
const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : '';
|
|
|
|
// First check excluded extensions
|
|
if (ext && checkpointConfig.HTMLCheckpointExcludedExtensions.includes(ext)) {
|
|
return undefined;
|
|
}
|
|
|
|
// Then check if we should only include specific extensions
|
|
if (checkpointConfig.HTMLCheckpointIncludedExtensions.length > 0) {
|
|
// If extension list is specified and current extension is not in it, skip
|
|
if (!checkpointConfig.HTMLCheckpointIncludedExtensions.includes(ext)) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// Validate session token
|
|
const cookies = cookie.parse(request.headers.get('cookie') || '');
|
|
const tokenCookie = cookies[checkpointConfig.CookieName];
|
|
const validation = await validateToken(tokenCookie, request);
|
|
if (validation) {
|
|
// Active session: bypass checkpoint
|
|
return undefined;
|
|
}
|
|
|
|
// Log new checkpoint flow
|
|
console.log(`checkpoint: incoming ${method} ${request.url}`);
|
|
console.log(`checkpoint: tokenCookie=${tokenCookie}`);
|
|
console.log(`checkpoint: validateToken => ${validation}`);
|
|
|
|
// Serve interstitial challenge
|
|
return serveInterstitial(request);
|
|
};
|
|
}
|
|
|
|
async function addToken(tokenHash, data) {
|
|
if (!db) return;
|
|
try {
|
|
const ttlMs = checkpointConfig.TokenExpiration;
|
|
|
|
await db.put(tokenHash, data);
|
|
|
|
tokenExpirations.set(tokenHash, Date.now() + ttlMs);
|
|
} catch (err) {
|
|
console.error('Error adding token:', err);
|
|
}
|
|
}
|
|
|
|
async function updateTokenVerification(tokenHash) {
|
|
if (!db) return;
|
|
try {
|
|
const data = await db.get(tokenHash);
|
|
data.LastVerified = new Date().toISOString();
|
|
await db.put(tokenHash, data);
|
|
} catch (err) {
|
|
console.error('Error updating token verification:', err);
|
|
}
|
|
}
|
|
|
|
async function lookupTokenData(tokenHash) {
|
|
if (!db) return { data: null, found: false };
|
|
try {
|
|
const expireTime = tokenExpirations.get(tokenHash);
|
|
if (!expireTime || expireTime <= Date.now()) {
|
|
if (expireTime) {
|
|
tokenExpirations.delete(tokenHash);
|
|
try {
|
|
await db.del(tokenHash);
|
|
} catch (e) {}
|
|
}
|
|
return { data: null, found: false };
|
|
}
|
|
|
|
const data = await db.get(tokenHash);
|
|
return { data, found: true };
|
|
} catch (err) {
|
|
if (err.code === 'LEVEL_NOT_FOUND') return { data: null, found: false };
|
|
console.error('Error looking up token:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function closeTokenStore() {
|
|
if (db) await db.close();
|
|
}
|
|
|
|
function startCleanupTimer() {
|
|
// Cleanup expired data hourly
|
|
setInterval(() => {
|
|
cleanupExpiredData();
|
|
}, 3600000);
|
|
// Cleanup expired challenges at the challenge expiration interval
|
|
const challengeInterval = checkpointConfig.ChallengeExpiration || 60000;
|
|
setInterval(() => {
|
|
cleanupExpiredChallenges();
|
|
}, challengeInterval);
|
|
}
|
|
|
|
function cleanupExpiredData() {
|
|
const now = Date.now();
|
|
let count = 0;
|
|
|
|
try {
|
|
for (const [nonce, ts] of usedNonces.entries()) {
|
|
if (now - ts > checkpointConfig.MaxNonceAge) {
|
|
usedNonces.delete(nonce);
|
|
count++;
|
|
}
|
|
}
|
|
if (count) console.log(`Checkpoint: cleaned up ${count} expired nonces.`);
|
|
} catch (err) {
|
|
console.error('Error cleaning up nonces:', err);
|
|
}
|
|
|
|
// Clean up expired tokens from cache
|
|
let tokenCacheCount = 0;
|
|
try {
|
|
for (const [tokenKey, _] of tokenCache.entries()) {
|
|
const expireTime = tokenExpirations.get(tokenKey);
|
|
if (!expireTime || expireTime <= now) {
|
|
tokenCache.delete(tokenKey);
|
|
tokenExpirations.delete(tokenKey);
|
|
tokenCacheCount++;
|
|
}
|
|
}
|
|
if (tokenCacheCount)
|
|
console.log(`Checkpoint: cleaned up ${tokenCacheCount} expired tokens from cache.`);
|
|
} catch (err) {
|
|
console.error('Error cleaning up token cache:', err);
|
|
}
|
|
|
|
try {
|
|
ipRateLimit.clear();
|
|
console.log('Checkpoint: IP rate limits reset.');
|
|
} catch (err) {
|
|
console.error('Error resetting IP rate limits:', err);
|
|
}
|
|
}
|
|
|
|
function cleanupExpiredChallenges() {
|
|
const now = Date.now();
|
|
let count = 0;
|
|
for (const [id, params] of challengeStore.entries()) {
|
|
if (params.ExpiresAt && params.ExpiresAt < now) {
|
|
// Record failure for expired challenges that were never completed
|
|
safeRecordEvent('checkpoint.failure', {
|
|
reason: 'challenge_expired',
|
|
ip: params.ClientIP,
|
|
challenge_id: id.substring(0, 8), // Include partial ID for debugging
|
|
age_ms: now - params.CreatedAt, // How long the challenge existed
|
|
expiry_ms: checkpointConfig.ChallengeExpiration, // Configured expiry time
|
|
});
|
|
challengeStore.delete(id);
|
|
count++;
|
|
}
|
|
}
|
|
if (count) console.log(`Checkpoint: cleaned up ${count} expired challenges.`);
|
|
}
|
|
|
|
async function initSecret() {
|
|
try {
|
|
if (!checkpointConfig.SecretConfigPath) {
|
|
checkpointConfig.SecretConfigPath = join(rootDir, 'data', 'checkpoint_secret.json');
|
|
}
|
|
|
|
const secretPath = checkpointConfig.SecretConfigPath;
|
|
const exists = fs.existsSync(secretPath);
|
|
|
|
if (exists) {
|
|
const loaded = loadSecretFromFile();
|
|
if (loaded) {
|
|
hmacSecret = loaded;
|
|
console.log(`Loaded existing HMAC secret from ${secretPath}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
hmacSecret = crypto.randomBytes(32);
|
|
fs.mkdirSync(path.dirname(secretPath), { recursive: true });
|
|
|
|
const secretCfg = {
|
|
hmac_secret: hmacSecret.toString('base64'),
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
};
|
|
|
|
fs.writeFileSync(secretPath, JSON.stringify(secretCfg), { mode: 0o600 });
|
|
console.log(`Created and saved new HMAC secret to ${secretPath}`);
|
|
} catch (err) {
|
|
console.error('Error initializing secret:', err);
|
|
|
|
hmacSecret = crypto.randomBytes(32);
|
|
}
|
|
}
|
|
|
|
function loadSecretFromFile() {
|
|
try {
|
|
const data = fs.readFileSync(checkpointConfig.SecretConfigPath, 'utf8');
|
|
const cfg = JSON.parse(data);
|
|
const buf = Buffer.from(cfg.hmac_secret, 'base64');
|
|
if (buf.length < 16) return null;
|
|
|
|
cfg.updated_at = new Date().toISOString();
|
|
fs.writeFileSync(checkpointConfig.SecretConfigPath, JSON.stringify(cfg), { mode: 0o600 });
|
|
return buf;
|
|
} catch (e) {
|
|
console.warn('Could not load HMAC secret from file:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
(async function initialize() {
|
|
await initConfig();
|
|
await initSecret();
|
|
initTokenStore();
|
|
startCleanupTimer();
|
|
|
|
// Only register plugin if enabled
|
|
if (checkpointConfig.Enabled !== false) {
|
|
registerPlugin('checkpoint', CheckpointMiddleware());
|
|
} else {
|
|
console.log('Checkpoint plugin disabled via configuration');
|
|
}
|
|
})();
|
|
|
|
export { checkpointConfig, addToken, updateTokenVerification, lookupTokenData, closeTokenStore };
|