Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down
13 changes: 13 additions & 0 deletions src/lib/middleware.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
152 changes: 152 additions & 0 deletions src/lib/rateLimiter.js
Original file line number Diff line number Diff line change
@@ -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 };
11 changes: 11 additions & 0 deletions src/routes/ui/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -40,16 +41,26 @@ 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' });
}

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,
Expand Down
130 changes: 130 additions & 0 deletions tests/rateLimiter.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading