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

3f5b63bba05b48707f2ae7c0a71b71411a55ee5e

KevinSun <30999421+Zhen-Bo@users.noreply.github.com>

Signed
3 files changed, +29 -7Ignore whitespace
default/config.yaml+5 -0
@@ -85,6 +85,11 @@ cookieSecret: ''
85disableCsrfProtection: false85disableCsrfProtection: false
86# Disable startup security checks - NOT RECOMMENDED86# Disable startup security checks - NOT RECOMMENDED
87securityOverride: false87securityOverride: false
88# -- RATE LIMITING CONFIGURATION --
89rateLimiting:
90 # Use X-Real-IP header instead of socket IP for rate limiting
91 # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy)
92 preferRealIpHeader: false
88# -- ADVANCED CONFIGURATION --93# -- ADVANCED CONFIGURATION --
89# Open the browser automatically94# Open the browser automatically
90autorun: true95autorun: true
src/endpoints/users-public.js+10 -7
@@ -3,13 +3,16 @@ import crypto from 'node:crypto';
3import storage from 'node-persist';3import storage from 'node-persist';
4import express from 'express';4import express from 'express';
5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';5import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
6import { jsonParser, getIpFromRequest } from '../express-common.js';6import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-common.js';
7import { color, Cache, getConfigValue } from '../util.js';7import { color, Cache, getConfigValue } from '../util.js';
8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';8import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
99
10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false);
11const MFA_CACHE = new Cache(5 * 60 * 1000);12const MFA_CACHE = new Cache(5 * 60 * 1000);
1213
14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15
13export const router = express.Router();16export const router = express.Router();
14const loginLimiter = new RateLimiterMemory({17const loginLimiter = new RateLimiterMemory({
15 points: 5,18 points: 5,
@@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => {
60 return response.status(400).json({ error: 'Missing required fields' });63 return response.status(400).json({ error: 'Missing required fields' });
61 }64 }
6265
63 const ip = getIpFromRequest(request);66 const ip = getIpAddress(request);
64 await loginLimiter.consume(ip);67 await loginLimiter.consume(ip);
6568
66 /** @type {import('../users.js').User} */69 /** @type {import('../users.js').User} */
@@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => {
92 return response.json({ handle: user.handle });95 return response.json({ handle: user.handle });
93 } catch (error) {96 } catch (error) {
94 if (error instanceof RateLimiterRes) {97 if (error instanceof RateLimiterRes) {
95 console.error('Login failed: Rate limited from', getIpFromRequest(request));98 console.error('Login failed: Rate limited from', getIpAddress(request));
96 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });99 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
97 }100 }
98101
@@ -108,7 +111,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
108 return response.status(400).json({ error: 'Missing required fields' });111 return response.status(400).json({ error: 'Missing required fields' });
109 }112 }
110113
111 const ip = getIpFromRequest(request);114 const ip = getIpAddress(request);
112 await recoverLimiter.consume(ip);115 await recoverLimiter.consume(ip);
113116
114 /** @type {import('../users.js').User} */117 /** @type {import('../users.js').User} */
@@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
132 return response.sendStatus(204);135 return response.sendStatus(204);
133 } catch (error) {136 } catch (error) {
134 if (error instanceof RateLimiterRes) {137 if (error instanceof RateLimiterRes) {
135 console.error('Recover step 1 failed: Rate limited from', getIpFromRequest(request));138 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request));
136 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });139 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
137 }140 }
138141
@@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
150153
151 /** @type {import('../users.js').User} */154 /** @type {import('../users.js').User} */
152 const user = await storage.getItem(toKey(request.body.handle));155 const user = await storage.getItem(toKey(request.body.handle));
153 const ip = getIpFromRequest(request);156 const ip = getIpAddress(request);
154157
155 if (!user) {158 if (!user) {
156 console.error('Recover step 2 failed: User', request.body.handle, 'not found');159 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
@@ -186,7 +189,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
186 return response.sendStatus(204);189 return response.sendStatus(204);
187 } catch (error) {190 } catch (error) {
188 if (error instanceof RateLimiterRes) {191 if (error instanceof RateLimiterRes) {
189 console.error('Recover step 2 failed: Rate limited from', getIpFromRequest(request));192 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request));
190 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });193 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
191 }194 }
192195
src/express-common.js+14 -0
@@ -25,3 +25,17 @@ export function getIpFromRequest(req) {
25 }25 }
26 return clientIp;26 return clientIp;
27}27}
28
29/**
30 * Gets the IP address of the client when behind reverse proxy using x-real-ip header, falls back to socket remote address.
31 * This function should be used when the application is running behind a reverse proxy (e.g., Nginx, traefik, Caddy...).
32 * @param {import('express').Request} req Request object
33 * @returns {string} IP address of the client
34 */
35export function getRealIpFromHeader(req) {
36 if (req.headers['x-real-ip']) {
37 return req.headers['x-real-ip'].toString();
38 }
39
40 return getIpFromRequest(req);
41}