feat(self-hosting): allow opting in to private-address SSO providers (#1655)

This commit is contained in:
Hampus
2026-08-15 22:30:46 +02:00
committed by GitHub
parent cbc0a616ea
commit 578fd61d93
11 changed files with 84 additions and 37 deletions
+7
View File
@@ -39,6 +39,13 @@ [email protected]
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com #FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com #FLUXER_CSP_EXTRA_SCRIPT_SRC=https://analytics.example.com
# Allow the SSO identity provider to resolve to a private or internal address.
# Off by default: the API refuses to call non-public addresses so a misconfigured
# provider URL cannot be used to reach internal services. Turn it on only when the
# provider genuinely lives on your own network, such as split-horizon DNS or a LAN
# identity provider, and only when you trust everyone who can configure SSO.
#FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES=true
LIVEKIT_API_KEY=fluxer LIVEKIT_API_KEY=fluxer
LIVEKIT_API_SECRET=CHANGE_ME LIVEKIT_API_SECRET=CHANGE_ME
+1
View File
@@ -72,6 +72,7 @@ x-fluxer-env: &fluxer-env
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env} FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env} FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-false}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env} FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env} FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}} FLUXER_VAPID_EMAIL: ${FLUXER_VAPID_EMAIL:-admin@${FLUXER_DOMAIN}}
@@ -23,6 +23,7 @@ interface CachedLookupResult {
interface PublicInternetRequestUrlPolicyOptions { interface PublicInternetRequestUrlPolicyOptions {
dnsCacheTtlMs?: number; dnsCacheTtlMs?: number;
lookupHost?: (hostname: string) => Promise<Array<string>>; lookupHost?: (hostname: string) => Promise<Array<string>>;
allowPrivateAddresses?: boolean;
} }
const BLOCKED_IPV4_SUBNETS: Array<BlockedSubnet> = [ const BLOCKED_IPV4_SUBNETS: Array<BlockedSubnet> = [
@@ -175,6 +176,7 @@ export function createPublicInternetRequestUrlPolicy(
? options.dnsCacheTtlMs ? options.dnsCacheTtlMs
: DEFAULT_DNS_CACHE_TTL_MS; : DEFAULT_DNS_CACHE_TTL_MS;
const lookupHost = options?.lookupHost ?? defaultLookupHost; const lookupHost = options?.lookupHost ?? defaultLookupHost;
const allowPrivateAddresses = options?.allowPrivateAddresses === true;
const dnsCache = new Map<string, CachedLookupResult>(); const dnsCache = new Map<string, CachedLookupResult>();
async function resolveHostname(hostname: string): Promise<Array<string>> { async function resolveHostname(hostname: string): Promise<Array<string>> {
const now = Date.now(); const now = Date.now();
@@ -198,7 +200,7 @@ export function createPublicInternetRequestUrlPolicy(
throw createBlockedRequestError(url, context, 'Hostname is empty'); throw createBlockedRequestError(url, context, 'Hostname is empty');
} }
if (isIP(normalizedHostname)) { if (isIP(normalizedHostname)) {
if (isBlockedIpAddress(normalizedHostname)) { if (!allowPrivateAddresses && isBlockedIpAddress(normalizedHostname)) {
throw createBlockedRequestError(url, context, 'IP address is in an internal or special-use range'); throw createBlockedRequestError(url, context, 'IP address is in an internal or special-use range');
} }
return; return;
@@ -210,6 +212,9 @@ export function createPublicInternetRequestUrlPolicy(
if (resolvedAddresses.length === 0) { if (resolvedAddresses.length === 0) {
throw createBlockedRequestError(url, context, 'Hostname resolved to no IP addresses'); throw createBlockedRequestError(url, context, 'Hostname resolved to no IP addresses');
} }
if (allowPrivateAddresses) {
return;
}
for (const address of resolvedAddresses) { for (const address of resolvedAddresses) {
if (isBlockedIpAddress(address)) { if (isBlockedIpAddress(address)) {
throw createBlockedRequestError(url, context, `Hostname resolved to disallowed address ${address}`); throw createBlockedRequestError(url, context, `Hostname resolved to disallowed address ${address}`);
+1
View File
@@ -364,6 +364,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
auth: { auth: {
sudoModeSecret: master.auth.sudo_mode_secret, sudoModeSecret: master.auth.sudo_mode_secret,
connectionInitiationSecret: master.auth.connection_initiation_secret, connectionInitiationSecret: master.auth.connection_initiation_secret,
ssoAllowPrivateAddresses: master.auth.sso_allow_private_addresses,
passkeys: { passkeys: {
rpName: master.auth.passkeys.rp_name, rpName: master.auth.passkeys.rp_name,
rpId: master.auth.passkeys.rp_id, rpId: master.auth.passkeys.rp_id,
+34 -24
View File
@@ -33,6 +33,7 @@ import {
} from '../../instance/InstanceConfigRepository'; } from '../../instance/InstanceConfigRepository';
import { import {
deriveSsoRedirectUri, deriveSsoRedirectUri,
getSsoRequestUrlPolicy,
isTestSsoProvider, isTestSsoProvider,
validateSsoPublicOutboundUrl, validateSsoPublicOutboundUrl,
} from '../../instance/SsoConfigValidation'; } from '../../instance/SsoConfigValidation';
@@ -653,13 +654,16 @@ export class SsoService {
return cached.jwks; return cached.jwks;
} }
const fetchJwks = async (): Promise<JSONWebKeySet> => { const fetchJwks = async (): Promise<JSONWebKeySet> => {
const response = await FetchUtils.sendRequest({ const response = await FetchUtils.sendRequest(
url: jwksUrl, {
method: 'GET', url: jwksUrl,
headers: {Accept: 'application/json'}, method: 'GET',
timeout: ms('5 seconds'), headers: {Accept: 'application/json'},
serviceName: 'sso_jwks', timeout: ms('5 seconds'),
}); serviceName: 'sso_jwks',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`); throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
} }
@@ -746,16 +750,19 @@ export class SsoService {
} }
private async fetchUserInfo(userInfoUrl: string, accessToken: string): Promise<Record<string, unknown>> { private async fetchUserInfo(userInfoUrl: string, accessToken: string): Promise<Record<string, unknown>> {
const resp = await FetchUtils.sendRequest({ const resp = await FetchUtils.sendRequest(
url: userInfoUrl, {
method: 'GET', url: userInfoUrl,
headers: { method: 'GET',
Authorization: `Bearer ${accessToken}`, headers: {
Accept: 'application/json', Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
timeout: ms('15 seconds'),
serviceName: 'sso_user_info',
}, },
timeout: ms('15 seconds'), {requestUrlPolicy: getSsoRequestUrlPolicy()},
serviceName: 'sso_user_info', );
});
if (resp.status < 200 || resp.status >= 300) { if (resp.status < 200 || resp.status >= 300) {
throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO); throw InputValidationError.fromCode('access_token', ValidationErrorCodes.FAILED_TO_FETCH_SSO_USER_INFO);
} }
@@ -807,14 +814,17 @@ export class SsoService {
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}; };
const resp = await FetchUtils.sendRequest({ const resp = await FetchUtils.sendRequest(
url: config.tokenUrl ?? '', {
method: 'POST', url: config.tokenUrl ?? '',
headers, method: 'POST',
body, headers,
timeout: ms('15 seconds'), body,
serviceName: 'sso_token_exchange', timeout: ms('15 seconds'),
}); serviceName: 'sso_token_exchange',
},
{requestUrlPolicy: getSsoRequestUrlPolicy()},
);
if (resp.status < 200 || resp.status >= 300) { if (resp.status < 200 || resp.status >= 300) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE); throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_SSO_AUTHORIZATION_CODE);
} }
+1
View File
@@ -259,6 +259,7 @@ export interface APIConfig {
auth: { auth: {
sudoModeSecret: string; sudoModeSecret: string;
connectionInitiationSecret: string; connectionInitiationSecret: string;
ssoAllowPrivateAddresses: boolean;
passkeys: { passkeys: {
rpName: string; rpName: string;
rpId: string; rpId: string;
@@ -3,7 +3,9 @@
import {domainToASCII} from 'node:url'; import {domainToASCII} from 'node:url';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import type {RequestUrlPolicy} from '@pkgs/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy'; import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
import {Config} from '../Config';
interface SsoConfigValidationInput { interface SsoConfigValidationInput {
enabled: boolean; enabled: boolean;
@@ -21,7 +23,16 @@ interface NormalizedSsoConfigValidationResult extends SsoConfigValidationInput {
ready: boolean; ready: boolean;
} }
const SSO_REQUEST_URL_POLICY = createPublicInternetRequestUrlPolicy(); let ssoRequestUrlPolicy: RequestUrlPolicy | null = null;
export function getSsoRequestUrlPolicy(): RequestUrlPolicy {
if (ssoRequestUrlPolicy === null) {
ssoRequestUrlPolicy = createPublicInternetRequestUrlPolicy({
allowPrivateAddresses: Config.auth.ssoAllowPrivateAddresses,
});
}
return ssoRequestUrlPolicy;
}
const DOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; const DOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
function normalizeOptionalSsoString(value: string | null): string | null { function normalizeOptionalSsoString(value: string | null): string | null {
@@ -97,7 +108,7 @@ export async function validateSsoPublicOutboundUrl(rawUrl: string, fieldName: st
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT); throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
} }
try { try {
await SSO_REQUEST_URL_POLICY.validate(parsedUrl, { await getSsoRequestUrlPolicy().validate(parsedUrl, {
phase: 'initial', phase: 'initial',
redirectCount: 0, redirectCount: 0,
}); });
+18 -10
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import {createHttpClient} from '@pkgs/http_client/src/HttpClient'; import {createHttpClient} from '@pkgs/http_client/src/HttpClient';
import type {HttpClient, RequestOptions, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes'; import type {HttpClient, RequestOptions, RequestUrlPolicy, StreamResponse} from '@pkgs/http_client/src/HttpClientTypes';
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy'; import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
const requestUrlPolicy = createPublicInternetRequestUrlPolicy(); const requestUrlPolicy = createPublicInternetRequestUrlPolicy();
@@ -9,27 +9,35 @@ const client: HttpClient = createHttpClient({
userAgent: 'fluxer-api', userAgent: 'fluxer-api',
requestUrlPolicy, requestUrlPolicy,
}); });
const redirectScopedClients = new Map<number, HttpClient>(); const scopedClients = new Map<RequestUrlPolicy, Map<number, HttpClient>>();
interface SendRequestOptions { export interface SendRequestOptions {
maxRedirects?: number; maxRedirects?: number;
requestUrlPolicy?: RequestUrlPolicy;
} }
function getHttpClientForRequest(options?: SendRequestOptions): HttpClient { function getHttpClientForRequest(options?: SendRequestOptions): HttpClient {
if (!options?.maxRedirects) { const policy = options?.requestUrlPolicy ?? requestUrlPolicy;
const maxRedirects = options?.maxRedirects ?? 0;
if (policy === requestUrlPolicy && maxRedirects === 0) {
return client; return client;
} }
const existingClient = redirectScopedClients.get(options.maxRedirects); let clientsForPolicy = scopedClients.get(policy);
if (!clientsForPolicy) {
clientsForPolicy = new Map<number, HttpClient>();
scopedClients.set(policy, clientsForPolicy);
}
const existingClient = clientsForPolicy.get(maxRedirects);
if (existingClient) { if (existingClient) {
return existingClient; return existingClient;
} }
const redirectScopedClient = createHttpClient({ const scopedClient = createHttpClient({
userAgent: 'fluxer-api', userAgent: 'fluxer-api',
maxRedirects: options.maxRedirects, ...(maxRedirects > 0 ? {maxRedirects} : {}),
requestUrlPolicy, requestUrlPolicy: policy,
}); });
redirectScopedClients.set(options.maxRedirects, redirectScopedClient); clientsForPolicy.set(maxRedirects, scopedClient);
return redirectScopedClient; return scopedClient;
} }
export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) { export async function sendRequest(opts: RequestOptions, options?: SendRequestOptions) {
+1
View File
@@ -149,6 +149,7 @@ function defaultConfig(): MasterConfig {
auth: { auth: {
sudo_mode_secret: '', sudo_mode_secret: '',
connection_initiation_secret: '', connection_initiation_secret: '',
sso_allow_private_addresses: false,
passkeys: { passkeys: {
rp_name: 'Fluxer', rp_name: 'Fluxer',
rp_id: 'fluxer.app', rp_id: 'fluxer.app',
+1
View File
@@ -155,6 +155,7 @@ export interface MasterConfig {
auth: { auth: {
sudo_mode_secret: string; sudo_mode_secret: string;
connection_initiation_secret: string; connection_initiation_secret: string;
sso_allow_private_addresses: boolean;
passkeys: { passkeys: {
rp_name: string; rp_name: string;
rp_id: string; rp_id: string;
@@ -184,6 +184,7 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
}, },
FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']}, FLUXER_SUDO_MODE_SECRET: {path: ['auth', 'sudo_mode_secret']},
FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']}, FLUXER_CONNECTION_INITIATION_SECRET: {path: ['auth', 'connection_initiation_secret']},
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: {path: ['auth', 'sso_allow_private_addresses'], parse: parseEnvValue},
FLUXER_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']}, FLUXER_VAPID_PUBLIC_KEY: {path: ['auth', 'vapid', 'public_key']},
FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']}, FLUXER_VAPID_PRIVATE_KEY: {path: ['auth', 'vapid', 'private_key']},
FLUXER_VAPID_EMAIL: {path: ['auth', 'vapid', 'email']}, FLUXER_VAPID_EMAIL: {path: ['auth', 'vapid', 'email']},