Merge pull request #313 from builderz-labs/fix/http-tailscale-login

fix(auth): fix HTTP/Tailscale login — opt-in HTTPS redirect, CSP nonce propagation
This commit is contained in:
nyk 2026-03-13 12:29:58 +07:00 committed by GitHub
commit 626c8fabff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 148 additions and 27 deletions

View File

@ -2,6 +2,22 @@
All notable changes to Mission Control are documented in this file.
## [2.0.1] - 2026-03-13
### Fixed
- HTTP and Tailscale login broken by unconditional HTTPS redirect — replaced with opt-in `NEXT_PUBLIC_FORCE_HTTPS=1` (#309)
- CSP nonce mismatch blocking inline scripts after login — nonce now propagated into SSR request headers (#308, #311)
- Layout inline theme script missing `nonce` attribute, causing CSP violations on chunk loading (#308, #311)
- Session cookie `Secure` flag forced in production even over HTTP — now derived from actual request protocol (#304)
- Node 24 compatibility alongside Node 22 (#303)
### Changed
- CSP generation and browser-security helpers extracted to `src/lib/csp.ts` and `src/lib/browser-security.ts`
### Contributors
- @0xNyk
- @polaris-dxz
## [2.0.0] - 2026-03-11
### Added

View File

@ -38,6 +38,7 @@ import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel'
import { ChatPagePanel } from '@/components/panels/chat-page-panel'
import { ChatPanel } from '@/components/chat/chat-panel'
import { getPluginPanel } from '@/lib/plugins'
import { shouldRedirectDashboardToHttps } from '@/lib/browser-security'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
import { UpdateBanner } from '@/components/layout/update-banner'
@ -65,10 +66,6 @@ function renderPluginPanel(panelId: string) {
return pluginPanel ? createElement(pluginPanel) : <Dashboard />
}
function isLocalHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
}
export default function Home() {
const router = useRouter()
const { connect } = useWebSocket()
@ -148,9 +145,11 @@ export default function Home() {
useEffect(() => {
setIsClient(true)
// OpenClaw control-ui device identity requires a secure browser context.
// Redirect remote HTTP sessions to HTTPS automatically to avoid handshake failures.
if (window.location.protocol === 'http:' && !isLocalHost(window.location.hostname)) {
if (shouldRedirectDashboardToHttps({
protocol: window.location.protocol,
hostname: window.location.hostname,
forceHttps: process.env.NEXT_PUBLIC_FORCE_HTTPS === '1',
})) {
const secureUrl = new URL(window.location.href)
secureUrl.protocol = 'https:'
window.location.replace(secureUrl.toString())

View File

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from 'next'
import { Inter, JetBrains_Mono } from 'next/font/google'
import { headers } from 'next/headers'
import { ThemeProvider } from 'next-themes'
import { THEME_IDS } from '@/lib/themes'
import { ThemeBackground } from '@/components/ui/theme-background'
@ -78,17 +79,20 @@ export const metadata: Metadata = {
},
}
export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const nonce = (await headers()).get('x-nonce') || undefined
return (
<html lang="en" className="dark" suppressHydrationWarning>
<head>
{/* Blocking script to set 'dark' class before first paint, preventing FOUC.
Content is a static string literal no user input, no XSS vector. */}
<script
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme')||'void';var light=['light','paper'];if(light.indexOf(t)===-1)document.documentElement.classList.add('dark')}catch(e){}})()`,
}}

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { isLocalDashboardHost, shouldRedirectDashboardToHttps } from '@/lib/browser-security'
describe('isLocalDashboardHost', () => {
it('treats localhost variants as local', () => {
expect(isLocalDashboardHost('localhost')).toBe(true)
expect(isLocalDashboardHost('127.0.0.1')).toBe(true)
expect(isLocalDashboardHost('test.local')).toBe(true)
})
})
describe('shouldRedirectDashboardToHttps', () => {
it('does not redirect remote HTTP dashboards unless explicitly forced', () => {
expect(shouldRedirectDashboardToHttps({
protocol: 'http:',
hostname: '192.168.1.20',
forceHttps: false,
})).toBe(false)
})
it('redirects remote HTTP dashboards only when forceHttps is enabled', () => {
expect(shouldRedirectDashboardToHttps({
protocol: 'http:',
hostname: 'example.tailnet.ts.net',
forceHttps: true,
})).toBe(true)
})
it('never redirects localhost', () => {
expect(shouldRedirectDashboardToHttps({
protocol: 'http:',
hostname: 'localhost',
forceHttps: true,
})).toBe(false)
})
})

View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { buildMissionControlCsp, buildNonceRequestHeaders } from '@/lib/csp'
describe('buildMissionControlCsp', () => {
it('includes the request nonce in script and style directives', () => {
const csp = buildMissionControlCsp({ nonce: 'nonce-123', googleEnabled: false })
expect(csp).toContain(`script-src 'self' 'nonce-nonce-123' 'strict-dynamic'`)
expect(csp).toContain(`style-src 'self' 'nonce-nonce-123'`)
})
})
describe('buildNonceRequestHeaders', () => {
it('propagates nonce and CSP into request headers for Next.js rendering', () => {
const headers = buildNonceRequestHeaders({
headers: new Headers({ host: 'localhost:3000' }),
nonce: 'nonce-123',
googleEnabled: false,
})
expect(headers.get('x-nonce')).toBe('nonce-123')
expect(headers.get('Content-Security-Policy')).toContain(`'nonce-nonce-123'`)
})
})

View File

@ -0,0 +1,22 @@
function normalizeHostname(hostname: string): string {
return hostname.trim().toLowerCase()
}
export function isLocalDashboardHost(hostname: string): boolean {
const normalized = normalizeHostname(hostname)
return (
normalized === 'localhost' ||
normalized === '127.0.0.1' ||
normalized === '::1' ||
normalized.endsWith('.local')
)
}
export function shouldRedirectDashboardToHttps(input: {
protocol: string
hostname: string
forceHttps?: boolean
}): boolean {
if (!input.forceHttps) return false
return input.protocol === 'http:' && !isLocalDashboardHost(input.hostname)
}

31
src/lib/csp.ts Normal file
View File

@ -0,0 +1,31 @@
export function buildMissionControlCsp(input: { nonce: string; googleEnabled: boolean }): string {
const { nonce, googleEnabled } = input
return [
`default-src 'self'`,
`base-uri 'self'`,
`object-src 'none'`,
`frame-ancestors 'none'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
`style-src 'self' 'nonce-${nonce}'`,
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
`font-src 'self' data:`,
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
`worker-src 'self' blob:`,
].join('; ')
}
export function buildNonceRequestHeaders(input: {
headers: Headers
nonce: string
googleEnabled: boolean
}): Headers {
const requestHeaders = new Headers(input.headers)
const csp = buildMissionControlCsp({ nonce: input.nonce, googleEnabled: input.googleEnabled })
requestHeaders.set('x-nonce', input.nonce)
requestHeaders.set('Content-Security-Policy', csp)
return requestHeaders
}

View File

@ -2,6 +2,7 @@ import crypto from 'node:crypto'
import os from 'node:os'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { buildMissionControlCsp, buildNonceRequestHeaders } from '@/lib/csp'
import { MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME } from '@/lib/session-cookie'
/** Constant-time string comparison using Node.js crypto. */
@ -83,26 +84,14 @@ function hostMatches(pattern: string, hostname: string): boolean {
return h === p
}
function buildCsp(nonce: string, googleEnabled: boolean): string {
return [
`default-src 'self'`,
`base-uri 'self'`,
`object-src 'none'`,
`frame-ancestors 'none'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' blob:${googleEnabled ? ' https://accounts.google.com' : ''}`,
`style-src 'self' 'nonce-${nonce}'`,
`connect-src 'self' ws: wss: http://127.0.0.1:* http://localhost:* https://cdn.jsdelivr.net`,
`img-src 'self' data: blob:${googleEnabled ? ' https://*.googleusercontent.com https://lh3.googleusercontent.com' : ''}`,
`font-src 'self' data:`,
`frame-src 'self'${googleEnabled ? ' https://accounts.google.com' : ''}`,
`worker-src 'self' blob:`,
].join('; ')
}
function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } {
const nonce = crypto.randomBytes(16).toString('base64')
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
const requestHeaders = buildNonceRequestHeaders({
headers: request.headers,
nonce,
googleEnabled,
})
const response = NextResponse.next({
request: {
headers: requestHeaders,
@ -120,7 +109,7 @@ function addSecurityHeaders(response: NextResponse, _request: NextRequest, nonce
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64')
response.headers.set('Content-Security-Policy', buildCsp(effectiveNonce, googleEnabled))
response.headers.set('Content-Security-Policy', buildMissionControlCsp({ nonce: effectiveNonce, googleEnabled }))
return response
}