Add rate limit to basic auth middleware (#5504) * feat: add rate limiting to basic auth flow * fix: round up retry-after duration * feat: enhance point consume logic * fix: move unauthorized webpage reading inside response function * refactor: move getIpAddress to express-common * fix: check for rate limit before checking creds * fix: use correct rate limit pattern in /recover-step2 * feat: handle CF forwarded IP header in rate limit, whitelist and access logger * feat: add individual config toggles for forwarded headers * feat: enhance IP address retrieval to include forwarded IP for access logging * chore: clean-up diff * fix: don't consume points for missing credentials * feat: log rate limited method and URL Co-authored-by: Copilot <copilot@github.com> * feat: make rate limiter points configurable Co-authored-by: Copilot <copilot@github.com> * feat: implement retry-after header for rate limiting responses Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>

b2fa6a0afb2bc859bb2f116232f18035cafebb6d

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
6 files changed, +128 -57Showing whitespace changes
default/config.yaml+18 -3
@@ -58,7 +58,7 @@ ssl:
58# -- SECURITY CONFIGURATION --58# -- SECURITY CONFIGURATION --
59# Toggle whitelist mode59# Toggle whitelist mode
60whitelistMode: true60whitelistMode: true
61# Whitelist will also verify IP in X-Forwarded-For / X-Real-IP headers61# When enabled, whitelist will also verify IP in headers enabled in `forwardedHeaders` section.
62enableForwardedWhitelist: true62enableForwardedWhitelist: true
63# Whitelist of allowed IP addresses63# Whitelist of allowed IP addresses
64whitelist:64whitelist:
@@ -189,9 +189,24 @@ logging:
189 minLogLevel: 0189 minLogLevel: 0
190# -- RATE LIMITING CONFIGURATION --190# -- RATE LIMITING CONFIGURATION --
191rateLimiting:191rateLimiting:
192 # Use X-Real-IP header instead of socket IP for rate limiting192 # Use any of the enabled headers in the `forwardedHeaders` section to identify the client IP for rate limiting.
193 # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy)193 # If disabled, only the socket IP will be used, which may not work correctly if you are behind a reverse proxy.
194 preferRealIpHeader: false194 preferRealIpHeader: false
195 # Set the maximum number of allowed failed basic authentication attempts before rate limiting is applied. Set to 0 to disable rate limiting for basic auth.
196 basicAuthMaxAttempts: 5
197 # Set the maximum number of allowed failed account login attempts before rate limiting is applied. Set to 0 to disable rate limiting for account logins.
198 accountsLoginMaxAttempts: 5
199 # Set the maximum number of allowed failed account recovery attempts before rate limiting is applied. Set to 0 to disable rate limiting for account recovery.
200 accountsRecoverMaxAttempts: 5
201# Set to true to enable support for real IPs in certain request headers for features like IP whitelisting, rate limiting and access logging.
202# Only change if you are sure that you use a correctly configured reverse proxy, otherwise this may lead to IP spoofing.
203forwardedHeaders:
204 # X-Real-IP header (common with Nginx and Caddy)
205 xRealIp: true
206 # X-Forwarded-For header (common with many proxies, but may contain multiple IPs - only the first one will be used)
207 xForwardedFor: true
208 # CF-Connecting-IP header (used by Cloudflare Tunnels)
209 cfConnectingIp: false
195210
196## BACKUP CONFIGURATION211## BACKUP CONFIGURATION
197backups:212backups:
src/endpoints/users-public.js+19 -14
@@ -3,23 +3,23 @@ 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 { getIpFromRequest, getRealIpFromHeader } from '../express-common.js';6import { getIpAddress, retryAfter } 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, 'boolean');10const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');11const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
12const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5, 'number');
13const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
12const MFA_CACHE = new Cache(5 * 60 * 1000);14const MFA_CACHE = new Cache(5 * 60 * 1000);
1315
14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15
16export const router = express.Router();16export const router = express.Router();
17const loginLimiter = new RateLimiterMemory({17const loginLimiter = new RateLimiterMemory({
18 points: 5,18 points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
19 duration: 60,19 duration: 60,
20});20});
21const recoverLimiter = new RateLimiterMemory({21const recoverLimiter = new RateLimiterMemory({
22 points: 5,22 points: RECOVER_POINTS > 0 ? RECOVER_POINTS : Number.MAX_SAFE_INTEGER,
23 duration: 300,23 duration: 300,
24});24});
2525
@@ -63,7 +63,7 @@ router.post('/login', async (request, response) => {
63 return response.status(400).json({ error: 'Missing required fields' });63 return response.status(400).json({ error: 'Missing required fields' });
64 }64 }
6565
66 const ip = getIpAddress(request);66 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
67 await loginLimiter.consume(ip);67 await loginLimiter.consume(ip);
6868
69 /** @type {import('../users.js').User} */69 /** @type {import('../users.js').User} */
@@ -95,8 +95,8 @@ router.post('/login', async (request, response) => {
95 return response.json({ handle: user.handle });95 return response.json({ handle: user.handle });
96 } catch (error) {96 } catch (error) {
97 if (error instanceof RateLimiterRes) {97 if (error instanceof RateLimiterRes) {
98 console.error('Login failed: Rate limited from', getIpAddress(request));98 console.error('Login failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
99 return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });99 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
100 }100 }
101101
102 console.error('Login failed:', error);102 console.error('Login failed:', error);
@@ -111,7 +111,7 @@ router.post('/recover-step1', async (request, response) => {
111 return response.status(400).json({ error: 'Missing required fields' });111 return response.status(400).json({ error: 'Missing required fields' });
112 }112 }
113113
114 const ip = getIpAddress(request);114 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
115 await recoverLimiter.consume(ip);115 await recoverLimiter.consume(ip);
116116
117 /** @type {import('../users.js').User} */117 /** @type {import('../users.js').User} */
@@ -135,8 +135,8 @@ router.post('/recover-step1', async (request, response) => {
135 return response.sendStatus(204);135 return response.sendStatus(204);
136 } catch (error) {136 } catch (error) {
137 if (error instanceof RateLimiterRes) {137 if (error instanceof RateLimiterRes) {
138 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request));138 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
139 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });139 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
140 }140 }
141141
142 console.error('Recover step 1 failed:', error);142 console.error('Recover step 1 failed:', error);
@@ -153,7 +153,12 @@ router.post('/recover-step2', async (request, response) => {
153153
154 /** @type {import('../users.js').User} */154 /** @type {import('../users.js').User} */
155 const user = await storage.getItem(toKey(request.body.handle));155 const user = await storage.getItem(toKey(request.body.handle));
156 const ip = getIpAddress(request);156 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
157 const rateLimit = await recoverLimiter.get(ip);
158
159 if (rateLimit !== null && rateLimit.consumedPoints > recoverLimiter.points) {
160 throw rateLimit;
161 }
157162
158 if (!user) {163 if (!user) {
159 console.error('Recover step 2 failed: User', request.body.handle, 'not found');164 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
@@ -189,8 +194,8 @@ router.post('/recover-step2', async (request, response) => {
189 return response.sendStatus(204);194 return response.sendStatus(204);
190 } catch (error) {195 } catch (error) {
191 if (error instanceof RateLimiterRes) {196 if (error instanceof RateLimiterRes) {
192 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request));197 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
193 return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });198 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
194 }199 }
195200
196 console.error('Recover step 2 failed:', error);201 console.error('Recover step 2 failed:', error);
src/express-common.js+53 -7
@@ -1,5 +1,7 @@
1import ipaddr from 'ipaddr.js';1import ipaddr from 'ipaddr.js';
2import ipMatching from 'ip-matching';2import ipMatching from 'ip-matching';
3import { RateLimiterRes } from 'rate-limiter-flexible';
4import { getConfigValue } from './util.js';
35
4const noopMiddleware = (_req, _res, next) => next();6const noopMiddleware = (_req, _res, next) => next();
5/** @deprecated Do not use. A global middleware is provided at the application level. */7/** @deprecated Do not use. A global middleware is provided at the application level. */
@@ -29,17 +31,46 @@ export function getIpFromRequest(req) {
29}31}
3032
31/**33/**
32 * Gets the IP address of the client when behind reverse proxy using x-real-ip header, falls back to socket remote address.34 * Get the client IP address from the request headers.
33 * This function should be used when the application is running behind a reverse proxy (e.g., Nginx, traefik, Caddy...).35 * @param {import('express').Request} req Express request object
34 * @param {import('express').Request} req Request object36 * @returns {string|undefined} The client IP address
35 * @returns {string} IP address of the client
36 */37 */
37export function getRealIpFromHeader(req) {38export function getRealOrForwardedIp(req) {
38 if (req.headers['x-real-ip']) {39 const xRealIpEnabled = !!getConfigValue('forwardedHeaders.xRealIp', true, 'boolean');
40 const cfConnectingIpEnabled = !!getConfigValue('forwardedHeaders.cfConnectingIp', false, 'boolean');
41 const xForwardedForEnabled = !!getConfigValue('forwardedHeaders.xForwardedFor', true, 'boolean');
42
43 // Check if X-Real-IP is available
44 if (req.headers['x-real-ip'] && xRealIpEnabled) {
39 return req.headers['x-real-ip'].toString();45 return req.headers['x-real-ip'].toString();
40 }46 }
4147
42 return getIpFromRequest(req);48 // Check for CF-Connecting-IP (Cloudflare) if available
49 if (req.headers['cf-connecting-ip'] && cfConnectingIpEnabled) {
50 return req.headers['cf-connecting-ip'].toString();
51 }
52
53 // Check for X-Forwarded-For and parse if available
54 if (req.headers['x-forwarded-for'] && xForwardedForEnabled) {
55 const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
56 return ipList[0];
57 }
58
59 // If none of the headers are available, return undefined
60 return undefined;
61}
62
63/**
64 * Gets the IP address of the client, optionally including the real/forwarded IP from headers.
65 * Most common use cases: key for rate limiter, logging, etc. where you want to have the real client IP if behind a reverse proxy.
66 * @param {import('express').Request} request Request object
67 * @param {boolean} includeHeaderIp Whether to include the real/forwarded IP from headers
68 * @returns {string} IP address of the client (will include "forwarded" info if includeHeaderIp is true and headers are present)
69 */
70export function getIpAddress(request, includeHeaderIp) {
71 const socketIp = getIpFromRequest(request);
72 const forwardedIp = includeHeaderIp && getRealOrForwardedIp(request);
73 return forwardedIp ? `${socketIp} (forwarded: ${forwardedIp})` : socketIp;
43}74}
4475
45/**76/**
@@ -79,3 +110,18 @@ export function filterValidIpPatterns(entries, formatLog) {
79110
80 return validEntries;111 return validEntries;
81}112}
113
114/**
115 * Sets the Retry-After header on the response based on the rate limit information.
116 * @param {import('express').Response} response Express response object
117 * @param {RateLimiterRes} rateLimit The rate limit information from rate-limiter-flexible
118 * @returns {import('express').Response} The response object with the Retry-After header set if applicable
119 */
120export function retryAfter(response, rateLimit) {
121 if (response.headersSent || !(rateLimit instanceof RateLimiterRes)) {
122 return response;
123 }
124 const retryAfter = Math.ceil(rateLimit.msBeforeNext / 1000);
125 response.set('Retry-After', retryAfter.toString());
126 return response;
127}
src/middleware/accessLogWriter.js+2 -2
@@ -1,6 +1,6 @@
1import path from 'node:path';1import path from 'node:path';
2import fs from 'node:fs';2import fs from 'node:fs';
3import { getRealIpFromHeader } from '../express-common.js';3import { getIpAddress } from '../express-common.js';
4import { color, getConfigValue } from '../util.js';4import { color, getConfigValue } from '../util.js';
55
6const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean');6const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean');
@@ -32,7 +32,7 @@ export function migrateAccessLog() {
32 */32 */
33export default function accessLoggerMiddleware() {33export default function accessLoggerMiddleware() {
34 return function (req, res, next) {34 return function (req, res, next) {
35 const clientIp = getRealIpFromHeader(req);35 const clientIp = getIpAddress(req, true);
36 const userAgent = req.headers['user-agent'];36 const userAgent = req.headers['user-agent'];
3737
38 if (!knownIPs.has(clientIp)) {38 if (!knownIPs.has(clientIp)) {
src/middleware/basicAuth.js+33 -3
@@ -5,19 +5,31 @@
5import { Buffer } from 'node:buffer';5import { Buffer } from 'node:buffer';
6import path from 'node:path';6import path from 'node:path';
7import storage from 'node-persist';7import storage from 'node-persist';
8import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
8import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';9import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
9import { getConfigValue, safeReadFileSync } from '../util.js';10import { getConfigValue, safeReadFileSync } from '../util.js';
11import { getIpAddress, retryAfter } from '../express-common.js';
1012
11const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');13const PER_USER_BASIC_AUTH = !!getConfigValue('perUserBasicAuth', false, 'boolean');
12const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean');14const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
15const PREFER_REAL_IP_HEADER = !!getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
16const BASIC_AUTH_ATTEMPTS = getConfigValue('rateLimiting.basicAuthMaxAttempts', 5, 'number');
17
18const basicAuthLimiter = new RateLimiterMemory({
19 points: BASIC_AUTH_ATTEMPTS > 0 ? BASIC_AUTH_ATTEMPTS : Number.MAX_SAFE_INTEGER,
20 duration: 60,
21});
1322
14const basicAuthMiddleware = async function (request, response, callback) {23const basicAuthMiddleware = async function (request, response, callback) {
15 const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
16 const unauthorizedResponse = (res) => {24 const unauthorizedResponse = (res) => {
25 const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
17 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');26 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
18 return res.status(401).send(unauthorizedWebpage);27 return res.status(401).send(unauthorizedWebpage);
19 };28 };
2029
30 try {
31 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
32
21 const basicAuthUserName = getConfigValue('basicAuthUser.username');33 const basicAuthUserName = getConfigValue('basicAuthUser.username');
22 const basicAuthUserPassword = getConfigValue('basicAuthUser.password');34 const basicAuthUserPassword = getConfigValue('basicAuthUser.password');
23 const authHeader = request.headers.authorization;35 const authHeader = request.headers.authorization;
@@ -32,6 +44,12 @@ const basicAuthMiddleware = async function (request, response, callback) {
32 return unauthorizedResponse(response);44 return unauthorizedResponse(response);
33 }45 }
3446
47 const rateLimit = await basicAuthLimiter.get(ip);
48
49 if (rateLimit !== null && rateLimit.consumedPoints > basicAuthLimiter.points) {
50 throw rateLimit;
51 }
52
35 const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;53 const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;
36 const [username, ...passwordParts] = Buffer.from(credentials, 'base64')54 const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
37 .toString('utf8')55 .toString('utf8')
@@ -39,6 +57,7 @@ const basicAuthMiddleware = async function (request, response, callback) {
39 const password = passwordParts.join(':');57 const password = passwordParts.join(':');
4058
41 if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {59 if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
60 await basicAuthLimiter.delete(ip);
42 return callback();61 return callback();
43 } else if (usePerUserAuth) {62 } else if (usePerUserAuth) {
44 const userHandles = await getAllUserHandles();63 const userHandles = await getAllUserHandles();
@@ -46,12 +65,23 @@ const basicAuthMiddleware = async function (request, response, callback) {
46 if (username === userHandle) {65 if (username === userHandle) {
47 const user = await storage.getItem(toKey(userHandle));66 const user = await storage.getItem(toKey(userHandle));
48 if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {67 if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {
68 await basicAuthLimiter.delete(ip);
49 return callback();69 return callback();
50 }70 }
51 }71 }
52 }72 }
53 }73 }
74
75 await basicAuthLimiter.consume(ip);
54 return unauthorizedResponse(response);76 return unauthorizedResponse(response);
77 } catch (error) {
78 if (error instanceof RateLimiterRes) {
79 console.error('Basic auth failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER), request.method, request.originalUrl);
80 return retryAfter(response, error).sendStatus(429);
81 }
82 console.error('Basic auth error:', error);
83 return response.sendStatus(500);
84 }
55};85};
5686
57export default basicAuthMiddleware;87export default basicAuthMiddleware;
src/middleware/whitelist.js+3 -28
@@ -6,7 +6,7 @@ import Handlebars from 'handlebars';
6import ipMatching from 'ip-matching';6import ipMatching from 'ip-matching';
7import isDocker from 'is-docker';7import isDocker from 'is-docker';
88
9import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js';9import { filterValidIpPatterns, getIpFromRequest, getRealOrForwardedIp } from '../express-common.js';
10import { color, getConfigValue, safeReadFileSync } from '../util.js';10import { color, getConfigValue, safeReadFileSync } from '../util.js';
1111
12const whitelistPath = path.join(process.cwd(), './whitelist.txt');12const whitelistPath = path.join(process.cwd(), './whitelist.txt');
@@ -29,31 +29,6 @@ if (fs.existsSync(whitelistPath)) {
29whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`);29whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`);
3030
31/**31/**
32 * Get the client IP address from the request headers.
33 * @param {import('express').Request} req Express request object
34 * @returns {string|undefined} The client IP address
35 */
36function getForwardedIp(req) {
37 if (!enableForwardedWhitelist) {
38 return undefined;
39 }
40
41 // Check if X-Real-IP is available
42 if (req.headers['x-real-ip']) {
43 return req.headers['x-real-ip'].toString();
44 }
45
46 // Check for X-Forwarded-For and parse if available
47 if (req.headers['x-forwarded-for']) {
48 const ipList = req.headers['x-forwarded-for'].toString().split(',').map(ip => ip.trim());
49 return ipList[0];
50 }
51
52 // If none of the headers are available, return undefined
53 return undefined;
54}
55
56/**
57 * Resolves the IP addresses of Docker hostnames and adds them to the whitelist.32 * Resolves the IP addresses of Docker hostnames and adds them to the whitelist.
58 * @returns {Promise<void>} Promise that resolves when the Docker hostnames are resolved33 * @returns {Promise<void>} Promise that resolves when the Docker hostnames are resolved
59 */34 */
@@ -92,7 +67,7 @@ export default async function getWhitelistMiddleware() {
9267
93 return function (req, res, next) {68 return function (req, res, next) {
94 const clientIp = getIpFromRequest(req);69 const clientIp = getIpFromRequest(req);
95 const forwardedIp = getForwardedIp(req);70 const forwardedIp = enableForwardedWhitelist && getRealOrForwardedIp(req);
96 const userAgent = req.headers['user-agent'];71 const userAgent = req.headers['user-agent'];
9772
98 /**73 /**
@@ -107,7 +82,7 @@ export default async function getWhitelistMiddleware() {
10782
108 //clientIp = req.connection.remoteAddress.split(':').pop();83 //clientIp = req.connection.remoteAddress.split(':').pop();
109 if (!isIPInWhitelist(whitelist, clientIp)84 if (!isIPInWhitelist(whitelist, clientIp)
110 || forwardedIp && !isIPInWhitelist(whitelist, forwardedIp)85 || (forwardedIp && !isIPInWhitelist(whitelist, forwardedIp))
111 ) {86 ) {
112 // Log the connection attempt with real IP address87 // Log the connection attempt with real IP address
113 const ipDetails = forwardedIp88 const ipDetails = forwardedIp