117 lines
3.7 KiB
TypeScript
117 lines
3.7 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
|
|
|
|
// CSRF Origin validation for mutating requests
|
|
const method = request.method.toUpperCase()
|
|
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
|
|
const origin = request.headers.get('origin')
|
|
if (origin) {
|
|
let originHost: string
|
|
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
const requestHost = request.headers.get('host') || ''
|
|
if (originHost && requestHost && originHost !== requestHost.split(',')[0].trim()) {
|
|
return NextResponse.json({ error: 'CSRF origin mismatch' }, { status: 403 })
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
// Page routes: redirect to login if no session
|
|
if (sessionToken) {
|
|
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).*)']
|
|
}
|