enh(pockethost): add user proxy whitelisting

This commit is contained in:
Ben Allfree 2025-10-20 14:07:41 +00:00
parent d558c1e527
commit b03fdc41fe
4 changed files with 52 additions and 3 deletions

View File

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

View File

@ -2,10 +2,12 @@ import express from 'express'
import { RateLimiterMemory } from 'rate-limiter-flexible' import { RateLimiterMemory } from 'rate-limiter-flexible'
import { Logger } from 'src/common' 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'] const cf = req.headers['cf-connecting-ip'] || req.headers['true-client-ip']
if (cf) return Array.isArray(cf) ? cf[0] : cf if (cf) return Array.isArray(cf) ? cf[0] : cf
// Check X-Forwarded-For
const xff = req.headers['x-forwarded-for'] const xff = req.headers['x-forwarded-for']
const xffStr = Array.isArray(xff) ? xff.join(',') : xff const xffStr = Array.isArray(xff) ? xff.join(',') : xff
if (typeof xffStr === 'string') { if (typeof xffStr === 'string') {
@ -13,6 +15,7 @@ const getClientIp = (req: express.Request): string | undefined => {
if (ip) return ip if (ip) return ip
} }
// Check X-Real-IP
const xri = req.headers['x-real-ip'] const xri = req.headers['x-real-ip']
if (xri) return Array.isArray(xri) ? xri[0] : xri 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 // 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`) const { dbg, warn } = logger.create(`RateLimiter`)
dbg(`Creating`) 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({ const ipRateLimiter = new RateLimiterMemory({
points: 1000, points: 1000,
@ -47,6 +70,10 @@ export const createRateLimiterMiddleware = (logger: Logger) => {
return async (req: express.Request, res: express.Response, next: express.NextFunction) => { return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
const ip = getClientIp(req) const ip = getClientIp(req)
if (isUserProxy(ip)) {
dbg(`User Proxy IP detected: ${ip}`, req.headers)
}
if (!ip) { if (!ip) {
warn(`Could not determine IP address`) warn(`Could not determine IP address`)
res.status(429).send(`IP address not found`) res.status(429).send(`IP address not found`)

View File

@ -6,6 +6,7 @@ import {
Logger, Logger,
MOTHERSHIP_NAME, MOTHERSHIP_NAME,
MOTHERSHIP_PORT, MOTHERSHIP_PORT,
PH_USER_PROXY_IPS,
SSL_CERT, SSL_CERT,
SSL_KEY, SSL_KEY,
} from '@' } from '@'
@ -83,7 +84,7 @@ export const firewall = async ({ logger }: FirewallOptions) => {
// Use the IP blocker middleware // Use the IP blocker middleware
app.use(createIpWhitelistMiddleware(IPCIDR_LIST())) app.use(createIpWhitelistMiddleware(IPCIDR_LIST()))
app.use(createRateLimiterMiddleware(logger)) app.use(createRateLimiterMiddleware(logger, PH_USER_PROXY_IPS()))
forEach(hostnameRoutes, (target, host) => { forEach(hostnameRoutes, (target, host) => {
app.use(createVhostProxyMiddleware(host, target, IS_DEV(), logger)) app.use(createVhostProxyMiddleware(host, target, IS_DEV(), logger))

View File

@ -64,6 +64,7 @@ export const createSettings = () => ({
APEX_DOMAIN: mkString(_APEX_DOMAIN), APEX_DOMAIN: mkString(_APEX_DOMAIN),
IPCIDR_LIST: mkCsvString([]), IPCIDR_LIST: mkCsvString([]),
PH_USER_PROXY_IPS: mkCsvString([]),
DAEMON_PORT: mkNumber(3000), DAEMON_PORT: mkNumber(3000),
DAEMON_PB_IDLE_TTL: mkNumber(1000 * 5), // 5 seconds DAEMON_PB_IDLE_TTL: mkNumber(1000 * 5), // 5 seconds
PH_CONTAINER_LAUNCH_WARN_MS: mkNumber(200), 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 APEX_DOMAIN = () => settings().APEX_DOMAIN
export const IPCIDR_LIST = () => settings().IPCIDR_LIST 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_PORT = () => settings().DAEMON_PORT
export const DAEMON_PB_IDLE_TTL = () => settings().DAEMON_PB_IDLE_TTL export const DAEMON_PB_IDLE_TTL = () => settings().DAEMON_PB_IDLE_TTL
export const PH_CONTAINER_LAUNCH_WARN_MS = () => settings().PH_CONTAINER_LAUNCH_WARN_MS export const PH_CONTAINER_LAUNCH_WARN_MS = () => settings().PH_CONTAINER_LAUNCH_WARN_MS
@ -268,6 +270,7 @@ export const logConstants = () => {
APP_URL, APP_URL,
APEX_DOMAIN, APEX_DOMAIN,
IPCIDR_LIST, IPCIDR_LIST,
PH_USER_PROXY_IPS,
DAEMON_PORT, DAEMON_PORT,
DAEMON_PB_IDLE_TTL, DAEMON_PB_IDLE_TTL,
PH_CONTAINER_LAUNCH_WARN_MS, PH_CONTAINER_LAUNCH_WARN_MS,