mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
feat(self-hosting): allow opting in to private-address SSO providers (#1655)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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']},
|
||||||
|
|||||||
Reference in New Issue
Block a user