Add IP whitelist for SSO authentication headers (#5404) * feat: add trusted proxies configuration for SSO authentication * Refactor check to accept IP address directly * Refactor IP patterns validation * Unify warning message format

d96d1451ab468e82c1a6046bf4c6ea95c17ecdb8

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

Signed
4 files changed, +88 -23Showing whitespace changes
default/config.yaml+7 -0
@@ -127,6 +127,13 @@ sso:
127 # as that used for authentik. (Ensure the username in authentik127 # as that used for authentik. (Ensure the username in authentik
128 # is an exact match in lowercase with that in sillytavern).128 # is an exact match in lowercase with that in sillytavern).
129 authentikAuth: false129 authentikAuth: false
130 # List of trusted proxy IPs for SSO authentication.
131 # Supports wildcards or CIDR notation for subnets.
132 # Example: ['127.0.0.1', '192.168.1.1']
133 # Set to ['*'] to trust all proxies (NOT RECOMMENDED unless you have other security measures in place)
134 trustedProxies:
135 - ::1
136 - 127.0.0.1
130137
131# Host whitelist configuration. Recommended if you're using a listen mode138# Host whitelist configuration. Recommended if you're using a listen mode
132hostWhitelist:139hostWhitelist:
src/express-common.js+29 -0
@@ -1,4 +1,5 @@
1import ipaddr from 'ipaddr.js';1import ipaddr from 'ipaddr.js';
2import ipMatching from 'ip-matching';
23
3const noopMiddleware = (_req, _res, next) => next();4const noopMiddleware = (_req, _res, next) => next();
4/** @deprecated Do not use. A global middleware is provided at the application level. */5/** @deprecated Do not use. A global middleware is provided at the application level. */
@@ -50,3 +51,31 @@ export function isFirefox(req) {
50 const userAgent = req.headers['user-agent'] || '';51 const userAgent = req.headers['user-agent'] || '';
51 return /firefox/i.test(userAgent);52 return /firefox/i.test(userAgent);
52}53}
54
55/**
56 * Filters and validates IP patterns.
57 * @param {string[]} entries - The list of IP patterns to validate
58 * @param {(entry: string, message: string) => string} formatLog - The function to format the warning message for invalid entries
59 * @returns {string[]} The list of valid IP patterns
60 */
61export function filterValidIpPatterns(entries, formatLog) {
62 const validEntries = [];
63
64 if (!Array.isArray(entries)) {
65 return validEntries;
66 }
67
68 for (const entry of entries) {
69 try {
70 // This will throw if the entry is not a valid IP or CIDR
71 ipMatching.getMatch(entry);
72 validEntries.push(entry);
73 } catch (e) {
74 if (typeof formatLog === 'function') {
75 console.warn(formatLog(entry, e?.message || 'Unknown error'));
76 }
77 }
78 }
79
80 return validEntries;
81}
src/middleware/whitelist.js+4 -23
@@ -6,7 +6,7 @@ import Handlebars from 'handlebars';
6import ipMatching from 'ip-matching';6import ipMatching from 'ip-matching';
7import isDocker from 'is-docker';7import isDocker from 'is-docker';
88
9import { getIpFromRequest } from '../express-common.js';9import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js';
10import { color, getConfigValue, safeReadFileSync } from '../util.js';10import { color, getConfigValue, safeReadFileSync } from '../util.js';
1111
12const whitelistPath = path.join(process.cwd(), './whitelist.txt');12const whitelistPath = path.join(process.cwd(), './whitelist.txt');
@@ -16,6 +16,8 @@ const whitelistDockerHosts = !!getConfigValue('whitelistDockerHosts', true, 'boo
16let whitelist = getConfigValue('whitelist', []);16let whitelist = getConfigValue('whitelist', []);
1717
18if (fs.existsSync(whitelistPath)) {18if (fs.existsSync(whitelistPath)) {
19 console.warn(color.yellow('whitelist.txt is deprecated and will be removed in a future release.'));
20 console.warn(color.yellow('Please migrate its contents to the whitelist field in config.yaml. See the documentation for more details.'));
19 try {21 try {
20 let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8');22 let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8');
21 whitelist = whitelistTxt.split('\n').filter(ip => ip).map(ip => ip.trim());23 whitelist = whitelistTxt.split('\n').filter(ip => ip).map(ip => ip.trim());
@@ -24,28 +26,7 @@ if (fs.existsSync(whitelistPath)) {
24 }26 }
25}27}
2628
27/**29whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`);
28 * Validates and filters the whitelist, removing any invalid entries.
29 * @param {string[]} entries - The whitelist entries to validate
30 * @returns {string[]} The filtered list of valid whitelist entries
31 */
32function validateWhitelist(entries) {
33 const validEntries = [];
34
35 for (const entry of entries) {
36 try {
37 // This will throw if the entry is not a valid IP or CIDR
38 ipMatching.getMatch(entry);
39 validEntries.push(entry);
40 } catch (e) {
41 console.warn(`Whitelist ${color.red('Warning')}: Ignoring invalid entry ${color.yellow(entry)} - ${e.message}`);
42 }
43 }
44
45 return validEntries;
46}
47
48whitelist = validateWhitelist(whitelist);
4930
50/**31/**
51 * Get the client IP address from the request headers.32 * Get the client IP address from the request headers.
src/users.js+48 -0
@@ -14,12 +14,14 @@ import archiver from 'archiver';
14import _ from 'lodash';14import _ from 'lodash';
15import { sync as writeFileAtomicSync } from 'write-file-atomic';15import { sync as writeFileAtomicSync } from 'write-file-atomic';
16import sanitize from 'sanitize-filename';16import sanitize from 'sanitize-filename';
17import ipMatching from 'ip-matching';
1718
18import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';19import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js';
19import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js';20import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js';
20import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';21import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js';
21import { getContentOfType } from './endpoints/content-manager.js';22import { getContentOfType } from './endpoints/content-manager.js';
22import { serverDirectory } from './server-directory.js';23import { serverDirectory } from './server-directory.js';
24import { filterValidIpPatterns, getIpFromRequest } from './express-common.js';
2325
24export const KEY_PREFIX = 'user:';26export const KEY_PREFIX = 'user:';
25const AVATAR_PREFIX = 'avatar:';27const AVATAR_PREFIX = 'avatar:';
@@ -28,6 +30,7 @@ const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean');
28const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean');30const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean');
29const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');31const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean');
30const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');32const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64');
33const TRUSTED_PROXIES = filterValidIpPatterns(getConfigValue('sso.trustedProxies', ['127.0.0.1', '::1']) ?? [], (entry, message) => `${color.red('Warning')}: Ignoring invalid sso.trustedProxies entry ${color.yellow(entry)} - ${message}`);
3134
32/**35/**
33 * Cache for user directories.36 * Cache for user directories.
@@ -812,6 +815,44 @@ async function authentikUserLogin(request) {
812}815}
813816
814/**817/**
818 * Check if the request can authenticate SSO users based on the trusted proxies configuration and the request's IP address.
819 * @param {string} ip The IP address of the request
820 * @return {boolean} If the request is from a trusted proxy based on the configuration
821 */
822function isRequestFromTrustedProxy(ip) {
823 if (!Array.isArray(TRUSTED_PROXIES)) {
824 console.warn(color.yellow('sso.trustedProxies is not an array. Please check your config.yaml. SSO auto-login will not work.'));
825 return false;
826 }
827
828 // Bypass magic value check if the user explicitly configured
829 if (TRUSTED_PROXIES.length === 1 && TRUSTED_PROXIES[0] === '*') {
830 console.warn(color.yellow('sso.trustedProxies is set to accept all IPs. This is not recommended for production environments.'));
831 return true;
832 }
833
834 // If the IP is missing or unknown, we can't trust it
835 if (!ip || ip === 'unknown') {
836 return false;
837 }
838
839 // At least one entry in the trusted proxies list must match the request IP for it to be considered trusted
840 for (const entry of TRUSTED_PROXIES) {
841 try {
842 // This will throw if the entry is not a valid IP or CIDR
843 const match = ipMatching.getMatch(entry);
844 if (ipMatching.matches(ip, match)) {
845 return true;
846 }
847 } catch (e) {
848 continue;
849 }
850 }
851
852 return false;
853}
854
855/**
815 * Tries auto-login with a given header.856 * Tries auto-login with a given header.
816 * @param {import('express').Request} request Request object857 * @param {import('express').Request} request Request object
817 * @param {string} [header='Remote-User'] The header to use for the trusted user858 * @param {string} [header='Remote-User'] The header to use for the trusted user
@@ -828,6 +869,13 @@ async function headerUserLogin(request, header = 'Remote-User') {
828 }869 }
829 console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`);870 console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`);
830871
872 const ip = getIpFromRequest(request);
873 const isTrusted = isRequestFromTrustedProxy(ip);
874 if (!isTrusted) {
875 console.warn(color.yellow(`Received ${header} header from untrusted IP ${ip}. Ignoring for auto-login.`));
876 return false;
877 }
878
831 const userHandles = await getAllUserHandles();879 const userHandles = await getAllUserHandles();
832 for (const userHandle of userHandles) {880 for (const userHandle of userHandles) {
833 if (remoteUser.toLowerCase() === userHandle) {881 if (remoteUser.toLowerCase() === userHandle) {