mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
110 lines
3.3 KiB
TypeScript
110 lines
3.3 KiB
TypeScript
import { timingSafeEqual } from 'node:crypto';
|
|
import { ALLOW_INSECURE_HTTP, API_KEY } from '@/constants/config';
|
|
import { logWarn } from '@/utils/log';
|
|
|
|
// Rate limiing: track failed auth attempts per IP
|
|
const failedAttempts = new Map<string, { count: number; resetAt: number }>();
|
|
const MAX_FAILED_ATTEMPTS = 5;
|
|
const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes
|
|
|
|
// Cloudflare Tunnel sends real IP in CF-Connecting-IP
|
|
const getClientIP = (req: Request) =>
|
|
req.headers.get('cf-connecting-ip') ||
|
|
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
req.headers.get('x-real-ip') ||
|
|
'unknown';
|
|
|
|
const isRateLimited = (ip: string) => {
|
|
const now = Date.now();
|
|
const record = failedAttempts.get(ip);
|
|
|
|
if (!record) return false;
|
|
|
|
if (now > record.resetAt) {
|
|
failedAttempts.delete(ip);
|
|
return false;
|
|
}
|
|
|
|
return record.count >= MAX_FAILED_ATTEMPTS;
|
|
};
|
|
|
|
const recordFailedAttempt = (ip: string) => {
|
|
const now = Date.now();
|
|
const record = failedAttempts.get(ip);
|
|
|
|
if (!record || now > record.resetAt) {
|
|
failedAttempts.set(ip, { count: 1, resetAt: now + LOCKOUT_DURATION });
|
|
} else {
|
|
record.count++;
|
|
if (record.count >= MAX_FAILED_ATTEMPTS) {
|
|
logWarn(`IP ${ip} locked out after ${MAX_FAILED_ATTEMPTS} failed auth attempts`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const checkAuth = (req: Request) => {
|
|
if (!API_KEY) {
|
|
return new Response('Unauthorized', { status: 401 });
|
|
}
|
|
|
|
const clientIP = getClientIP(req);
|
|
|
|
if (isRateLimited(clientIP)) {
|
|
return new Response('Too many failed attempts. Try again later.', { status: 429 });
|
|
}
|
|
|
|
const proto = req.headers.get('x-forwarded-proto') || 'http';
|
|
const host = req.headers.get('host') || '';
|
|
const isLocalhost = host.startsWith('localhost') || host.startsWith('127.0.0.1');
|
|
|
|
if (proto !== 'https' && !isLocalhost && !ALLOW_INSECURE_HTTP) {
|
|
return new Response('HTTPS required when API_KEY is configured', {
|
|
status: 426,
|
|
headers: { Upgrade: 'TLS/1.2, HTTP/1.1' },
|
|
});
|
|
}
|
|
|
|
const authHeader = req.headers.get('authorization');
|
|
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
recordFailedAttempt(clientIP);
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
|
});
|
|
}
|
|
|
|
const base64Credentials = authHeader.slice(6);
|
|
const credentials = Buffer.from(base64Credentials, 'base64').toString('utf-8');
|
|
const [, password] = credentials.split(':');
|
|
const providedKey = password || '';
|
|
|
|
if (providedKey.length !== API_KEY.length) {
|
|
recordFailedAttempt(clientIP);
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
|
});
|
|
}
|
|
|
|
const providedBuffer = Buffer.from(providedKey);
|
|
const keyBuffer = Buffer.from(API_KEY);
|
|
|
|
if (!timingSafeEqual(providedBuffer, keyBuffer)) {
|
|
recordFailedAttempt(clientIP);
|
|
return new Response(null, {
|
|
status: 401,
|
|
headers: { 'WWW-Authenticate': 'Basic realm="SUP Admin - Username: any, Password: API_KEY"' },
|
|
});
|
|
}
|
|
|
|
failedAttempts.delete(clientIP);
|
|
return null;
|
|
};
|
|
|
|
export const withAuth =
|
|
<T extends unknown[]>(handler: (req: Request, ...args: T) => Response | Promise<Response>) =>
|
|
(req: Request, ...args: T) => {
|
|
const auth = checkAuth(req);
|
|
if (auth) return auth;
|
|
return handler(req, ...args);
|
|
};
|