From b03fdc41fe1305437fe656329020a06dfd66d736 Mon Sep 17 00:00:00 2001 From: Ben Allfree Date: Mon, 20 Oct 2025 14:07:41 +0000 Subject: [PATCH] enh(pockethost): add user proxy whitelisting --- .../src/routes/(static)/docs/limits/+page.md | 18 +++++++++++ .../ServeCommand/firewall/rate-limiter.ts | 31 +++++++++++++++++-- .../ServeCommand/firewall/server.ts | 3 +- packages/pockethost/src/constants.ts | 3 ++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/src/routes/(static)/docs/limits/+page.md b/packages/dashboard/src/routes/(static)/docs/limits/+page.md index 08e3dc6a..db907166 100644 --- a/packages/dashboard/src/routes/(static)/docs/limits/+page.md +++ b/packages/dashboard/src/routes/(static)/docs/limits/+page.md @@ -39,6 +39,24 @@ If you're making numerous requests from the client side, we recommend using the In general, exceeding the rate limit often indicates a coding issue. Another option is to write custom routes using [JS Hooks](/docs/programming) to perform bulk fetching and filtering server-side, which can be difficult to manage effectively on the client side. +### Server-Side Rendering (SSR) and Proxy Servers + +If you're using a proxy server for Server-Side Rendering (SSR) purposes, all requests to PocketHost will appear to come from your server's IP address rather than your end users' IPs. This means your server will quickly hit the per-IP rate limits (1,000 requests/hour and 5 concurrent requests), affecting all your users. + +**Our recommended solutions:** + +1. **Switch to Client-Side Rendering (CSR)** - Make API calls directly from the browser instead of through your server +2. **Use [PocketPages.dev](https://pocketpages.dev)** - A lightweight SSR solution that runs directly within PocketBase + +**If you must use a proxy server:** + +If neither of the above solutions work for your use case, you can configure your proxy to forward the real client IP addresses: + +1. Configure your proxy server to send the `X-PocketHost-Client-IP` header with each request, containing the real client's IP address +2. Contact [PocketHost Support](/support) to whitelist your proxy server's IP address + +Once whitelisted, PocketHost will use the IP from the `X-PocketHost-Client-IP` header for rate limiting instead of your proxy server's IP, ensuring each end user gets their own rate limit allocation. + ### Special Cases In special cases, such as during conferences or events where a large amount of traffic originates from a single IP, we have ways to expand or bypass these rate limits. If this applies to you, please contact [PocketHost Support](/support). 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 index 71f3fd62..b23ddb70 100644 --- a/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/rate-limiter.ts +++ b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/rate-limiter.ts @@ -2,10 +2,12 @@ import express from 'express' import { RateLimiterMemory } from 'rate-limiter-flexible' import { Logger } from 'src/common' -const getClientIp = (req: express.Request): string | undefined => { +const getConnectingIp = (req: express.Request): string | undefined => { + // Check Cloudflare headers const cf = req.headers['cf-connecting-ip'] || req.headers['true-client-ip'] if (cf) return Array.isArray(cf) ? cf[0] : cf + // Check X-Forwarded-For const xff = req.headers['x-forwarded-for'] const xffStr = Array.isArray(xff) ? xff.join(',') : xff if (typeof xffStr === 'string') { @@ -13,6 +15,7 @@ const getClientIp = (req: express.Request): string | undefined => { if (ip) return ip } + // Check X-Real-IP const xri = req.headers['x-real-ip'] if (xri) return Array.isArray(xri) ? xri[0] : xri @@ -20,9 +23,29 @@ const getClientIp = (req: express.Request): string | undefined => { } // Middleware factory to create a rate limiting middleware -export const createRateLimiterMiddleware = (logger: Logger) => { +export const createRateLimiterMiddleware = (logger: Logger, userProxyIps: string[] = []) => { const { dbg, warn } = logger.create(`RateLimiter`) dbg(`Creating`) + if (userProxyIps.length > 0) { + dbg(`User proxy IPs: ${userProxyIps.join(', ')}`) + } + + const isUserProxy = (connectingIp: string | undefined): boolean => { + if (!connectingIp) return false + return userProxyIps.includes(connectingIp) + } + + const getClientIp = (req: express.Request): string | undefined => { + const connectingIp = getConnectingIp(req) + + // If from user proxy, check custom header first + if (isUserProxy(connectingIp)) { + const customIp = req.headers['x-pockethost-client-ip'] + if (customIp) return Array.isArray(customIp) ? customIp[0] : customIp + } + + return connectingIp + } const ipRateLimiter = new RateLimiterMemory({ points: 1000, @@ -47,6 +70,10 @@ export const createRateLimiterMiddleware = (logger: Logger) => { return async (req: express.Request, res: express.Response, next: express.NextFunction) => { const ip = getClientIp(req) + if (isUserProxy(ip)) { + dbg(`User Proxy IP detected: ${ip}`, req.headers) + } + if (!ip) { warn(`Could not determine IP address`) res.status(429).send(`IP address not found`) 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 44ef87d4..6edc81c5 100644 --- a/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts +++ b/packages/pockethost/src/cli/commands/FirewallCommand/ServeCommand/firewall/server.ts @@ -6,6 +6,7 @@ import { Logger, MOTHERSHIP_NAME, MOTHERSHIP_PORT, + PH_USER_PROXY_IPS, SSL_CERT, SSL_KEY, } from '@' @@ -83,7 +84,7 @@ export const firewall = async ({ logger }: FirewallOptions) => { // Use the IP blocker middleware app.use(createIpWhitelistMiddleware(IPCIDR_LIST())) - app.use(createRateLimiterMiddleware(logger)) + app.use(createRateLimiterMiddleware(logger, PH_USER_PROXY_IPS())) forEach(hostnameRoutes, (target, host) => { app.use(createVhostProxyMiddleware(host, target, IS_DEV(), logger)) diff --git a/packages/pockethost/src/constants.ts b/packages/pockethost/src/constants.ts index 0fe0f558..43621ddb 100644 --- a/packages/pockethost/src/constants.ts +++ b/packages/pockethost/src/constants.ts @@ -64,6 +64,7 @@ export const createSettings = () => ({ APEX_DOMAIN: mkString(_APEX_DOMAIN), IPCIDR_LIST: mkCsvString([]), + PH_USER_PROXY_IPS: mkCsvString([]), DAEMON_PORT: mkNumber(3000), DAEMON_PB_IDLE_TTL: mkNumber(1000 * 5), // 5 seconds PH_CONTAINER_LAUNCH_WARN_MS: mkNumber(200), @@ -164,6 +165,7 @@ export const APP_URL = (...path: string[]) => [settings().APP_URL, path.join(`/` export const APEX_DOMAIN = () => settings().APEX_DOMAIN export const IPCIDR_LIST = () => settings().IPCIDR_LIST +export const PH_USER_PROXY_IPS = () => settings().PH_USER_PROXY_IPS export const DAEMON_PORT = () => settings().DAEMON_PORT export const DAEMON_PB_IDLE_TTL = () => settings().DAEMON_PB_IDLE_TTL export const PH_CONTAINER_LAUNCH_WARN_MS = () => settings().PH_CONTAINER_LAUNCH_WARN_MS @@ -268,6 +270,7 @@ export const logConstants = () => { APP_URL, APEX_DOMAIN, IPCIDR_LIST, + PH_USER_PROXY_IPS, DAEMON_PORT, DAEMON_PB_IDLE_TTL, PH_CONTAINER_LAUNCH_WARN_MS,