Merge branch 'staging' into immutable-config
| @@ -83,6 +83,11 @@ sessionTimeout: -1 | |||
| 83 | disableCsrfProtection: false | 83 | disableCsrfProtection: false |
| 84 | # Disable startup security checks - NOT RECOMMENDED | 84 | # Disable startup security checks - NOT RECOMMENDED |
| 85 | securityOverride: false | 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 | # -- ADVANCED CONFIGURATION -- | 91 | # -- ADVANCED CONFIGURATION -- |
| 87 | # Open the browser automatically | 92 | # Open the browser automatically |
| 88 | autorun: true | 93 | autorun: true |
| @@ -3,13 +3,16 @@ 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 { jsonParser, getIpFromRequest } from '../express-common.js'; | 6 | import { jsonParser, getIpFromRequest, getRealIpFromHeader } 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 MFA_CACHE = new Cache(5 * 60 * 1000); | 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 | export const router = express.Router(); | 16 | export const router = express.Router(); |
| 14 | const loginLimiter = new RateLimiterMemory({ | 17 | const loginLimiter = new RateLimiterMemory({ |
| 15 | points: 5, | 18 | points: 5, |
| @@ -60,7 +63,7 @@ router.post('/login', jsonParser, async (request, response) => { | |||
| 60 | return response.status(400).json({ error: 'Missing required fields' }); | 63 | return response.status(400).json({ error: 'Missing required fields' }); |
| 61 | } | 64 | } |
| 62 | 65 | ||
| 63 | const ip = getIpFromRequest(request); | 66 | const ip = getIpAddress(request); |
| 64 | await loginLimiter.consume(ip); | 67 | await loginLimiter.consume(ip); |
| 65 | 68 | ||
| 66 | /** @type {import('../users.js').User} */ | 69 | /** @type {import('../users.js').User} */ |
| @@ -92,7 +95,7 @@ router.post('/login', jsonParser, async (request, response) => { | |||
| 92 | return response.json({ handle: user.handle }); | 95 | return response.json({ handle: user.handle }); |
| 93 | } catch (error) { | 96 | } catch (error) { |
| 94 | if (error instanceof RateLimiterRes) { | 97 | if (error instanceof RateLimiterRes) { |
| 95 | console.error('Login failed: Rate limited from', getIpFromRequest(request)); | 98 | console.error('Login failed: Rate limited from', getIpAddress(request)); |
| 96 | return response.status(429).send({ error: 'Too many attempts. Try again later or recover your password.' }); | 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 | return response.status(400).json({ error: 'Missing required fields' }); | 111 | return response.status(400).json({ error: 'Missing required fields' }); |
| 109 | } | 112 | } |
| 110 | 113 | ||
| 111 | const ip = getIpFromRequest(request); | 114 | const ip = getIpAddress(request); |
| 112 | await recoverLimiter.consume(ip); | 115 | await recoverLimiter.consume(ip); |
| 113 | 116 | ||
| 114 | /** @type {import('../users.js').User} */ | 117 | /** @type {import('../users.js').User} */ |
| @@ -132,7 +135,7 @@ router.post('/recover-step1', jsonParser, async (request, response) => { | |||
| 132 | return response.sendStatus(204); | 135 | return response.sendStatus(204); |
| 133 | } catch (error) { | 136 | } catch (error) { |
| 134 | if (error instanceof RateLimiterRes) { | 137 | if (error instanceof RateLimiterRes) { |
| 135 | console.error('Recover step 1 failed: Rate limited from', getIpFromRequest(request)); | 138 | console.error('Recover step 1 failed: Rate limited from', getIpAddress(request)); |
| 136 | return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); | 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 | /** @type {import('../users.js').User} */ | 154 | /** @type {import('../users.js').User} */ |
| 152 | const user = await storage.getItem(toKey(request.body.handle)); | 155 | const user = await storage.getItem(toKey(request.body.handle)); |
| 153 | const ip = getIpFromRequest(request); | 156 | const ip = getIpAddress(request); |
| 154 | 157 | ||
| 155 | if (!user) { | 158 | if (!user) { |
| 156 | console.error('Recover step 2 failed: User', request.body.handle, 'not found'); | 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 | return response.sendStatus(204); | 189 | return response.sendStatus(204); |
| 187 | } catch (error) { | 190 | } catch (error) { |
| 188 | if (error instanceof RateLimiterRes) { | 191 | if (error instanceof RateLimiterRes) { |
| 189 | console.error('Recover step 2 failed: Rate limited from', getIpFromRequest(request)); | 192 | console.error('Recover step 2 failed: Rate limited from', getIpAddress(request)); |
| 190 | return response.status(429).send({ error: 'Too many attempts. Try again later or contact your admin.' }); | 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 | return clientIp; | 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 | } | ||