fix(auth): fix HTTP/Tailscale login — opt-in HTTPS redirect, CSP nonce propagation
- Replace unconditional HTTP→HTTPS redirect with opt-in via NEXT_PUBLIC_FORCE_HTTPS=1 - Propagate CSP nonce into forwarded request headers so SSR inline scripts get the nonce - Bind nonce attribute to layout's inline theme script to prevent CSP violations - Extract CSP and browser-security helpers into dedicated modules with tests Closes #308, #309, #311
This commit is contained in:
parent
eddfd752c2
commit
a6e6341e23
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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){}})()`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 { 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue