diff --git a/packages/pockethost/package.json b/packages/pockethost/package.json index 2f0200a9..939e1adf 100644 --- a/packages/pockethost/package.json +++ b/packages/pockethost/package.json @@ -53,6 +53,7 @@ "node-fetch": "^3.3.2", "node-os-utils": "^1.3.7", "pocketbase": "^0.21.3", + "rate-limiter-flexible": "^8.1.0", "semver": "^7.6.3", "tail": "^2.2.6", "tsx": "^4.20.3", diff --git a/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/rate-limiter.ts b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/rate-limiter.ts new file mode 100644 index 00000000..20d42c5f --- /dev/null +++ b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/rate-limiter.ts @@ -0,0 +1,69 @@ +import express from 'express' +import { RateLimiterMemory } from 'rate-limiter-flexible' +import { Logger } from 'src/common' + +const getClientIp = (req: express.Request): string | undefined => { + const cf = req.headers['cf-connecting-ip'] || req.headers['true-client-ip'] + if (cf) return Array.isArray(cf) ? cf[0] : cf + + const xff = req.headers['x-forwarded-for'] + const xffStr = Array.isArray(xff) ? xff.join(',') : xff + if (typeof xffStr === 'string') { + const ip = xffStr.split(',')?.[0]?.trim() + if (ip) return ip + } + + const xri = req.headers['x-real-ip'] + if (xri) return Array.isArray(xri) ? xri[0] : xri + + return req.ip || req.socket?.remoteAddress +} + +// Middleware factory to create a rate limiting middleware +export const createRateLimiterMiddleware = (logger: Logger) => { + const { dbg, warn } = logger.create(`RateLimiter`) + dbg(`Creating`) + + const ipRateLimiter = new RateLimiterMemory({ + points: 1000, + duration: 60 * 60, + }) + + const hostnameRateLimiter = new RateLimiterMemory({ + points: 10000, + duration: 60 * 60, + }) + + return async (req: express.Request, res: express.Response, next: express.NextFunction) => { + const ip = getClientIp(req) + if (!ip) { + warn(`Could not determine IP address`) + return next() + } + + const hostname = req.hostname + // dbg(`Request from ${ip} for host ${hostname}`) + + try { + const ipResult = await ipRateLimiter.consume(ip) + dbg(`IP points remaining for ${ip}: ${ipResult.remainingPoints}`) + } catch (rateLimiterRes: any) { + const retryAfter = Math.ceil(rateLimiterRes.msBeforeNext / 1000) + warn(`IP rate limit exceeded for ${ip} on host ${hostname}. Retry after ${retryAfter} seconds`) + res.set('Retry-After', String(retryAfter)) + res.status(429).send(`Too Many Requests: retry after ${retryAfter} seconds`) + return + } + + try { + const hostnameResult = await hostnameRateLimiter.consume(hostname) + dbg(`Hostname points remaining for ${hostname}: ${hostnameResult.remainingPoints}`) + next() + } catch (rateLimiterRes: any) { + const retryAfter = Math.ceil(rateLimiterRes.msBeforeNext / 1000) + warn(`Hostname rate limit exceeded for ${hostname} by IP ${ip}. Retry after ${retryAfter} seconds`) + res.set('Retry-After', String(retryAfter)) + res.status(429).send(`Too Many Requests: retry after ${retryAfter} seconds`) + } + } +} diff --git a/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts index 5ce53c78..44ef87d4 100644 --- a/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts +++ b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts @@ -21,6 +21,7 @@ import { createProxyMiddleware } from 'http-proxy-middleware' import https from 'https' import { createIpWhitelistMiddleware } from './cidr' import { createVhostProxyMiddleware } from './createVhostProxyMiddleware' +import { createRateLimiterMiddleware } from './rate-limiter' export type FirewallOptions = { logger: Logger @@ -82,6 +83,7 @@ export const firewall = async ({ logger }: FirewallOptions) => { // Use the IP blocker middleware app.use(createIpWhitelistMiddleware(IPCIDR_LIST())) + app.use(createRateLimiterMiddleware(logger)) forEach(hostnameRoutes, (target, host) => { app.use(createVhostProxyMiddleware(host, target, IS_DEV(), logger)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1be76155..9ac78dad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -253,6 +253,9 @@ importers: pocketbase: specifier: ^0.21.3 version: 0.21.5 + rate-limiter-flexible: + specifier: ^8.1.0 + version: 8.1.0 semver: specifier: ^7.6.3 version: 7.6.3 @@ -3606,6 +3609,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@8.1.0: + resolution: {integrity: sha512-J+4xBdVboibP1h0Imn4nFoCLT+UM9Os9vJaWaRWkLsQxS7jrhLJeLlmzP5hyCEsLwtgFIIY5KcWiJGyyVTMaKg==} + raw-body@2.5.2: resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} engines: {node: '>= 0.8'} @@ -7550,6 +7556,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@8.1.0: {} + raw-body@2.5.2: dependencies: bytes: 3.1.2