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>
Signed| @@ -58,7 +58,7 @@ ssl: | |||
| 58 | # -- SECURITY CONFIGURATION -- | 58 | # -- SECURITY CONFIGURATION -- |
| 59 | # Toggle whitelist mode | 59 | # Toggle whitelist mode |
| 60 | whitelistMode: true | 60 | whitelistMode: true |
| 61 | # Whitelist will also verify IP in X-Forwarded-For / X-Real-IP headers | 61 | # When enabled, whitelist will also verify IP in headers enabled in `forwardedHeaders` section. |
| 62 | enableForwardedWhitelist: true | 62 | enableForwardedWhitelist: true |
| 63 | # Whitelist of allowed IP addresses | 63 | # Whitelist of allowed IP addresses |
| 64 | whitelist: | 64 | whitelist: |
| @@ -189,9 +189,24 @@ logging: | |||
| 189 | minLogLevel: 0 | 189 | minLogLevel: 0 |
| 190 | # -- RATE LIMITING CONFIGURATION -- | 190 | # -- RATE LIMITING CONFIGURATION -- |
| 191 | rateLimiting: | 191 | rateLimiting: |
| 192 | # Use X-Real-IP header instead of socket IP for rate limiting | 192 | # 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: false | 194 | 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 | ||
| 195 | 210 | ||
| 196 | ## BACKUP CONFIGURATION | 211 | ## BACKUP CONFIGURATION |
| 197 | backups: | 212 | backups: |
| @@ -3,23 +3,23 @@ import crypto from 'node:crypto'; | |||
| 3 | import storage from 'node-persist'; | 3 | import storage from 'node-persist'; |
| 4 | import express from 'express'; | 4 | import express from 'express'; |
| 5 | import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; | 5 | import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; |
| 6 | import { getIpFromRequest, getRealIpFromHeader } from '../express-common.js'; | 6 | import { getIpAddress, retryAfter } from '../express-common.js'; |
| 7 | import { color, Cache, getConfigValue } from '../util.js'; | 7 | import { color, Cache, getConfigValue } from '../util.js'; |
| 8 | import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js'; | 8 | import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js'; |
| 9 | 9 | ||
| 10 | const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean'); | 10 | const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean'); |
| 11 | const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean'); | 11 | const 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'); | ||
| 12 | const MFA_CACHE = new Cache(5 * 60 * 1000); | 14 | const MFA_CACHE = new Cache(5 * 60 * 1000); |
| 13 | 15 | ||
| 14 | const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request); | ||
| 15 | |||
| 16 | export const router = express.Router(); | 16 | export const router = express.Router(); |
| 17 | const loginLimiter = new RateLimiterMemory({ | 17 | const 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 | }); |
| 21 | const recoverLimiter = new RateLimiterMemory({ | 21 | const 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 | }); |
| 25 | 25 | ||
| @@ -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 | } |
| 65 | 65 | ||
| 66 | const ip = getIpAddress(request); | 66 | const ip = getIpAddress(request, PREFER_REAL_IP_HEADER); |
| 67 | await loginLimiter.consume(ip); | 67 | await loginLimiter.consume(ip); |
| 68 | 68 | ||
| 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 | } |
| 101 | 101 | ||
| 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 | } |
| 113 | 113 | ||
| 114 | const ip = getIpAddress(request); | 114 | const ip = getIpAddress(request, PREFER_REAL_IP_HEADER); |
| 115 | await recoverLimiter.consume(ip); | 115 | await recoverLimiter.consume(ip); |
| 116 | 116 | ||
| 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 | } |
| 141 | 141 | ||
| 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) => { | |||
| 153 | 153 | ||
| 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 | } | ||
| 157 | 162 | ||
| 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 | } |
| 195 | 200 | ||
| 196 | console.error('Recover step 2 failed:', error); | 201 | console.error('Recover step 2 failed:', error); |
| @@ -1,5 +1,7 @@ | |||
| 1 | import ipaddr from 'ipaddr.js'; | 1 | import ipaddr from 'ipaddr.js'; |
| 2 | import ipMatching from 'ip-matching'; | 2 | import ipMatching from 'ip-matching'; |
| 3 | import { RateLimiterRes } from 'rate-limiter-flexible'; | ||
| 4 | import { getConfigValue } from './util.js'; | ||
| 3 | 5 | ||
| 4 | const noopMiddleware = (_req, _res, next) => next(); | 6 | const 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 | } |
| 30 | 32 | ||
| 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 object | 36 | * @returns {string|undefined} The client IP address |
| 35 | * @returns {string} IP address of the client | ||
| 36 | */ | 37 | */ |
| 37 | export function getRealIpFromHeader(req) { | 38 | export 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 | } |
| 41 | 47 | ||
| 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; | ||
| 43 | } | 74 | } |
| 44 | 75 | ||
| 45 | /** | 76 | /** |
| @@ -79,3 +110,18 @@ export function filterValidIpPatterns(entries, formatLog) { | |||
| 79 | 110 | ||
| 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 | */ | ||
| 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 | } | ||
| @@ -1,6 +1,6 @@ | |||
| 1 | import path from 'node:path'; | 1 | import path from 'node:path'; |
| 2 | import fs from 'node:fs'; | 2 | import fs from 'node:fs'; |
| 3 | import { getRealIpFromHeader } from '../express-common.js'; | 3 | import { getIpAddress } from '../express-common.js'; |
| 4 | import { color, getConfigValue } from '../util.js'; | 4 | import { color, getConfigValue } from '../util.js'; |
| 5 | 5 | ||
| 6 | const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean'); | 6 | const enableAccessLog = getConfigValue('logging.enableAccessLog', true, 'boolean'); |
| @@ -32,7 +32,7 @@ export function migrateAccessLog() { | |||
| 32 | */ | 32 | */ |
| 33 | export default function accessLoggerMiddleware() { | 33 | export 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']; |
| 37 | 37 | ||
| 38 | if (!knownIPs.has(clientIp)) { | 38 | if (!knownIPs.has(clientIp)) { |
| @@ -5,53 +5,83 @@ | |||
| 5 | import { Buffer } from 'node:buffer'; | 5 | import { Buffer } from 'node:buffer'; |
| 6 | import path from 'node:path'; | 6 | import path from 'node:path'; |
| 7 | import storage from 'node-persist'; | 7 | import storage from 'node-persist'; |
| 8 | import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; | ||
| 8 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; | 9 | import { getAllUserHandles, toKey, getPasswordHash } from '../users.js'; |
| 9 | import { getConfigValue, safeReadFileSync } from '../util.js'; | 10 | import { getConfigValue, safeReadFileSync } from '../util.js'; |
| 11 | import { getIpAddress, retryAfter } from '../express-common.js'; | ||
| 10 | 12 | ||
| 11 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean'); | 13 | const PER_USER_BASIC_AUTH = !!getConfigValue('perUserBasicAuth', false, 'boolean'); |
| 12 | const ENABLE_ACCOUNTS = getConfigValue('enableUserAccounts', false, 'boolean'); | 14 | const 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 | }); | ||
| 13 | 22 | ||
| 14 | const basicAuthMiddleware = async function (request, response, callback) { | 23 | const 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 | }; |
| 20 | 29 | ||
| 21 | const basicAuthUserName = getConfigValue('basicAuthUser.username'); | 30 | try { |
| 22 | const basicAuthUserPassword = getConfigValue('basicAuthUser.password'); | 31 | const ip = getIpAddress(request, PREFER_REAL_IP_HEADER); |
| 23 | const authHeader = request.headers.authorization; | ||
| 24 | 32 | ||
| 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 | } | ||
| 28 | 40 | ||
| 29 | const [scheme, credentials] = authHeader.split(' '); | 41 | const [scheme, credentials] = authHeader.split(' '); |
| 30 | 42 | ||
| 31 | if (scheme !== 'Basic' || !credentials) { | 43 | if (scheme !== 'Basic' || !credentials) { |
| 32 | return unauthorizedResponse(response); | 44 | return unauthorizedResponse(response); |
| 33 | } | 45 | } |
| 34 | 46 | ||
| 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 | } |
| 40 | 52 | ||
| 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); |
| 49 | return callback(); | 61 | 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 | } | ||
| 50 | } | 71 | } |
| 51 | } | 72 | } |
| 52 | } | 73 | } |
| 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); | ||
| 53 | } | 84 | } |
| 54 | return unauthorizedResponse(response); | ||
| 55 | }; | 85 | }; |
| 56 | 86 | ||
| 57 | export default basicAuthMiddleware; | 87 | export default basicAuthMiddleware; |
| @@ -6,7 +6,7 @@ import Handlebars from 'handlebars'; | |||
| 6 | import ipMatching from 'ip-matching'; | 6 | import ipMatching from 'ip-matching'; |
| 7 | import isDocker from 'is-docker'; | 7 | import isDocker from 'is-docker'; |
| 8 | 8 | ||
| 9 | import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js'; | 9 | import { filterValidIpPatterns, getIpFromRequest, getRealOrForwardedIp } from '../express-common.js'; |
| 10 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; | 10 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 11 | 11 | ||
| 12 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); | 12 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| @@ -29,31 +29,6 @@ if (fs.existsSync(whitelistPath)) { | |||
| 29 | whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`); | 29 | whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`); |
| 30 | 30 | ||
| 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 | */ | ||
| 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 | /** | ||
| 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 resolved | 33 | * @returns {Promise<void>} Promise that resolves when the Docker hostnames are resolved |
| 59 | */ | 34 | */ |
| @@ -92,7 +67,7 @@ export default async function getWhitelistMiddleware() { | |||
| 92 | 67 | ||
| 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']; |
| 97 | 72 | ||
| 98 | /** | 73 | /** |
| @@ -107,7 +82,7 @@ export default async function getWhitelistMiddleware() { | |||
| 107 | 82 | ||
| 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 address | 87 | // Log the connection attempt with real IP address |
| 113 | const ipDetails = forwardedIp | 88 | const ipDetails = forwardedIp |