Merge branch 'staging' into immutable-config
| @@ -83,6 +83,11 @@ sessionTimeout: -1 | ||
| 83 | 83 | disableCsrfProtection: false |
| 84 | 84 | # Disable startup security checks - NOT RECOMMENDED |
| 85 | 85 | securityOverride: false |
| 86 | +# -- RATE LIMITING CONFIGURATION -- | |
| 87 | +rateLimiting: | |
| 88 | + # Use X-Real-IP header instead of socket IP for rate limiting | |
| 89 | + # Only enable this if you are using a properly configured reverse proxy (like Nginx/traefik/Caddy) | |
| 90 | + preferRealIpHeader: false | |
| 86 | 91 | # -- ADVANCED CONFIGURATION -- |
| 87 | 92 | # Open the browser automatically |
| 88 | 93 | autorun: true |
| @@ -3,13 +3,16 @@ import crypto from 'node:crypto'; | ||
| 3 | 3 | import storage from 'node-persist'; |
| 4 | 4 | import express from 'express'; |
| 5 | 5 | import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible'; |
| 6 | 6 | import { jsonParser, getIpFromRequest, getRealIpFromHeader } from '../express-common.js'; |
| 7 | 7 | import { color, Cache, getConfigValue } from '../util.js'; |
| 8 | 8 | import { KEY_PREFIX, getUserAvatar, toKey, getPasswordHash, getPasswordSalt } from '../users.js'; |
| 9 | 9 | |
| 10 | 10 | const DISCREET_LOGIN = getConfigValue('enableDiscreetLogin', false, 'boolean'); |
| 11 | +const PREFER_REAL_IP_HEADER = getConfigValue('rateLimiting.preferRealIpHeader', false, 'boolean'); | |
| 11 | 12 | const MFA_CACHE = new Cache(5 * 60 * 1000); |
| 12 | 13 | |
| 14 | +const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request); | |
| 15 | + | |
| 13 | 16 | export const router = express.Router(); |
| 14 | 17 | const loginLimiter = new RateLimiterMemory({ |
| 15 | 18 | points: 5, |
| @@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => { | ||
| 60 | 63 | return response.status(400).json({ error: 'Missing required fields' }); |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | const ip = getIpFromRequestgetIpAddress(request); |
| 64 | 67 | await loginLimiter.consume(ip); |
| 65 | 68 | |
| 66 | 69 | /** @type {import('../users.js').User} */ |
| @@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => { | ||
| 92 | 95 | return response.json({ handle: user.handle }); |
| 93 | 96 | } catch (error) { |
| 94 | 97 | if (error instanceof RateLimiterRes) { |
| 95 | 98 | console.error('Login failed: Rate limited from', getIpFromRequestgetIpAddress(request)); |
| 96 | 99 | return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' }); |
| 97 | 100 | } |
| 98 | 101 | |
| @@ -108,7 +111,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => { | ||
| 108 | 111 | return response.status(400).json({ error: 'Missing required fields' }); |
| 109 | 112 | } |
| 110 | 113 | |
| 111 | 114 | const ip = getIpFromRequestgetIpAddress(request); |
| 112 | 115 | await recoverLimiter.consume(ip); |
| 113 | 116 | |
| 114 | 117 | /** @type {import('../users.js').User} */ |
| @@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => { | ||
| 132 | 135 | return response.sendStatus(204); |
| 133 | 136 | } catch (error) { |
| 134 | 137 | if (error instanceof RateLimiterRes) { |
| 135 | 138 | console.error('Recover step 1 failed: Rate limited from', getIpFromRequestgetIpAddress(request)); |
| 136 | 139 | return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); |
| 137 | 140 | } |
| 138 | 141 | |
| @@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => { | ||
| 150 | 153 | |
| 151 | 154 | /** @type {import('../users.js').User} */ |
| 152 | 155 | const user = await storage.getItem(toKey(request.body.handle)); |
| 153 | 156 | const ip = getIpFromRequestgetIpAddress(request); |
| 154 | 157 | |
| 155 | 158 | if (!user) { |
| 156 | 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 | 189 | return response.sendStatus(204); |
| 187 | 190 | } catch (error) { |
| 188 | 191 | if (error instanceof RateLimiterRes) { |
| 189 | 192 | console.error('Recover step 2 failed: Rate limited from', getIpFromRequestgetIpAddress(request)); |
| 190 | 193 | return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); |
| 191 | 194 | } |
| 192 | 195 | |
| @@ -25,3 +25,17 @@ export function getIpFromRequest(req) { | ||
| 25 | 25 | } |
| 26 | 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 | + */ | |
| 35 | +export 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 | +} | |