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, +154 -83Ignore whitespace
default/config.yaml+18 -3
@@ -58,7 +58,7 @@ ssl:
5858# -- SECURITY CONFIGURATION --
5959# Toggle whitelist mode
6060whitelistMode: true
6161# WhitelistWhen enabled, whitelist will also verify IP in X-Forwarded-Forheaders /enabled X-Real-IPin headers`forwardedHeaders` section.
6262enableForwardedWhitelist: true
6363# Whitelist of allowed IP addresses
6464whitelist:
@@ -189,9 +189,24 @@ logging:
189189 minLogLevel: 0
190190# -- RATE LIMITING CONFIGURATION --
191191rateLimiting:
192192 # Use X-Real-IPany headerof insteadthe ofenabled socketheaders 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.
194194 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.
203+forwardedHeaders:
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
196211## BACKUP CONFIGURATION
197212backups:
src/endpoints/users-public.js+19 -14
@@ -3,23 +3,23 @@ import crypto from 'node:crypto';
33import storage from 'node-persist';
44import express from 'express';
55import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
66import { getIpFromRequestgetIpAddress, getRealIpFromHeaderretryAfter } from '../express-common.js';
77import { color, Cache, getConfigValue } from '../util.js';
88import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js';
99
1010const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean');
1111const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
12+const LOGIN_POINTS = getConfigValue('rateLimiting.accountsLoginMaxAttempts', 5, 'number');
13+const RECOVER_POINTS = getConfigValue('rateLimiting.accountsRecoverMaxAttempts', 5, 'number');
1214const MFA_CACHE = new Cache(5 * 60 * 1000);
1315
14-const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15-
1616export const router = express.Router();
1717const loginLimiter = new RateLimiterMemory({
18- points: 5,
18+ points: LOGIN_POINTS > 0 ? LOGIN_POINTS : Number.MAX_SAFE_INTEGER,
1919 duration: 60,
2020});
2121const recoverLimiter = new RateLimiterMemory({
22- points: 5,
22+ points: RECOVER_POINTS > 0 ? RECOVER_POINTS : Number.MAX_SAFE_INTEGER,
2323 duration: 300,
2424});
2525
@@ -63,7 +63,7 @@ router.post('/login', async (request, response) => {
6363 return response.status(400).json({ error: 'Missing required fields' });
6464 }
6565
6666 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
6767 await loginLimiter.consume(ip);
6868
6969 /** @type {import('../users.js').User} */
@@ -95,8 +95,8 @@ router.post('/login', async (request, response) => {
9595 return response.json({ handle: user.handle });
9696 } catch (error) {
9797 if (error instanceof RateLimiterRes) {
9898 console.error('Login failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
9999 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or recover your password.' });
100100 }
101101
102102 console.error('Login failed:', error);
@@ -111,7 +111,7 @@ router.post('/recover-step1', async (request, response) => {
111111 return response.status(400).json({ error: 'Missing required fields' });
112112 }
113113
114114 const ip = getIpAddress(request, PREFER_REAL_IP_HEADER);
115115 await recoverLimiter.consume(ip);
116116
117117 /** @type {import('../users.js').User} */
@@ -135,8 +135,8 @@ router.post('/recover-step1', async (request, response) => {
135135 return response.sendStatus(204);
136136 } catch (error) {
137137 if (error instanceof RateLimiterRes) {
138138 console.error('Recover step 1 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
139139 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
140140 }
141141
142142 console.error('Recover step 1 failed:', error);
@@ -153,7 +153,12 @@ router.post('/recover-step2', async (request, response) => {
153153
154154 /** @type {import('../users.js').User} */
155155 const user = await storage.getItem(toKey(request.body.handle));
156156 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
158163 if (!user) {
159164 console.error('Recover step 2 failed: User', request.body.handle, 'not found');
@@ -189,8 +194,8 @@ router.post('/recover-step2', async (request, response) => {
189194 return response.sendStatus(204);
190195 } catch (error) {
191196 if (error instanceof RateLimiterRes) {
192197 console.error('Recover step 2 failed: Rate limited from', getIpAddress(request, PREFER_REAL_IP_HEADER));
193198 return retryAfter(response, error).status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' });
194199 }
195200
196201 console.error('Recover step 2 failed:', error);
src/express-common.js+53 -7
@@ -1,5 +1,7 @@
11import ipaddr from 'ipaddr.js';
22import ipMatching from 'ip-matching';
3+import { RateLimiterRes } from 'rate-limiter-flexible';
4+import { getConfigValue } from './util.js';
35
46const noopMiddleware = (_req, _res, next) => next();
57/** @deprecated Do not use. A global middleware is provided at the application level. */
@@ -29,17 +31,46 @@ export function getIpFromRequest(req) {
2931}
3032
3133/**
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 object
36+ * @returns {string|undefined} The client IP address
35- * @returns {string} IP address of the client
3637 */
3738export function getRealIpFromHeadergetRealOrForwardedIp(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) {
3945 return req.headers['x-real-ip'].toString();
4046 }
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+ */
70+export function getIpAddress(request, includeHeaderIp) {
71+ const socketIp = getIpFromRequest(request);
72+ const forwardedIp = includeHeaderIp && getRealOrForwardedIp(request);
73+ return forwardedIp ? `${socketIp} (forwarded: ${forwardedIp})` : socketIp;
4374}
4475
4576/**
@@ -79,3 +110,18 @@ export function filterValidIpPatterns(entries, formatLog) {
79110
80111 return validEntries;
81112}
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+ */
120+export 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 @@
11import path from 'node:path';
22import fs from 'node:fs';
33import { getRealIpFromHeadergetIpAddress } from '../express-common.js';
44import { color, getConfigValue } from '../util.js';
55
66const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean');
@@ -32,7 +32,7 @@ export function migrateAccessLog() {
3232 */
3333export default function accessLoggerMiddleware() {
3434 return function (req, res, next) {
3535 const clientIp = getRealIpFromHeadergetIpAddress(req, true);
3636 const userAgent = req.headers['user-agent'];
3737
3838 if (!knownIPs.has(clientIp)) {
src/middleware/basicAuth.js+59 -29
@@ -5,53 +5,83 @@
55import { Buffer } from 'node:buffer';
66import path from 'node:path';
77import storage from 'node-persist';
8+import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
89import { getAllUserHandles, toKey, getPasswordHash } from '../users.js';
910import { getConfigValue, safeReadFileSync } from '../util.js';
11+import { getIpAddress, retryAfter } from '../express-common.js';
1012
1113const PER_USER_BASIC_AUTH = !!getConfigValue('perUserBasicAuth', false, 'boolean');
1214const ENABLE_ACCOUNTS = !!getConfigValue('enableUserAccounts', false, 'boolean');
15+const PREFER_REAL_IP_HEADER = !!getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean');
16+const BASIC_AUTH_ATTEMPTS = getConfigValue('rateLimiting.basicAuthMaxAttempts', 5, 'number');
17+
18+const basicAuthLimiter = new RateLimiterMemory({
19+ points: BASIC_AUTH_ATTEMPTS > 0 ? BASIC_AUTH_ATTEMPTS : Number.MAX_SAFE_INTEGER,
20+ duration: 60,
21+});
1322
1423const basicAuthMiddleware = async function (request, response, callback) {
15- const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
1624 const unauthorizedResponse = (res) => {
25+ const unauthorizedWebpage = safeReadFileSync(path.join(globalThis.DATA_ROOT, '_errors', 'unauthorized.html')) ?? '';
1726 res.set('WWW-Authenticate', 'Basic realm="SillyTavern", charset="UTF-8"');
1827 return res.status(401).send(unauthorizedWebpage);
1928 };
2029
21- const basicAuthUserName = getConfigValue('basicAuthUser.username');
30+ try {
2231 const basicAuthUserPasswordip = getConfigValuegetIpAddress('basicAuthUser.password'request, PREFER_REAL_IP_HEADER);
23- const authHeader = request.headers.authorization;
2432
25- if (!authHeader) {
33+ const basicAuthUserName = getConfigValue('basicAuthUser.username');
26- return unauthorizedResponse(response);
34+ const basicAuthUserPassword = getConfigValue('basicAuthUser.password');
27- }
35+ const authHeader = request.headers.authorization;
36+
37+ if (!authHeader) {
38+ return unauthorizedResponse(response);
39+ }
2840
2941 const [scheme, credentials] = authHeader.split(' ');
3042
3143 if (scheme !== 'Basic' || !credentials) {
3244 return unauthorizedResponse(response);
3345 }
3446
35- const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;
47+ const rateLimit = await basicAuthLimiter.get(ip);
36- const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
48+
37- .toString('utf8')
49+ if (rateLimit !== null && rateLimit.consumedPoints > basicAuthLimiter.points) {
38- .split(':');
50+ throw rateLimit;
39- const password = passwordParts.join(':');
51+ }
4052
41- if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
53+ const usePerUserAuth = PER_USER_BASIC_AUTH && ENABLE_ACCOUNTS;
42- return callback();
54+ const [username, ...passwordParts] = Buffer.from(credentials, 'base64')
43- } else if (usePerUserAuth) {
55+ .toString('utf8')
44- const userHandles = await getAllUserHandles();
56+ .split(':');
45- for (const userHandle of userHandles) {
57+ const password = passwordParts.join(':');
46- if (username === userHandle) {
58+
47- const user = await storage.getItem(toKey(userHandle));
59+ if (!usePerUserAuth && username === basicAuthUserName && password === basicAuthUserPassword) {
48- if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {
60+ await basicAuthLimiter.delete(ip);
4961 return callback();
62+ } else if (usePerUserAuth) {
63+ const userHandles = await getAllUserHandles();
64+ for (const userHandle of userHandles) {
65+ if (username === userHandle) {
66+ const user = await storage.getItem(toKey(userHandle));
67+ if (user && user.enabled && (user.password && user.password === getPasswordHash(password, user.salt))) {
68+ await basicAuthLimiter.delete(ip);
69+ return callback();
70+ }
5071 }
5172 }
5273 }
74+
75+ await basicAuthLimiter.consume(ip);
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);
5384 }
54- return unauthorizedResponse(response);
5585};
5686
5787export default basicAuthMiddleware;
src/middleware/whitelist.js+3 -28
@@ -6,7 +6,7 @@ import Handlebars from 'handlebars';
66import ipMatching from 'ip-matching';
77import isDocker from 'is-docker';
88
99import { filterValidIpPatterns, getIpFromRequest, getRealOrForwardedIp } from '../express-common.js';
1010import { color, getConfigValue, safeReadFileSync } from '../util.js';
1111
1212const whitelistPath = path.join(process.cwd(), './whitelist.txt');
@@ -29,31 +29,6 @@ if (fs.existsSync(whitelistPath)) {
2929whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`);
3030
3131/**
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- */
36-function 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-/**
5732 * Resolves the IP addresses of Docker hostnames and adds them to the whitelist.
5833 * @returns {Promise<void>} Promise that resolves when the Docker hostnames are resolved
5934 */
@@ -92,7 +67,7 @@ export default async function getWhitelistMiddleware() {
9267
9368 return function (req, res, next) {
9469 const clientIp = getIpFromRequest(req);
9570 const forwardedIp = getForwardedIpenableForwardedWhitelist && getRealOrForwardedIp(req);
9671 const userAgent = req.headers['user-agent'];
9772
9873 /**
@@ -107,7 +82,7 @@ export default async function getWhitelistMiddleware() {
10782
10883 //clientIp = req.connection.remoteAddress.split(':').pop();
10984 if (!isIPInWhitelist(whitelist, clientIp)
11085 || (forwardedIp && !isIPInWhitelist(whitelist, forwardedIp))
11186 ) {
11287 // Log the connection attempt with real IP address
11388 const ipDetails = forwardedIp