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
Signed| @@ -127,6 +127,13 @@ sso: | |||
| 127 | # as that used for authentik. (Ensure the username in authentik | 127 | # 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: false | 129 | 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 | ||
| 130 | 137 | ||
| 131 | # Host whitelist configuration. Recommended if you're using a listen mode | 138 | # Host whitelist configuration. Recommended if you're using a listen mode |
| 132 | hostWhitelist: | 139 | hostWhitelist: |
| @@ -1,4 +1,5 @@ | |||
| 1 | import ipaddr from 'ipaddr.js'; | 1 | import ipaddr from 'ipaddr.js'; |
| 2 | import ipMatching from 'ip-matching'; | ||
| 2 | 3 | ||
| 3 | const noopMiddleware = (_req, _res, next) => next(); | 4 | const 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 | */ | ||
| 61 | export 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 | } | ||
| @@ -6,7 +6,7 @@ import Handlebars from 'handlebars'; | |||
| 6 | import ipMatching from 'ip-matching'; | 6 | import ipMatching from 'ip-matching'; |
| 7 | import isDocker from 'is-docker'; | 7 | import isDocker from 'is-docker'; |
| 8 | 8 | ||
| 9 | import { getIpFromRequest } from '../express-common.js'; | 9 | import { filterValidIpPatterns, getIpFromRequest } from '../express-common.js'; |
| 10 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; | 10 | import { color, getConfigValue, safeReadFileSync } from '../util.js'; |
| 11 | 11 | ||
| 12 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); | 12 | const whitelistPath = path.join(process.cwd(), './whitelist.txt'); |
| @@ -16,6 +16,8 @@ const whitelistDockerHosts = !!getConfigValue('whitelistDockerHosts', true, 'boo | |||
| 16 | let whitelist = getConfigValue('whitelist', []); | 16 | let whitelist = getConfigValue('whitelist', []); |
| 17 | 17 | ||
| 18 | if (fs.existsSync(whitelistPath)) { | 18 | if (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 | } |
| 26 | 28 | ||
| 27 | /** | 29 | whitelist = 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 | */ | ||
| 32 | function 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 | |||
| 48 | whitelist = validateWhitelist(whitelist); | ||
| 49 | 30 | ||
| 50 | /** | 31 | /** |
| 51 | * Get the client IP address from the request headers. | 32 | * Get the client IP address from the request headers. |
| @@ -14,12 +14,14 @@ import archiver from 'archiver'; | |||
| 14 | import _ from 'lodash'; | 14 | import _ from 'lodash'; |
| 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; | 15 | import { sync as writeFileAtomicSync } from 'write-file-atomic'; |
| 16 | import sanitize from 'sanitize-filename'; | 16 | import sanitize from 'sanitize-filename'; |
| 17 | import ipMatching from 'ip-matching'; | ||
| 17 | 18 | ||
| 18 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; | 19 | import { USER_DIRECTORY_TEMPLATE, DEFAULT_USER, PUBLIC_DIRECTORIES, SETTINGS_FILE, UPLOADS_DIRECTORY } from './constants.js'; |
| 19 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; | 20 | import { getConfigValue, color, delay, generateTimestamp, invalidateFirefoxCache, isPathUnderParent, setPermissionsSync } from './util.js'; |
| 20 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; | 21 | import { allowKeysExposure, readSecret, writeSecret, SECRETS_FILE } from './endpoints/secrets.js'; |
| 21 | import { getContentOfType } from './endpoints/content-manager.js'; | 22 | import { getContentOfType } from './endpoints/content-manager.js'; |
| 22 | import { serverDirectory } from './server-directory.js'; | 23 | import { serverDirectory } from './server-directory.js'; |
| 24 | import { filterValidIpPatterns, getIpFromRequest } from './express-common.js'; | ||
| 23 | 25 | ||
| 24 | export const KEY_PREFIX = 'user:'; | 26 | export const KEY_PREFIX = 'user:'; |
| 25 | const AVATAR_PREFIX = 'avatar:'; | 27 | const AVATAR_PREFIX = 'avatar:'; |
| @@ -28,6 +30,7 @@ const AUTHELIA_AUTH = getConfigValue('sso.autheliaAuth', false, 'boolean'); | |||
| 28 | const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean'); | 30 | const AUTHENTIK_AUTH = getConfigValue('sso.authentikAuth', false, 'boolean'); |
| 29 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean'); | 31 | const PER_USER_BASIC_AUTH = getConfigValue('perUserBasicAuth', false, 'boolean'); |
| 30 | const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); | 32 | const ANON_CSRF_SECRET = crypto.randomBytes(64).toString('base64'); |
| 33 | 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}`); | ||
| 31 | 34 | ||
| 32 | /** | 35 | /** |
| 33 | * Cache for user directories. | 36 | * Cache for user directories. |
| @@ -812,6 +815,44 @@ async function authentikUserLogin(request) { | |||
| 812 | } | 815 | } |
| 813 | 816 | ||
| 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 | */ | ||
| 822 | function 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 object | 857 | * @param {import('express').Request} request Request object |
| 817 | * @param {string} [header='Remote-User'] The header to use for the trusted user | 858 | * @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}`); |
| 830 | 871 | ||
| 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) { |