mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
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 7207506240
.
* 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
This commit is contained in:
@@ -85,6 +85,11 @@ cookieSecret: ''
|
|||||||
disableCsrfProtection: false
|
disableCsrfProtection: false
|
||||||
# Disable startup security checks - NOT RECOMMENDED
|
# Disable startup security checks - NOT RECOMMENDED
|
||||||
securityOverride: false
|
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 --
|
# -- ADVANCED CONFIGURATION --
|
||||||
# Open the browser automatically
|
# Open the browser automatically
|
||||||
autorun: true
|
autorun: true
|
||||||
|
@@ -3,13 +3,16 @@ import crypto from 'node:crypto';
|
|||||||
import storage from 'node-persist';
|
import storage from 'node-persist';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
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 { color, Cache, getConfigValue } from '../util.js';
|
||||||
import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
|
import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
|
||||||
|
|
||||||
const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
|
const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false);
|
||||||
|
const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false);
|
||||||
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
const MFA_CACHE = new Cache(5 * 60 * 1000);
|
||||||
|
|
||||||
|
const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
|
||||||
|
|
||||||
export const router = express.Router();
|
export const router = express.Router();
|
||||||
const loginLimiter = new RateLimiterMemory({
|
const loginLimiter = new RateLimiterMemory({
|
||||||
points: 5,
|
points: 5,
|
||||||
@@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => {
|
|||||||
return response.status(400).json({ error: 'Missing required fields' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = getIpFromRequest(request);
|
const ip = getIpAddress(request);
|
||||||
await loginLimiter.consume(ip);
|
await loginLimiter.consume(ip);
|
||||||
|
|
||||||
/** @type {import('../users.js').User} */
|
/** @type {import('../users.js').User} */
|
||||||
@@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => {
|
|||||||
return response.json({ handle: user.handle });
|
return response.json({ handle: user.handle });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RateLimiterRes) {
|
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.' });
|
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' });
|
return response.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const ip = getIpFromRequest(request);
|
const ip = getIpAddress(request);
|
||||||
await recoverLimiter.consume(ip);
|
await recoverLimiter.consume(ip);
|
||||||
|
|
||||||
/** @type {import('../users.js').User} */
|
/** @type {import('../users.js').User} */
|
||||||
@@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => {
|
|||||||
return response.sendStatus(204);
|
return response.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RateLimiterRes) {
|
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.' });
|
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} */
|
/** @type {import('../users.js').User} */
|
||||||
const user = await storage.getItem(toKey(request.body.handle));
|
const user = await storage.getItem(toKey(request.body.handle));
|
||||||
const ip = getIpFromRequest(request);
|
const ip = getIpAddress(request);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.error('Recover step 2 failed: User', request.body.handle, 'not found');
|
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);
|
return response.sendStatus(204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RateLimiterRes) {
|
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.' });
|
return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -25,3 +25,17 @@ export function getIpFromRequest(req) {
|
|||||||
}
|
}
|
||||||
return clientIp;
|
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);
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user