diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index befc56f74..af8de969c 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -1,9 +1,10 @@ 'use client'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useRouter } from 'next/navigation'; import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from '@/components/icons'; import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation'; +import { takeSetupTokenFromUrl } from '@/lib/setup/url-token'; type State = 'bootstrap' | 'configured' | 'env-managed'; @@ -93,7 +94,7 @@ const STEPS = [ export default function SetupWizardPage() { const router = useRouter(); - const searchParams = useSearchParams(); + const [tokenFromUrl, setTokenFromUrl] = useState(''); const [bootstrapping, setBootstrapping] = useState(true); const [error, setError] = useState(null); @@ -110,6 +111,7 @@ export default function SetupWizardPage() { const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); useEffect(() => { setInsecureContext(detectInsecureContext()); + setTokenFromUrl(takeSetupTokenFromUrl(window.location, window.history)); }, []); // ─── Initial status load ──────────────────────────────────────────────── @@ -222,7 +224,7 @@ export default function SetupWizardPage() { {!authenticated ? ( { try { await submitToken(t); @@ -500,6 +502,7 @@ function WelcomeStep({ tokenFromUrl, onSubmit }: { tokenFromUrl: string; onSubmi // Auto-submit if token came in via URL. useEffect(() => { if (tokenFromUrl && !submitting) { + setToken(tokenFromUrl); setSubmitting(true); onSubmit(tokenFromUrl).finally(() => setSubmitting(false)); } diff --git a/instrumentation.node.ts b/instrumentation.node.ts index b98c2f9f5..365427d9d 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -31,7 +31,8 @@ migrateLegacyAdminLayout() console.info("=============================================================="); console.info(" SETUP REQUIRED"); console.info(` Token: ${token}`); - console.info(` Open: http://:${port}/setup?token=${token}`); + // In the fragment, so the token stays out of access logs and Referer. + console.info(` Open: http://:${port}/setup#token=${token}`); console.info(" Token expires in 1 hour. Restart the container to reissue."); console.info("=============================================================="); console.info(""); diff --git a/lib/__tests__/setup-url-token.test.ts b/lib/__tests__/setup-url-token.test.ts new file mode 100644 index 000000000..07683c5bc --- /dev/null +++ b/lib/__tests__/setup-url-token.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { takeSetupTokenFromUrl } from '@/lib/setup/url-token'; + +function take(href: string) { + const url = new URL(href); + const history = { state: { k: 1 }, replaceState: vi.fn() }; + const token = takeSetupTokenFromUrl(url, history); + return { token, history }; +} + +describe('takeSetupTokenFromUrl', () => { + it('reads the token from the fragment and clears it from the address bar', () => { + const { token, history } = take('https://mail.example/setup#token=abc123'); + expect(token).toBe('abc123'); + expect(history.replaceState).toHaveBeenCalledWith({ k: 1 }, '', '/setup'); + }); + + it('still accepts the legacy query parameter and keeps other parameters', () => { + const { token, history } = take('https://mail.example/setup?lang=de&token=abc123'); + expect(token).toBe('abc123'); + expect(history.replaceState).toHaveBeenCalledWith({ k: 1 }, '', '/setup?lang=de'); + }); + + it('leaves a URL without a token alone', () => { + const { token, history } = take('https://mail.example/setup?lang=de'); + expect(token).toBe(''); + expect(history.replaceState).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/setup/url-token.ts b/lib/setup/url-token.ts new file mode 100644 index 000000000..9d494925d --- /dev/null +++ b/lib/setup/url-token.ts @@ -0,0 +1,21 @@ +/** + * The setup link printed at startup carries the token in the fragment + * (`/setup#token=...`): a fragment never reaches the server, a reverse + * proxy's access log or a Referer. Older logs printed `?token=`, which is + * still accepted. Either way the token is removed from the address bar (and + * so from history) as soon as it is read. + */ +export function takeSetupTokenFromUrl( + location: Pick, + history: Pick, +): string { + const fromHash = new URLSearchParams(location.hash.replace(/^#/, '')).get('token'); + const search = new URLSearchParams(location.search); + const fromQuery = search.get('token'); + if (fromHash === null && fromQuery === null) return ''; + + search.delete('token'); + const rest = search.toString(); + history.replaceState(history.state, '', location.pathname + (rest ? `?${rest}` : '')); + return (fromHash ?? fromQuery ?? '').trim(); +}