diff --git a/src/index.js b/src/index.js index ad28fff..1e5c020 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ import { connectHsync } from './lib/hsyncManager.js'; import { startCloudflared } from './lib/cloudflareManager.js'; import { initSocket } from './lib/socketManager.js'; import { apiKeyAuth, writeProxy, serviceAccessCheck } from './lib/middleware.js'; +import { globalRateLimit } from './lib/rateLimiter.js'; import githubRoutes from './routes/github.js'; import blueskyRoutes from './routes/bluesky.js'; import redditRoutes from './routes/reddit.js'; @@ -43,6 +44,9 @@ const PORT = process.env.PORT || 3050; // No API key auth — the target gateway handles its own authentication app.use('/px/:proxyId', createProxyRouter()); +// Global rate limit — 200 req/min per IP +app.use(globalRateLimit); + app.use(express.json({ limit: '10mb', verify: (req, res, buf) => { diff --git a/src/lib/middleware.js b/src/lib/middleware.js index 03bfd7f..7f0a49a 100644 --- a/src/lib/middleware.js +++ b/src/lib/middleware.js @@ -1,24 +1,37 @@ import { validateApiKey, checkServiceAccess, checkBypassAuth, createQueueEntry, markAutoApproved, updateQueueStatus, getAccountCredentials } from './db.js'; import { getAccessToken, buildUrl, buildHeaders } from './queueExecutor.js'; import { emitCountUpdate } from './socketManager.js'; +import { checkAuthBackoff, recordAuthFailure, clearAuthFailures } from './rateLimiter.js'; // API key auth middleware for /api routes export async function apiKeyAuth(req, res, next) { + // Check if IP is in backoff from previous failures + const { blocked, retryAfter } = checkAuthBackoff(req.ip); + if (blocked) { + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ error: 'Too many failed authentication attempts', retryAfter }); + } + const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { + recordAuthFailure(req.ip); return res.status(401).json({ error: 'Missing or invalid Authorization header' }); } const key = authHeader.slice(7); const valid = await validateApiKey(key); if (!valid) { + recordAuthFailure(req.ip); return res.status(401).json({ error: 'Invalid API key' }); } if (!valid.enabled) { + recordAuthFailure(req.ip); return res.status(403).json({ error: 'Agent is disabled' }); } + // Successful auth — clear any backoff + clearAuthFailures(req.ip); req.apiKeyInfo = valid; next(); diff --git a/src/lib/rateLimiter.js b/src/lib/rateLimiter.js new file mode 100644 index 0000000..c024788 --- /dev/null +++ b/src/lib/rateLimiter.js @@ -0,0 +1,152 @@ +// Rate limiting and exponential backoff on failed auth +// In-memory stores (single-process deployment) + +// --- Global rate limit: per-IP request counter --- + +const globalHits = new Map(); // ip -> { count, resetTime } +const GLOBAL_WINDOW_MS = 60 * 1000; // 1 minute +const GLOBAL_MAX_REQUESTS = 200; + +/** + * Global rate limit middleware — 200 req/min per IP. + * Applies to all routes. + */ +export function globalRateLimit(req, res, next) { + const ip = req.ip; + const now = Date.now(); + + let entry = globalHits.get(ip); + if (!entry || now > entry.resetTime) { + entry = { count: 0, resetTime: now + GLOBAL_WINDOW_MS }; + globalHits.set(ip, entry); + } + + entry.count++; + + // Set standard rate limit headers + const remaining = Math.max(0, GLOBAL_MAX_REQUESTS - entry.count); + res.set('X-RateLimit-Limit', String(GLOBAL_MAX_REQUESTS)); + res.set('X-RateLimit-Remaining', String(remaining)); + res.set('X-RateLimit-Reset', String(Math.ceil(entry.resetTime / 1000))); + + if (entry.count > GLOBAL_MAX_REQUESTS) { + const retryAfter = Math.ceil((entry.resetTime - now) / 1000); + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ + error: 'Too many requests', + retryAfter + }); + } + + next(); +} + +// --- Auth failure exponential backoff --- + +const authFailures = new Map(); // ip -> { count, lockedUntil } +const AUTH_BACKOFF_THRESHOLD = 3; // failures before delays kick in +const AUTH_BACKOFF_MAX_MS = 10 * 60 * 1000; // 10 minute cap +const AUTH_FAILURE_WINDOW_MS = 30 * 60 * 1000; // reset after 30 min of no failures + +/** + * Calculate delay for a given failure count. + * 1-3 failures: no delay + * 4-5: 2s + * 6+: doubles each time (4s, 8s, 16s, ...) capped at 10 min + */ +function getBackoffDelay(failureCount) { + if (failureCount <= AUTH_BACKOFF_THRESHOLD) return 0; + + const exponent = failureCount - AUTH_BACKOFF_THRESHOLD - 1; + const delayMs = 2000 * Math.pow(2, exponent); + return Math.min(delayMs, AUTH_BACKOFF_MAX_MS); +} + +/** + * Check if an IP is currently in a backoff delay. + * Returns { blocked, retryAfter } where retryAfter is seconds. + */ +export function checkAuthBackoff(ip) { + const entry = authFailures.get(ip); + if (!entry) return { blocked: false, retryAfter: 0 }; + + const now = Date.now(); + + // Clear stale entries + if (now - entry.lastFailure > AUTH_FAILURE_WINDOW_MS) { + authFailures.delete(ip); + return { blocked: false, retryAfter: 0 }; + } + + if (entry.lockedUntil && now < entry.lockedUntil) { + const retryAfter = Math.ceil((entry.lockedUntil - now) / 1000); + return { blocked: true, retryAfter }; + } + + return { blocked: false, retryAfter: 0 }; +} + +/** + * Record a failed auth attempt for an IP. + */ +export function recordAuthFailure(ip) { + const now = Date.now(); + let entry = authFailures.get(ip); + + if (!entry || now - entry.lastFailure > AUTH_FAILURE_WINDOW_MS) { + entry = { count: 0, lastFailure: now, lockedUntil: null }; + } + + entry.count++; + entry.lastFailure = now; + + const delay = getBackoffDelay(entry.count); + entry.lockedUntil = delay > 0 ? now + delay : null; + + authFailures.set(ip, entry); +} + +/** + * Clear auth failures for an IP (on successful auth). + */ +export function clearAuthFailures(ip) { + authFailures.delete(ip); +} + +/** + * Middleware that blocks requests from IPs in backoff. + * Mount before auth-checking routes. + */ +export function authBackoffMiddleware(req, res, next) { + const { blocked, retryAfter } = checkAuthBackoff(req.ip); + if (blocked) { + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ + error: 'Too many failed authentication attempts', + retryAfter + }); + } + next(); +} + +// --- Cleanup: periodically purge stale entries --- + +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // every 5 min + +function cleanup() { + const now = Date.now(); + + for (const [ip, entry] of globalHits) { + if (now > entry.resetTime) globalHits.delete(ip); + } + + for (const [ip, entry] of authFailures) { + if (now - entry.lastFailure > AUTH_FAILURE_WINDOW_MS) authFailures.delete(ip); + } +} + +const cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL_MS); +cleanupTimer.unref(); // don't prevent process exit + +// Export for testing +export { getBackoffDelay, globalHits, authFailures, cleanup }; diff --git a/src/routes/ui/auth.js b/src/routes/ui/auth.js index e012024..4d977d9 100644 --- a/src/routes/ui/auth.js +++ b/src/routes/ui/auth.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import { setAdminPassword, verifyAdminPassword, hasAdminPassword } from '../../lib/db.js'; import { AUTH_COOKIE, COOKIE_MAX_AGE } from './shared.js'; +import { checkAuthBackoff, recordAuthFailure, clearAuthFailures } from '../../lib/rateLimiter.js'; const router = Router(); @@ -40,6 +41,12 @@ router.get('/login', (req, res) => { // Handle login router.post('/login', async (req, res) => { + // Check if IP is in backoff from previous failures + const { blocked, retryAfter } = checkAuthBackoff(req.ip); + if (blocked) { + return res.render('pages/login', { error: `Too many failed attempts. Try again in ${retryAfter} seconds.` }); + } + const { password } = req.body; if (!password) { return res.render('pages/login', { error: 'Password required' }); @@ -47,9 +54,13 @@ router.post('/login', async (req, res) => { const valid = await verifyAdminPassword(password); if (!valid) { + recordAuthFailure(req.ip); return res.render('pages/login', { error: 'Invalid password' }); } + // Successful auth — clear any backoff + clearAuthFailures(req.ip); + res.cookie(AUTH_COOKIE, 'authenticated', { signed: true, httpOnly: true, diff --git a/tests/rateLimiter.test.js b/tests/rateLimiter.test.js new file mode 100644 index 0000000..f0dbbdd --- /dev/null +++ b/tests/rateLimiter.test.js @@ -0,0 +1,130 @@ +import { jest } from '@jest/globals'; + +// Import the module +let rateLimiter; + +beforeEach(async () => { + // Fresh import each test to reset state + rateLimiter = await import('../src/lib/rateLimiter.js'); + // Clear maps between tests + rateLimiter.globalHits.clear(); + rateLimiter.authFailures.clear(); +}); + +describe('getBackoffDelay', () => { + test('no delay for 1-3 failures', () => { + expect(rateLimiter.getBackoffDelay(1)).toBe(0); + expect(rateLimiter.getBackoffDelay(2)).toBe(0); + expect(rateLimiter.getBackoffDelay(3)).toBe(0); + }); + + test('2s delay for 4th failure', () => { + expect(rateLimiter.getBackoffDelay(4)).toBe(2000); + }); + + test('2s delay for 5th failure', () => { + expect(rateLimiter.getBackoffDelay(5)).toBe(4000); + }); + + test('doubles each time after threshold', () => { + expect(rateLimiter.getBackoffDelay(6)).toBe(8000); + expect(rateLimiter.getBackoffDelay(7)).toBe(16000); + }); + + test('caps at 10 minutes', () => { + expect(rateLimiter.getBackoffDelay(100)).toBe(10 * 60 * 1000); + }); +}); + +describe('globalRateLimit middleware', () => { + function mockReqRes(ip = '127.0.0.1') { + const req = { ip }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + set: jest.fn() + }; + const next = jest.fn(); + return { req, res, next }; + } + + test('allows requests under limit', () => { + const { req, res, next } = mockReqRes(); + rateLimiter.globalRateLimit(req, res, next); + expect(next).toHaveBeenCalled(); + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Remaining', '199'); + }); + + test('blocks requests over limit', () => { + const ip = '10.0.0.1'; + // Fill up the limit + for (let i = 0; i < 200; i++) { + const { req, res, next } = mockReqRes(ip); + rateLimiter.globalRateLimit(req, res, next); + } + // 201st should be blocked + const { req, res, next } = mockReqRes(ip); + rateLimiter.globalRateLimit(req, res, next); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + }); + + test('different IPs have separate limits', () => { + // Fill up IP A + for (let i = 0; i < 200; i++) { + const { req, res, next } = mockReqRes('10.0.0.1'); + rateLimiter.globalRateLimit(req, res, next); + } + // IP B should still be fine + const { req, res, next } = mockReqRes('10.0.0.2'); + rateLimiter.globalRateLimit(req, res, next); + expect(next).toHaveBeenCalled(); + }); +}); + +describe('auth backoff', () => { + test('no block initially', () => { + const result = rateLimiter.checkAuthBackoff('1.2.3.4'); + expect(result.blocked).toBe(false); + }); + + test('no block after 3 failures', () => { + rateLimiter.recordAuthFailure('1.2.3.4'); + rateLimiter.recordAuthFailure('1.2.3.4'); + rateLimiter.recordAuthFailure('1.2.3.4'); + const result = rateLimiter.checkAuthBackoff('1.2.3.4'); + expect(result.blocked).toBe(false); + }); + + test('blocks after 4th failure', () => { + for (let i = 0; i < 4; i++) { + rateLimiter.recordAuthFailure('1.2.3.4'); + } + const result = rateLimiter.checkAuthBackoff('1.2.3.4'); + expect(result.blocked).toBe(true); + expect(result.retryAfter).toBeGreaterThan(0); + }); + + test('clearAuthFailures resets', () => { + for (let i = 0; i < 10; i++) { + rateLimiter.recordAuthFailure('1.2.3.4'); + } + rateLimiter.clearAuthFailures('1.2.3.4'); + const result = rateLimiter.checkAuthBackoff('1.2.3.4'); + expect(result.blocked).toBe(false); + }); +}); + +describe('cleanup', () => { + test('removes stale global hits', () => { + rateLimiter.globalHits.set('old-ip', { count: 5, resetTime: Date.now() - 1000 }); + rateLimiter.cleanup(); + expect(rateLimiter.globalHits.has('old-ip')).toBe(false); + }); + + test('keeps fresh global hits', () => { + rateLimiter.globalHits.set('fresh-ip', { count: 5, resetTime: Date.now() + 60000 }); + rateLimiter.cleanup(); + expect(rateLimiter.globalHits.has('fresh-ip')).toBe(true); + }); +});