feat(pockethost): add IP and hostname rate limiters

This commit is contained in:
Ben Allfree 2025-10-19 00:37:39 +00:00
parent f91d1cf001
commit 262da3e455
4 changed files with 80 additions and 0 deletions

View File

@ -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",

View File

@ -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`)
}
}
}

View File

@ -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))

8
pnpm-lock.yaml generated
View File

@ -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