diff --git a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx index 7e3dfeb2e..df0b43271 100644 --- a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx +++ b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx @@ -3,6 +3,7 @@ import * as Modal from '@app/features/app/components/dialogs/Modal'; import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css'; import { + classifySetupUnauthorized, fetchInstanceConfig, type SetupBrandingAssetKind, testSmtpConfig, @@ -55,6 +56,7 @@ import {fileToBase64} from '@app/features/user/utils/AvatarUtils'; import * as FormUtils from '@app/lib/forms'; import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants'; import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas'; +import type {MessageDescriptor} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import {ArrowLeftIcon, ArrowRightIcon, CheckIcon, WrenchIcon} from '@phosphor-icons/react'; @@ -92,6 +94,11 @@ const LOAD_ERROR_DESCRIPTOR = msg({ message: 'Could not load the instance configuration. Try reloading the page.', comment: 'Error shown when the setup wizard fails to load the instance configuration.', }); +const ORIGIN_MISMATCH_DESCRIPTOR = msg({ + message: + 'The API is on a different origin than this page, so setup requests are sent without your session. Check the public origin and port this instance is configured with, then reload.', + comment: 'Error shown when the setup wizard cannot load because the API origin differs from the page origin.', +}); const ASSET_UPLOAD_ERROR_DESCRIPTOR = msg({ message: 'That image could not be used. Try a different file.', comment: 'Error shown when a branding image fails to upload in the setup wizard.', @@ -425,7 +432,7 @@ export const SelfHostedSetupWizardGate = observer(() => { const stepNavigationUnlockTimerRef = useRef(null); const [config, setConfig] = useState(null); - const [loadError, setLoadError] = useState(false); + const [loadError, setLoadError] = useState(null); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const [stepNavigationLocked, setStepNavigationLocked] = useState(false); @@ -531,11 +538,11 @@ export const SelfHostedSetupWizardGate = observer(() => { }, [authStoreAuthenticated, forceUnauthenticatedSetup]); const resetStaleSetupSession = useCallback(async () => { - logger.warn('Instance config fetch returned 401 during setup; clearing stale local setup session'); + logger.warn('The setup session token was rejected. Clearing the stale local setup session.'); setForceUnauthenticatedSetup(true); registerFormDraftsRef.current.clear(); setConfig(null); - setLoadError(false); + setLoadError(null); setSubmitError(null); setSubmitting(false); setWizardSnapshot(createSetupWizardSnapshot()); @@ -605,7 +612,7 @@ export const SelfHostedSetupWizardGate = observer(() => { useEffect(() => { if (!isAuthenticated || config) return; let cancelled = false; - setLoadError(false); + setLoadError(null); void (async () => { try { const next = await fetchInstanceConfig(); @@ -613,12 +620,15 @@ export const SelfHostedSetupWizardGate = observer(() => { hydrateFromConfig(next); } catch (error) { if (cancelled) return; - if (error instanceof HttpError && error.status === 401) { + const cause = + error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown'; + if (cancelled) return; + if (cause === 'stale_session') { await resetStaleSetupSession(); return; } logger.error('Failed to load instance configuration', error); - setLoadError(true); + setLoadError(cause === 'origin_mismatch' ? ORIGIN_MISMATCH_DESCRIPTOR : LOAD_ERROR_DESCRIPTOR); } })(); return () => { @@ -868,7 +878,7 @@ export const SelfHostedSetupWizardGate = observer(() => { role="alert" data-flx="app.self-hosted-setup-wizard-gate.load-error" > - {i18n._(LOAD_ERROR_DESCRIPTOR)} + {i18n._(loadError)}

) : ( diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts index 82c0e4bf9..3484b4bff 100644 --- a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts +++ b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {Endpoints} from '@app/features/app/constants/Endpoints'; +import SessionManager from '@app/features/platform/state/AuthSession'; import {http} from '@app/features/platform/transport/RestTransport'; +import {Logger} from '@app/features/platform/utils/AppLogger'; import type { BrandingAssetUploadRequest, InstanceConfigResponse, @@ -10,6 +12,8 @@ import type { InstanceEmailSmtpTestResponse, } from '@fluxer/schema/src/domains/admin/AdminSchemas'; +const logger = new Logger('SetupWizardClient'); + export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind']; export async function fetchInstanceConfig(): Promise { @@ -35,3 +39,17 @@ export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promis const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body}); return response.body; } + +export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unknown'; + +export async function classifySetupUnauthorized(): Promise { + if (!SessionManager.token) return 'unknown'; + if (!http.carriesAuthorization()) return 'origin_mismatch'; + try { + const response = await http.get(Endpoints.USER_ME, {mode: 'silent'}); + return response.status === 401 ? 'stale_session' : 'unknown'; + } catch (error) { + logger.warn('Could not confirm whether the setup session is still valid', error); + return 'unknown'; + } +} diff --git a/fluxer_app/src/features/platform/transport/RestTransport.ts b/fluxer_app/src/features/platform/transport/RestTransport.ts index edabf1ab4..d88242c1a 100644 --- a/fluxer_app/src/features/platform/transport/RestTransport.ts +++ b/fluxer_app/src/features/platform/transport/RestTransport.ts @@ -84,6 +84,8 @@ interface OnlineWaiter { onAbort: () => void; } +const strippedAuthorizationOrigins = new Set(); + const onlineWaiters = new Set(); let onlineListenerActive = false; @@ -167,6 +169,10 @@ export class RestClient { this.state.globalIntercept = hooks.intercept; } + carriesAuthorization(): boolean { + return !isOffOrigin(resolveUrl(this.state, '/', undefined)); + } + dispatch(method: HttpMethod, path: string, options: RestRequestOptions = {}): Promise> { return runWithSudoEscalation(this.state, method, path, options, 'fresh'); } @@ -319,7 +325,12 @@ function composePlan( ): Plan { const url = resolveUrl(state, path, options.query); const body = encodeBody(options); - const sameOrigin = !looksAbsolute(path) && !isOffOrigin(url); + const targetsApiBase = !looksAbsolute(path); + const apiOrigin = targetsApiBase ? originOf(url) : null; + const sameOrigin = targetsApiBase && (apiOrigin === null || apiOrigin === window.location.origin); + if (apiOrigin !== null && !sameOrigin) { + reportStrippedAuthorization(state, apiOrigin, options.auth); + } const headers = assembleHeaders({ state, callerHeaders: options.headers, @@ -365,14 +376,26 @@ function looksAbsolute(path: string): boolean { return path.startsWith('//') || /^[a-z][a-z0-9+.-]*:\/\//i.test(path); } -function isOffOrigin(url: string): boolean { +function originOf(url: string): string | null { try { - return new URL(url).origin !== window.location.origin; + return new URL(url).origin; } catch { - return false; + return null; } } +function isOffOrigin(url: string): boolean { + const origin = originOf(url); + return origin !== null && origin !== window.location.origin; +} + +function reportStrippedAuthorization(state: RuntimeState, apiOrigin: string, auth: RestAuthMode | undefined): void { + if (auth === 'none' || strippedAuthorizationOrigins.has(apiOrigin)) return; + if (!state.authProvider()) return; + strippedAuthorizationOrigins.add(apiOrigin); + log.warn(`authorization withheld from off-origin api base: ${apiOrigin} (page ${window.location.origin})`); +} + function encodeBody(options: RestRequestOptions): BodyShape { if (options.multipart) { return {tag: 'form', payload: buildFormData(options.multipart)};