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:
commit
626c8fabff
16
CHANGELOG.md
16
CHANGELOG.md
|
|
@ -2,6 +2,22 @@
|
||||||
|
|
||||||
All notable changes to Mission Control are documented in this file.
|
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
|
## [2.0.0] - 2026-03-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel'
|
||||||
import { ChatPagePanel } from '@/components/panels/chat-page-panel'
|
import { ChatPagePanel } from '@/components/panels/chat-page-panel'
|
||||||
import { ChatPanel } from '@/components/chat/chat-panel'
|
import { ChatPanel } from '@/components/chat/chat-panel'
|
||||||
import { getPluginPanel } from '@/lib/plugins'
|
import { getPluginPanel } from '@/lib/plugins'
|
||||||
|
import { shouldRedirectDashboardToHttps } from '@/lib/browser-security'
|
||||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
|
import { LocalModeBanner } from '@/components/layout/local-mode-banner'
|
||||||
import { UpdateBanner } from '@/components/layout/update-banner'
|
import { UpdateBanner } from '@/components/layout/update-banner'
|
||||||
|
|
@ -65,10 +66,6 @@ function renderPluginPanel(panelId: string) {
|
||||||
return pluginPanel ? createElement(pluginPanel) : <Dashboard />
|
return pluginPanel ? createElement(pluginPanel) : <Dashboard />
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLocalHost(hostname: string): boolean {
|
|
||||||
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { connect } = useWebSocket()
|
const { connect } = useWebSocket()
|
||||||
|
|
@ -148,9 +145,11 @@ export default function Home() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true)
|
setIsClient(true)
|
||||||
|
|
||||||
// OpenClaw control-ui device identity requires a secure browser context.
|
if (shouldRedirectDashboardToHttps({
|
||||||
// Redirect remote HTTP sessions to HTTPS automatically to avoid handshake failures.
|
protocol: window.location.protocol,
|
||||||
if (window.location.protocol === 'http:' && !isLocalHost(window.location.hostname)) {
|
hostname: window.location.hostname,
|
||||||
|
forceHttps: process.env.NEXT_PUBLIC_FORCE_HTTPS === '1',
|
||||||
|
})) {
|
||||||
const secureUrl = new URL(window.location.href)
|
const secureUrl = new URL(window.location.href)
|
||||||
secureUrl.protocol = 'https:'
|
secureUrl.protocol = 'https:'
|
||||||
window.location.replace(secureUrl.toString())
|
window.location.replace(secureUrl.toString())
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata, Viewport } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { Inter, JetBrains_Mono } from 'next/font/google'
|
import { Inter, JetBrains_Mono } from 'next/font/google'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { ThemeProvider } from 'next-themes'
|
import { ThemeProvider } from 'next-themes'
|
||||||
import { THEME_IDS } from '@/lib/themes'
|
import { THEME_IDS } from '@/lib/themes'
|
||||||
import { ThemeBackground } from '@/components/ui/theme-background'
|
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,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const nonce = (await headers()).get('x-nonce') || undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark" suppressHydrationWarning>
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
{/* Blocking script to set 'dark' class before first paint, preventing FOUC.
|
{/* Blocking script to set 'dark' class before first paint, preventing FOUC.
|
||||||
Content is a static string literal — no user input, no XSS vector. */}
|
Content is a static string literal — no user input, no XSS vector. */}
|
||||||
<script
|
<script
|
||||||
|
nonce={nonce}
|
||||||
dangerouslySetInnerHTML={{
|
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){}})()`,
|
__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){}})()`,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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'`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
27
src/proxy.ts
27
src/proxy.ts
|
|
@ -2,6 +2,7 @@ import crypto from 'node:crypto'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import type { NextRequest } 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'
|
import { MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME } from '@/lib/session-cookie'
|
||||||
|
|
||||||
/** Constant-time string comparison using Node.js crypto. */
|
/** Constant-time string comparison using Node.js crypto. */
|
||||||
|
|
@ -83,26 +84,14 @@ function hostMatches(pattern: string, hostname: string): boolean {
|
||||||
return h === p
|
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 } {
|
function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } {
|
||||||
const nonce = crypto.randomBytes(16).toString('base64')
|
const nonce = crypto.randomBytes(16).toString('base64')
|
||||||
const requestHeaders = new Headers(request.headers)
|
const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
||||||
requestHeaders.set('x-nonce', nonce)
|
const requestHeaders = buildNonceRequestHeaders({
|
||||||
|
headers: request.headers,
|
||||||
|
nonce,
|
||||||
|
googleEnabled,
|
||||||
|
})
|
||||||
const response = NextResponse.next({
|
const response = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: requestHeaders,
|
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 googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID)
|
||||||
const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64')
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue