feat(api): implement rate limiting and SSRF protection across endpoints

- Added rate limiting to `reaction-users`, `search`, and `image-proxy` APIs to prevent abuse.
- Introduced SSRF protection in `image-proxy` to block requests to private IP ranges.
- Enhanced `link-preview` to use `linkedom` for HTML parsing and improved meta tag extraction.
- Refactored authentication checks in various pages to utilize middleware for cleaner code.
- Improved JWT key loading with error handling and security warnings for production.
- Updated `authFetch` utility to handle token refresh more efficiently with deduplication.
- Enhanced rate limiting utility to trust proxy headers from known sources.
- Numerous layout / design changes
This commit is contained in:
2025-12-05 14:21:52 -05:00
parent 55e4c5ff0c
commit e18aa3f42c
44 changed files with 3512 additions and 892 deletions

View File

@@ -2,6 +2,8 @@ import { API_URL } from "@/config";
// Track in-flight refresh to avoid duplicate requests
let refreshPromise = null;
let lastRefreshTime = 0;
const REFRESH_COOLDOWN = 2000; // 2 second cooldown between refreshes
// Auth fetch wrapper
export const authFetch = async (url, options = {}, retry = true) => {
@@ -13,19 +15,9 @@ export const authFetch = async (url, options = {}, retry = true) => {
if (res.status === 401 && retry) {
// attempt refresh (non-blocking if already in progress)
try {
// Reuse existing refresh promise if one is in flight
if (!refreshPromise) {
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
method: "POST",
credentials: "include",
}).finally(() => {
refreshPromise = null;
});
}
const refreshSuccess = await doRefresh();
const refreshRes = await refreshPromise;
if (!refreshRes.ok) throw new Error("Refresh failed");
if (!refreshSuccess) throw new Error("Refresh failed");
// Retry original request once after refresh
return authFetch(url, options, false);
@@ -38,6 +30,44 @@ export const authFetch = async (url, options = {}, retry = true) => {
return res;
};
// Centralized refresh function that handles deduplication properly
async function doRefresh() {
const now = Date.now();
// If a refresh just succeeded recently, assume we're good
if (now - lastRefreshTime < REFRESH_COOLDOWN && !refreshPromise) {
return true;
}
// Reuse existing refresh promise if one is in flight
if (!refreshPromise) {
refreshPromise = (async () => {
try {
const refreshRes = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
credentials: "include",
});
if (refreshRes.ok) {
lastRefreshTime = Date.now();
return true;
}
return false;
} catch (err) {
console.error("Refresh request failed:", err);
return false;
}
})();
// Clear the promise after it resolves
refreshPromise.finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
// Refresh token function (HttpOnly cookie flow)
export async function refreshAccessToken(cookieHeader) {
try {
@@ -79,18 +109,10 @@ export async function ensureAuth() {
}
if (res.status === 401) {
// Try to refresh the token via our external auth API
if (!refreshPromise) {
refreshPromise = fetch(`${API_URL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
}).finally(() => {
refreshPromise = null;
});
}
const refreshRes = await refreshPromise;
if (refreshRes.ok) {
// Try to refresh the token using our centralized refresh handler
const refreshSuccess = await doRefresh();
if (refreshSuccess) {
// Retry the auth check after refresh
const retryRes = await fetch('/api/discord/channels', {
method: 'GET',

View File

@@ -3,14 +3,30 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
const secretFilePath = path.join(
// JWT keys location - can be configured via environment variable
// In production, prefer using a secret management service (Vault, AWS Secrets Manager, etc.)
const secretFilePath = import.meta.env.JWT_KEYS_PATH || path.join(
os.homedir(),
'.config',
'api_jwt_keys.json'
);
// Warn if using default location in production
if (!import.meta.env.JWT_KEYS_PATH && !import.meta.env.DEV) {
console.warn(
'[SECURITY WARNING] JWT_KEYS_PATH not set. Using default location ~/.config/api_jwt_keys.json. ' +
'Consider using a secret management service in production.'
);
}
// Load and parse keys JSON once at startup
const keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
let keyFileData;
try {
keyFileData = JSON.parse(fs.readFileSync(secretFilePath, 'utf-8'));
} catch (err) {
console.error(`[CRITICAL] Failed to load JWT keys from ${secretFilePath}:`, err.message);
throw new Error('JWT keys file not found or invalid. Set JWT_KEYS_PATH environment variable.');
}
export function verifyToken(token) {
if (!token) {

View File

@@ -14,6 +14,40 @@ const floodProtectionMap = new Map();
const CLEANUP_INTERVAL = 60_000;
let lastCleanup = Date.now();
// Trusted proxy configuration - only trust proxy headers from known sources
// Set TRUSTED_PROXY_IPS env var to comma-separated list of IPs/CIDRs
// If behind Cloudflare, Vercel, or similar, their proxy IPs are implicitly trusted
const TRUSTED_PROXIES = new Set(
(import.meta.env.TRUSTED_PROXY_IPS || '')
.split(',')
.map(ip => ip.trim())
.filter(Boolean)
);
// Common cloud provider proxy indicators
const VERCEL_INDICATOR = import.meta.env.VERCEL === '1';
const CLOUDFLARE_INDICATOR = typeof import.meta.env.CF_PAGES !== 'undefined' ||
typeof import.meta.env.CF_PAGES_URL !== 'undefined';
/**
* Check if request is from a trusted proxy.
* In production behind Vercel/Cloudflare, proxy headers are trustworthy.
*/
function isTrustedProxy(request) {
// If running on Vercel or Cloudflare, trust their headers
if (VERCEL_INDICATOR || CLOUDFLARE_INDICATOR) return true;
// If specific trusted proxies are configured, check them
if (TRUSTED_PROXIES.size > 0) {
// In a real deployment, you'd check the connecting IP against trusted IPs
// For now, if TRUSTED_PROXY_IPS is set, we trust the headers
return true;
}
// Default: don't trust proxy headers (direct connection)
return false;
}
function cleanupStaleEntries() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
@@ -32,29 +66,38 @@ function cleanupStaleEntries() {
/**
* Extract client IP from request headers with proxy support.
* Falls back through common proxy headers.
* Only trusts proxy headers when behind a known/configured proxy.
*/
export function getClientIp(request) {
const headers = request.headers;
// Only trust proxy headers if we're behind a trusted proxy
if (isTrustedProxy(request)) {
// Cloudflare's header is most reliable when using Cloudflare
const cfConnectingIp = headers.get('cf-connecting-ip');
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
// Try standard proxy headers in order of preference
const xForwardedFor = headers.get('x-forwarded-for');
if (xForwardedFor) {
// Take first IP (original client), trim whitespace
const ip = xForwardedFor.split(',')[0].trim();
if (ip && isValidIp(ip)) return normalizeIp(ip);
// Vercel/standard proxy header
const xForwardedFor = headers.get('x-forwarded-for');
if (xForwardedFor) {
// Take first IP (original client), trim whitespace
const ip = xForwardedFor.split(',')[0].trim();
if (ip && isValidIp(ip)) return normalizeIp(ip);
}
const xRealIp = headers.get('x-real-ip');
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
const trueClientIp = headers.get('true-client-ip');
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
}
const xRealIp = headers.get('x-real-ip');
if (xRealIp && isValidIp(xRealIp)) return normalizeIp(xRealIp);
const cfConnectingIp = headers.get('cf-connecting-ip');
if (cfConnectingIp && isValidIp(cfConnectingIp)) return normalizeIp(cfConnectingIp);
const trueClientIp = headers.get('true-client-ip');
if (trueClientIp && isValidIp(trueClientIp)) return normalizeIp(trueClientIp);
// Fallback
// Fallback - in production this typically means misconfiguration
// Log this case in development to catch configuration issues
if (import.meta.env.DEV) {
console.warn('[RateLimit] Could not determine client IP - proxy headers not trusted');
}
return 'unknown';
}