From d96d1451ab468e82c1a6046bf4c6ea95c17ecdb8 Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 5 Apr 2026 22:20:39 +0300 Subject: [PATCH] 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 --- default/config.yaml | 7 ++++++ src/express-common.js | 29 ++++++++++++++++++++++ src/middleware/whitelist.js | 27 ++++----------------- src/users.js | 48 +++++++++++++++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/default/config.yaml b/default/config.yaml index 817aebcd8..bfa79c343 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -127,6 +127,13 @@ sso: # as that used for authentik. (Ensure the username in authentik # is an exact match in lowercase with that in sillytavern). authentikAuth: false + # List of trusted proxy IPs for SSO authentication. + # Supports wildcards or CIDR notation for subnets. + # Example: ['127.0.0.1', '192.168.1.1'] + # Set to ['*'] to trust all proxies (NOT RECOMMENDED unless you have other security measures in place) + trustedProxies: + - ::1 + - 127.0.0.1 # Host whitelist configuration. Recommended if you're using a listen mode hostWhitelist: diff --git a/src/express-common.js b/src/express-common.js index 744ac4da8..df74cc7b8 100644 --- a/src/express-common.js +++ b/src/express-common.js @@ -1,4 +1,5 @@ import ipaddr from 'ipaddr.js'; +import ipMatching from 'ip-matching'; const noopMiddleware = (_req, _res, next) => next(); /** @deprecated Do not use. A global middleware is provided at the application level. */ @@ -50,3 +51,31 @@ export function isFirefox(req) { const userAgent = req.headers['user-agent'] || ''; return /firefox/i.test(userAgent); } + +/** + * Filters and validates IP patterns. + * @param {string[]} entries - The list of IP patterns to validate + * @param {(entry: string, message: string) => string} formatLog - The function to format the warning message for invalid entries + * @returns {string[]} The list of valid IP patterns + */ +export function filterValidIpPatterns(entries, formatLog) { + const validEntries = []; + + if (!Array.isArray(entries)) { + return validEntries; + } + + for (const entry of entries) { + try { + // This will throw if the entry is not a valid IP or CIDR + ipMatching.getMatch(entry); + validEntries.push(entry); + } catch (e) { + if (typeof formatLog === 'function') { + console.warn(formatLog(entry, e?.message || 'Unknown error')); + } + } + } + + return validEntries; +} diff --git a/src/middleware/whitelist.js b/src/middleware/whitelist.js index 3934a0dc5..98a9aa473 100644 --- a/src/middleware/whitelist.js +++ b/src/middleware/whitelist.js @@ -6,7 +6,7 @@ import Handlebars from 'handlebars'; import ipMatching from 'ip-matching'; import isDocker from 'is-docker'; -import { getIpFromRequest } from '../express-common.js'; +import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js'; import { color, getConfigValue, safeReadFileSync } from '../util.js'; const whitelistPath = path.join(process.cwd(), './whitelist.txt'); @@ -16,6 +16,8 @@ const whitelistDockerHosts = !!getConfigValue('whitelistDockerHosts', true, 'boo let whitelist = getConfigValue('whitelist', []); if (fs.existsSync(whitelistPath)) { + console.warn(color.yellow('whitelist.txt is deprecated and will be removed in a future release.')); + console.warn(color.yellow('Please migrate its contents to the whitelist field in config.yaml. See the documentation for more details.')); try { let whitelistTxt = fs.readFileSync(whitelistPath, 'utf-8'); whitelist = whitelistTxt.split('\n').filter(ip => ip).map(ip => ip.trim()); @@ -24,28 +26,7 @@ if (fs.existsSync(whitelistPath)) { } } -/** - * Validates and filters the whitelist, removing any invalid entries. - * @param {string[]} entries - The whitelist entries to validate - * @returns {string[]} The filtered list of valid whitelist entries - */ -function validateWhitelist(entries) { - const validEntries = []; - - for (const entry of entries) { - try { - // This will throw if the entry is not a valid IP or CIDR - ipMatching.getMatch(entry); - validEntries.push(entry); - } catch (e) { - console.warn(`Whitelist ${color.red('Warning')}: Ignoring invalid entry ${color.yellow(entry)} - ${e.message}`); - } - } - - return validEntries; -} - -whitelist = validateWhitelist(whitelist); +whitelist = filterValidIpPatterns(whitelist, (entry, message) => `${color.red('Warning')}: Ignoring invalid whitelist entry ${color.yellow(entry)} - ${message}`); /** * Get the client IP address from the request headers. diff --git a/src/users.js b/src/users.js index d32859170..408026431 100644 --- a/src/users.js +++ b/src/users.js @@ -14,12 +14,14 @@ import archiver from 'archiver'; import _ from 'lodash'; import { sync as writeFileAtomicSync } from 'write-file-atomic'; import sanitize from 'sanitize-filename'; +import ipMatching from 'ip-matching'; import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; import { getContentOfType } from './endpoints/content-manager.js'; import { serverDirectory } from './server-directory.js'; +import { filterValidIpPatterns, getIpFromRequest } from './express-common.js'; export const KEY_PREFIX = 'user:'; const AVATAR_PREFIX = 'avatar:'; @@ -28,6 +30,7 @@ const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean'); const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean'); const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean'); const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); +const 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}`); /** * Cache for user directories. @@ -811,6 +814,44 @@ async function authentikUserLogin(request) { return headerUserLogin(request, 'X-Authentik-Username'); } +/** + * Check if the request can authenticate SSO users based on the trusted proxies configuration and the request's IP address. + * @param {string} ip The IP address of the request + * @return {boolean} If the request is from a trusted proxy based on the configuration + */ +function isRequestFromTrustedProxy(ip) { + if (!Array.isArray(TRUSTED_PROXIES)) { + console.warn(color.yellow('sso.trustedProxies is not an array. Please check your config.yaml. SSO auto-login will not work.')); + return false; + } + + // Bypass magic value check if the user explicitly configured + if (TRUSTED_PROXIES.length === 1 && TRUSTED_PROXIES[0] === '*') { + console.warn(color.yellow('sso.trustedProxies is set to accept all IPs. This is not recommended for production environments.')); + return true; + } + + // If the IP is missing or unknown, we can't trust it + if (!ip || ip === 'unknown') { + return false; + } + + // At least one entry in the trusted proxies list must match the request IP for it to be considered trusted + for (const entry of TRUSTED_PROXIES) { + try { + // This will throw if the entry is not a valid IP or CIDR + const match = ipMatching.getMatch(entry); + if (ipMatching.matches(ip, match)) { + return true; + } + } catch (e) { + continue; + } + } + + return false; +} + /** * Tries auto-login with a given header. * @param {import('express').Request} request Request object @@ -828,6 +869,13 @@ async function headerUserLogin(request, header = 'Remote-User') { } console.debug(`Attempting auto-login for user from header ${header}: ${remoteUser}`); + const ip = getIpFromRequest(request); + const isTrusted = isRequestFromTrustedProxy(ip); + if (!isTrusted) { + console.warn(color.yellow(`Received ${header} header from untrusted IP ${ip}. Ignoring for auto-login.`)); + return false; + } + const userHandles = await getAllUserHandles(); for (const userHandle of userHandles) { if (remoteUser.toLowerCase() === userHandle) {