feat: allow setup wizard over plain HTTP with dismissable warning gate

This commit is contained in:
Linus Rath
2026-05-20 19:01:46 +02:00
parent 433a63bf1a
commit 1c44f59ba1
3 changed files with 50 additions and 39 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ export async function POST(request: NextRequest) {
} }
const response = NextResponse.json({ ok: true }); const response = NextResponse.json({ ok: true });
const attrs = buildSessionCookieAttributes(); const attrs = buildSessionCookieAttributes(request);
response.cookies.set(attrs.name, submitted, { response.cookies.set(attrs.name, submitted, {
httpOnly: attrs.httpOnly, httpOnly: attrs.httpOnly,
sameSite: attrs.sameSite, sameSite: attrs.sameSite,
+33 -36
View File
@@ -101,20 +101,13 @@ export default function SetupWizardPage() {
const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG); const [config, setConfig] = useState<WizardConfig>(EMPTY_CONFIG);
const [stepIndex, setStepIndex] = useState(0); const [stepIndex, setStepIndex] = useState(0);
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
// Detect synchronously on first client render so we don't flash the loading // Detect synchronously on first client render so the cleartext-credentials
// screen before the warning appears. The session cookie is set with the // warning is in the first paint instead of popping in after hydration.
// Secure flag in production, which browsers silently drop over plain HTTP -
// every subsequent step call then 401s with "Wizard session required".
const [insecureContext] = useState<boolean>(detectInsecureContext); const [insecureContext] = useState<boolean>(detectInsecureContext);
const [insecureAcknowledged, setInsecureAcknowledged] = useState(false);
// ─── Initial status load ──────────────────────────────────────────────── // ─── Initial status load ────────────────────────────────────────────────
useEffect(() => { 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; let cancelled = false;
(async () => { (async () => {
try { try {
@@ -152,7 +145,7 @@ export default function SetupWizardPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router, insecureContext]); }, [router]);
// ─── Token submit (welcome step) ──────────────────────────────────────── // ─── Token submit (welcome step) ────────────────────────────────────────
async function submitToken(token: string) { async function submitToken(token: string) {
@@ -184,8 +177,8 @@ export default function SetupWizardPage() {
} }
// ─── Render shell ─────────────────────────────────────────────────────── // ─── Render shell ───────────────────────────────────────────────────────
if (insecureContext) { if (insecureContext && !insecureAcknowledged) {
return <InsecureContextScreen />; return <InsecureContextScreen onContinue={() => setInsecureAcknowledged(true)} />;
} }
if (bootstrapping) { if (bootstrapping) {
@@ -362,7 +355,7 @@ function CompletedScreen() {
); );
} }
function InsecureContextScreen() { function InsecureContextScreen({ onContinue }: { onContinue: () => void }) {
const httpsUrl = const httpsUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? `https://${window.location.host}${window.location.pathname}${window.location.search}` ? `https://${window.location.host}${window.location.pathname}${window.location.search}`
@@ -373,29 +366,29 @@ function InsecureContextScreen() {
<div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4"> <div className="mx-auto h-12 w-12 rounded-full bg-warning/15 text-warning flex items-center justify-center mb-4">
<ShieldAlert className="h-6 w-6" /> <ShieldAlert className="h-6 w-6" />
</div> </div>
<h1 className="text-xl font-semibold">HTTPS required for setup</h1> <h1 className="text-xl font-semibold">You&apos;re running setup over plain HTTP</h1>
<p className="text-sm text-muted-foreground mt-2"> <p className="text-sm text-muted-foreground mt-2 leading-relaxed">
The setup wizard signs you in with a <code className="font-mono text-xs">Secure</code> cookie, The setup token and admin password you enter here will travel in cleartext.
which your browser will only accept over HTTPS. Loading this page over plain HTTP causes every Please use HTTPS if at all possible - terminate TLS on the container or a reverse proxy in front of it.
step to fail with <em>Wizard session required</em>.
</p> </p>
</div> </div>
<div className="mt-5 text-left text-sm text-muted-foreground space-y-2"> <div className="mt-6 space-y-2">
<p className="font-medium text-foreground">To continue, do one of the following:</p> {httpsUrl && (
<ul className="list-disc pl-5 space-y-1"> <a
<li>Reach this page over HTTPS (terminate TLS on the container or a reverse proxy in front of it).</li> href={httpsUrl}
<li>If you already have a reverse proxy, make sure it forwards to the webmail and forwards the className="block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90"
<code className="font-mono text-xs"> X-Forwarded-Proto</code> header.</li> >
</ul> Try HTTPS
</div> </a>
{httpsUrl && ( )}
<a <button
href={httpsUrl} type="button"
className="mt-6 block w-full rounded-md bg-primary text-primary-foreground text-center px-4 py-2.5 text-sm font-medium hover:bg-primary/90" onClick={onContinue}
className="block w-full rounded-md border border-border text-center px-4 py-2.5 text-sm font-medium hover:bg-muted"
> >
Open over HTTPS Continue over HTTP
</a> </button>
)} </div>
</CenteredCard> </CenteredCard>
); );
} }
@@ -1786,9 +1779,13 @@ function detectInsecureContext(): boolean {
if (typeof window === 'undefined') return false; if (typeof window === 'undefined') return false;
if (window.location.protocol !== 'http:') return false; if (window.location.protocol !== 'http:') return false;
// Browsers treat localhost/loopback as "potentially trustworthy" and accept // 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; 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 false;
} }
return true; return true;
+16 -2
View File
@@ -1,4 +1,5 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import type { NextRequest } from 'next/server';
import { verifySetupToken } from './token'; import { verifySetupToken } from './token';
export const SETUP_COOKIE = 'bulwark_setup_token'; export const SETUP_COOKIE = 'bulwark_setup_token';
@@ -21,13 +22,26 @@ export async function authenticateWizardRequest(): Promise<boolean> {
return verifySetupToken(token); 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 { return {
name: SETUP_COOKIE, name: SETUP_COOKIE,
httpOnly: true, httpOnly: true,
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production', secure: request ? isHttpsRequest(request) : process.env.NODE_ENV === 'production',
path: '/', path: '/',
maxAge: COOKIE_MAX_AGE, 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:';
}