Merge branch 'staging' into immutable-config

eb31d7baa2a0d1f0441902961ffb8bdfad6ac27e

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

3 files changed, +29 -7Showing whitespace changes
default/config.yaml+5 -0
@@ -83,6 +83,11 @@ sessionTimeout: -1
83disableCsrfProtection: false83disableCsrfProtection: false
84# Disable startup security checks - NOT RECOMMENDED84# Disable startup security checks - NOT RECOMMENDED
85securityOverride: false85securityOverride: false
86# -- RATE LIMITING CONFIGURATION --
87rateLimiting:
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 automatically92# Open the browser automatically
88autorun: true93autorun: true
src/endpoints/users-public.js+10 -7
@@ -3,13 +3,16 @@ 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 { jsonParser, getIpFromRequest } from '../express-common.js';6import { jsonParser, getIpFromRequest, getRealIpFromHeader } 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 MFA_CACHE = new Cache(5 * 60 * 1000);12const MFA_CACHE = new Cache(5 * 60 * 1000);
1213
14const getIpAddress = (request) => PREFER_REAL_IP_HEADER ? getRealIpFromHeader(request) : getIpFromRequest(request);
15
13export const router = express.Router();16export const router = express.Router();
14const loginLimiter = new RateLimiterMemory({17const 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 }
6265
63 const ip = getIpFromRequest(request);66 const ip = getIpAddress(request);
64 await loginLimiter.consume(ip);67 await loginLimiter.consume(ip);
6568
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 }
98101
@@ -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 }
110113
111 const ip = getIpFromRequest(request);114 const ip = getIpAddress(request);
112 await recoverLimiter.consume(ip);115 await recoverLimiter.consume(ip);
113116
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 }
138141
@@ -150,7 +153,7 @@ router.post('/recover-step2', jsonParser, async (request, response) => {
150153
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);
154157
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 }
192195
src/express-common.js+14 -0
@@ -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 */
35export 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}