From 034ee27057a795dd9c52bd701a8ed13ab6d0148f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:32:38 +0200 Subject: [PATCH] fix: keep the setup token out of access logs, referers and history The startup banner printed `/setup?token=...`, so the one-hour bootstrap token that grants the setup wizard landed in reverse-proxy access logs, Referer headers and browser history. The link now carries the token in the fragment, which never leaves the browser, and the wizard removes it from the address bar as soon as it reads it. Old `?token=` links still work and are cleaned up the same way. --- app/(main)/setup/page.tsx | 9 ++++++--- instrumentation.node.ts | 3 ++- lib/__tests__/setup-url-token.test.ts | 29 +++++++++++++++++++++++++++ lib/setup/url-token.ts | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/setup-url-token.test.ts create mode 100644 lib/setup/url-token.ts 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(); +}