mission-control/middleware.ts

115 lines
3.6 KiB
TypeScript

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
/** Edge-compatible constant-time string comparison. */
function safeCompare(a: string, b: string): boolean {
if (typeof a !== 'string' || typeof b !== 'string') return false
const encoder = new TextEncoder()
const bufA = encoder.encode(a)
const bufB = encoder.encode(b)
if (bufA.length !== bufB.length) return false
let result = 0
for (let i = 0; i < bufA.length; i++) {
result |= bufA[i] ^ bufB[i]
}
return result === 0
}
function envFlag(name: string): boolean {
const raw = process.env[name]
if (raw === undefined) return false
const v = String(raw).trim().toLowerCase()
return v === '1' || v === 'true' || v === 'yes' || v === 'on'
}
function getRequestHostname(request: NextRequest): string {
const raw = request.headers.get('x-forwarded-host') || request.headers.get('host') || ''
// If multiple hosts are present, take the first (proxy chain).
const first = raw.split(',')[0] || ''
return first.trim().split(':')[0] || ''
}
function hostMatches(pattern: string, hostname: string): boolean {
const p = pattern.trim().toLowerCase()
const h = hostname.trim().toLowerCase()
if (!p || !h) return false
// "*.example.com" matches "a.example.com" (but not bare "example.com")
if (p.startsWith('*.')) {
const suffix = p.slice(2)
return h.endsWith(`.${suffix}`)
}
// "100.*" matches "100.64.0.1"
if (p.endsWith('.*')) {
const prefix = p.slice(0, -1)
return h.startsWith(prefix)
}
return h === p
}
export function middleware(request: NextRequest) {
// Network access control.
// In production: default-deny unless explicitly allowed.
// In dev/test: allow all hosts unless overridden.
const hostName = getRequestHostname(request)
const allowAnyHost = envFlag('MC_ALLOW_ANY_HOST') || process.env.NODE_ENV !== 'production'
const allowedPatterns = String(process.env.MC_ALLOWED_HOSTS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const isAllowedHost = allowAnyHost || allowedPatterns.some((p) => hostMatches(p, hostName))
if (!isAllowedHost) {
return new NextResponse('Forbidden', { status: 403 })
}
const { pathname } = request.nextUrl
// Allow login page and auth API without session
if (pathname === '/login' || pathname.startsWith('/api/auth/')) {
return NextResponse.next()
}
// Check for session cookie
const sessionToken = request.cookies.get('mc-session')?.value
// API routes: accept session cookie OR API key
if (pathname.startsWith('/api/')) {
const apiKey = request.headers.get('x-api-key')
if (sessionToken || (apiKey && safeCompare(apiKey, process.env.API_KEY || ''))) {
return NextResponse.next()
}
// Backward compat: accept legacy cookie during migration
const legacyCookie = request.cookies.get('mission-control-auth')
if (safeCompare(legacyCookie?.value || '', process.env.AUTH_SECRET || '')) {
return NextResponse.next()
}
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Page routes: redirect to login if no session
if (sessionToken) {
return NextResponse.next()
}
// Backward compat: accept legacy cookie
const legacyCookie = request.cookies.get('mission-control-auth')
if (safeCompare(legacyCookie?.value || '', process.env.AUTH_SECRET || '')) {
return NextResponse.next()
}
// Redirect to login
const loginUrl = request.nextUrl.clone()
loginUrl.pathname = '/login'
return NextResponse.redirect(loginUrl)
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
}