From 1c44f59ba175dd96257590efa8b74a3430c35f75 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 20 May 2026 19:01:46 +0200 Subject: [PATCH] feat: allow setup wizard over plain HTTP with dismissable warning gate --- app/api/setup/token/route.ts | 2 +- app/setup/page.tsx | 69 +++++++++++++++++------------------- lib/setup/session.ts | 18 ++++++++-- 3 files changed, 50 insertions(+), 39 deletions(-) diff --git a/app/api/setup/token/route.ts b/app/api/setup/token/route.ts index 4b22b794..a2024ba9 100644 --- a/app/api/setup/token/route.ts +++ b/app/api/setup/token/route.ts @@ -37,7 +37,7 @@ export async function POST(request: NextRequest) { } const response = NextResponse.json({ ok: true }); - const attrs = buildSessionCookieAttributes(); + const attrs = buildSessionCookieAttributes(request); response.cookies.set(attrs.name, submitted, { httpOnly: attrs.httpOnly, sameSite: attrs.sameSite, diff --git a/app/setup/page.tsx b/app/setup/page.tsx index d40f0534..09859fd3 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -101,20 +101,13 @@ export default function SetupWizardPage() { const [config, setConfig] = useState(EMPTY_CONFIG); const [stepIndex, setStepIndex] = useState(0); const [completed, setCompleted] = useState(false); - // Detect synchronously on first client render so we don't flash the loading - // screen before the warning appears. The session cookie is set with the - // Secure flag in production, which browsers silently drop over plain HTTP - - // every subsequent step call then 401s with "Wizard session required". + // Detect synchronously on first client render so the cleartext-credentials + // warning is in the first paint instead of popping in after hydration. const [insecureContext] = useState(detectInsecureContext); + const [insecureAcknowledged, setInsecureAcknowledged] = useState(false); // ─── Initial status load ──────────────────────────────────────────────── useEffect(() => { - // Skip the status fetch entirely when we're going to render the HTTPS - // notice - the wizard cookie can't survive an HTTP origin anyway. - if (insecureContext) { - setBootstrapping(false); - return; - } let cancelled = false; (async () => { try { @@ -152,7 +145,7 @@ export default function SetupWizardPage() { return () => { cancelled = true; }; - }, [router, insecureContext]); + }, [router]); // ─── Token submit (welcome step) ──────────────────────────────────────── async function submitToken(token: string) { @@ -184,8 +177,8 @@ export default function SetupWizardPage() { } // ─── Render shell ─────────────────────────────────────────────────────── - if (insecureContext) { - return ; + if (insecureContext && !insecureAcknowledged) { + return setInsecureAcknowledged(true)} />; } if (bootstrapping) { @@ -362,7 +355,7 @@ function CompletedScreen() { ); } -function InsecureContextScreen() { +function InsecureContextScreen({ onContinue }: { onContinue: () => void }) { const httpsUrl = typeof window !== 'undefined' ? `https://${window.location.host}${window.location.pathname}${window.location.search}` @@ -373,29 +366,29 @@ function InsecureContextScreen() {
-

HTTPS required for setup

-

- The setup wizard signs you in with a Secure cookie, - which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every - step to fail with Wizard session required. +

You're running setup over plain HTTP

+

+ The setup token and admin password you enter here will travel in cleartext. + Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.

-
-

To continue, do one of the following:

-
    -
  • Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).
  • -
  • If you already have a reverse proxy, make sure it forwards to the webmail and forwards the - X-Forwarded-Proto header.
  • -
-
- {httpsUrl && ( - + {httpsUrl && ( + + Try HTTPS + + )} + + ); } @@ -1786,9 +1779,13 @@ function detectInsecureContext(): boolean { if (typeof window === 'undefined') return false; if (window.location.protocol !== 'http:') return false; // Browsers treat localhost/loopback as "potentially trustworthy" and accept - // Secure cookies even without TLS, so the wizard still works there. + // Secure cookies even without TLS, so the wizard still works there. In dev + // we still want to render the warning so we can preview it without spinning + // up a non-loopback host. const host = window.location.hostname; - if (host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]') { + const isLoopback = + host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; + if (isLoopback && process.env.NODE_ENV !== 'development') { return false; } return true; diff --git a/lib/setup/session.ts b/lib/setup/session.ts index e4922e4e..a3ee7368 100644 --- a/lib/setup/session.ts +++ b/lib/setup/session.ts @@ -1,4 +1,5 @@ import { cookies } from 'next/headers'; +import type { NextRequest } from 'next/server'; import { verifySetupToken } from './token'; export const SETUP_COOKIE = 'bulwark_setup_token'; @@ -21,13 +22,26 @@ export async function authenticateWizardRequest(): Promise { return verifySetupToken(token); } -export function buildSessionCookieAttributes() { +export function buildSessionCookieAttributes(request?: NextRequest) { + // Match Secure to the actual request protocol. Browsers drop Secure cookies + // on plain HTTP, so unconditionally setting Secure in production breaks + // setup over HTTP — the operator gets "Wizard session required" on every + // step. The wizard surfaces a cleartext-credentials warning in the UI when + // HTTPS isn't in use. return { name: SETUP_COOKIE, httpOnly: true, sameSite: 'lax' as const, - secure: process.env.NODE_ENV === 'production', + secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production', path: '/', maxAge: COOKIE_MAX_AGE, }; } + +function isHttpsRequest(request: NextRequest): boolean { + const forwarded = request.headers.get('x-forwarded-proto'); + if (forwarded) { + return forwarded.split(',')[0]!.trim().toLowerCase() === 'https'; + } + return request.nextUrl.protocol === 'https:'; +}