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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user