feat: allow setup wizard over plain HTTP with dismissable warning gate
This commit is contained in:
@@ -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
@@ -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'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
@@ -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:';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user