From 3f5b63bba05b48707f2ae7c0a71b71411a55ee5e Mon Sep 17 00:00:00 2001 From: KevinSun <30999421+Zhen-Bo@users.noreply.github.com> Date: Fri, 21 Feb 2025 03:11:44 +0800 Subject: [PATCH] Feature: Add configurable X-Real-IP header support for rate limiting (#3504) * fix: correct client IP detection behind reverse proxy * Revert "fix: correct client IP detection behind reverse proxy" This reverts commit 72075062402eadb32c9e349df9bc92bfe4546ce3. * feat: support X-Real-IP header for reverse proxy setups * feat: add option to use x-real-ip for rate limiting behind reverse proxy * docs: update rate limiting configuration comments for X-Real-IP usage * refactor: extract getIpAddress function to reduce code duplication * revert(whitelist): rate limit settings shouldn't affect whitelist --- default/config.yaml | 5 +++++ src/endpoints/users-public.js | 17 ++++++++++------- src/express-common.js | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/default/config.yaml b/default/config.yaml index 5d7597a6c..d730bfbdb 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -85,6 +85,11 @@ cookieSecret: '' disableCsrfProtection: false # Disable startup security checks - NOT RECOMMENDED securityOverride: false +# -- RATE LIMITING CONFIGURATION -- +rateLimiting: + # Use X-Real-IP header instead of socket IP for rate limiting + # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy) + preferRealIpHeader: false # -- ADVANCED CONFIGURATION -- # Open the browser automatically autorun: true diff --git a/src/endpoints/users-public.js b/src/endpoints/users-public.js index 8370e5748..2927e9bea 100644 --- a/src/endpoints/users-public.js +++ b/src/endpoints/users-public.js @@ -3,13 +3,16 @@ import crypto from 'node:crypto'; import storage from 'node-persist'; import express from 'express'; import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; -import { jsonParser, getIpFromRequest } from '../express-common.js'; +import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-common.js'; import { color, Cache, getConfigValue } from '../util.js'; import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js'; const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false); +const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false); const MFA_CACHE = new Cache(5 * 60 * 1000); +const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request); + export const router = express.Router(); const loginLimiter = new RateLimiterMemory({ points: 5, @@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => { return response.status(400).json({ error: 'Missing required fields' }); } - const ip = getIpFromRequest(request); + const ip = getIpAddress(request); await loginLimiter.consume(ip); /** @type {import('../users.js').User} */ @@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => { return response.json({ handle: user.handle }); } catch (error) { if (error instanceof RateLimiterRes) { - console.error('Login failed: Rate limited from', getIpFromRequest(request)); + console.error('Login failed: Rate limited from', getIpAddress(request)); return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' }); } @@ -108,7 +111,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => { return response.status(400).json({ error: 'Missing required fields' }); } - const ip = getIpFromRequest(request); + const ip = getIpAddress(request); await recoverLimiter.consume(ip); /** @type {import('../users.js').User} */ @@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => { return response.sendStatus(204); } catch (error) { if (error instanceof RateLimiterRes) { - console.error('Recover step 1 failed: Rate limited from', getIpFromRequest(request)); + console.error('Recover step 1 failed: Rate limited from', getIpAddress(request)); return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); } @@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => { /** @type {import('../users.js').User} */ const user = await storage.getItem(toKey(request.body.handle)); - const ip = getIpFromRequest(request); + const ip = getIpAddress(request); if (!user) { console.error('Recover step 2 failed: User', request.body.handle, 'not found'); @@ -186,7 +189,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => { return response.sendStatus(204); } catch (error) { if (error instanceof RateLimiterRes) { - console.error('Recover step 2 failed: Rate limited from', getIpFromRequest(request)); + console.error('Recover step 2 failed: Rate limited from', getIpAddress(request)); return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); } diff --git a/src/express-common.js b/src/express-common.js index 630d62c59..9717450a1 100644 --- a/src/express-common.js +++ b/src/express-common.js @@ -25,3 +25,17 @@ export function getIpFromRequest(req) { } return clientIp; } + +/** + * Gets the IP address of the client when behind reverse proxy using x-real-ip header, falls back to socket remote address. + * This function should be used when the application is running behind a reverse proxy (e.g., Nginx, traefik, Caddy...). + * @param {import('express').Request} req Request object + * @returns {string} IP address of the client + */ +export function getRealIpFromHeader(req) { + if (req.headers['x-real-ip']) { + return req.headers['x-real-ip'].toString(); + } + + return getIpFromRequest(req); +}