Initial commit: Upload Checkpoint project
This commit is contained in:
commit
c0e3781244
32 changed files with 6121 additions and 0 deletions
961
checkpoint.js
Normal file
961
checkpoint.js
Normal file
|
|
@ -0,0 +1,961 @@
|
|||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue