import { error, json, redirect, type Handle } from '@sveltejs/kit'; import { readSession, readSessionHash, recordAudit, setupRequired } from './index.js'; import { authBody, authError, requireSameOrigin, requireUserTransport, sessionCookie } from './http.js'; import { roleAllows, routePermission, type RoutePermission } from './access.js'; import { withRequestUsername } from './request-context.js'; function publicAsset(routeId: string | null, path: string, method: string) { return ( routeId === null && ['GET', 'HEAD'].includes(method) && (path.startsWith('/_app/') || path === '/favicon.svg' || [ '/agent/ec-agent.py', '/agent/ec-agent-update.py', '/agent/install.sh', '/agent/manifest.json' ].includes(path)) ); } /** Streaming responses must lose access when the underlying session does. */ export function protectEventStream( response: Response, tokenHash: string, permission: RoutePermission, signal: AbortSignal ): Response { if (!response.body) return response; const reader = response.body.getReader(); let timer: ReturnType | undefined; let closed = false; let closeStream: () => void = () => {}; const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); }; const stream = new ReadableStream({ start(controller) { closeStream = () => { if (closed) return; closed = true; cleanup(); void reader.cancel().catch(() => {}); controller.close(); }; timer = setInterval(() => { try { const current = readSessionHash(tokenHash); if ( !current || current.user.mustChangePassword || !roleAllows(current.user.role, permission) ) closeStream(); } catch { closeStream(); } }, 15_000); signal.addEventListener('abort', closeStream, { once: true }); if (signal.aborted) { closeStream(); return; } }, async pull(controller) { try { const next = await reader.read(); if (closed) return; if (next.done) { closed = true; cleanup(); controller.close(); } else controller.enqueue(next.value); } catch (cause) { if (!closed) { closed = true; cleanup(); controller.error(cause); } } }, cancel() { closed = true; cleanup(); return reader.cancel(); } }); return new Response(stream, { status: response.status, statusText: response.statusText, headers: response.headers }); } export const authenticationHandle: Handle = async ({ event, resolve }) => { event.locals.user = null; event.locals.sessionHash = null; const routeId = event.route.id; const method = event.request.method.toUpperCase(); if (publicAsset(routeId, event.url.pathname, method)) return resolve(event); const permission = routePermission(routeId, method); if ( !routeId?.startsWith('/api/agent/') && /^Bearer ec_(?:host|enroll)_/i.test(event.request.headers.get('authorization') ?? '') ) { return json( { error: 'Host credentials only authorize the host agent API.' }, { status: 403 } ); } if (permission === 'machine') return resolve(event); const api = routeId?.startsWith('/api/') || event.url.pathname.startsWith('/api/'); try { requireUserTransport(event.url); if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) requireSameOrigin(event.request, event.url); } catch (cause) { if (api) return authError(cause); throw cause; } const session = readSession(event.cookies.get(sessionCookie(event.url))); event.locals.user = session?.user ?? null; event.locals.sessionHash = session?.tokenHash ?? null; const setup = setupRequired(); if (permission !== 'public') { if (!session) { if (api) return json( { error: setup ? 'Complete administrator setup before using the management API.' : 'Sign in to continue.', setupRequired: setup }, { status: 401, headers: { 'Cache-Control': 'no-store' } } ); redirect( 303, setup ? '/setup' : `/login?next=${encodeURIComponent(event.url.pathname + event.url.search)}` ); } if (session.user.mustChangePassword && permission !== 'authenticated') { if (api) return json( { error: 'Change your temporary password from Account before continuing.', mustChangePassword: true }, { status: 403, headers: { 'Cache-Control': 'no-store' } } ); redirect(303, '/account'); } if (!roleAllows(session.user.role, permission)) { const message = permission === 'admin' ? 'Administrator access is required.' : 'Operator access is required for this action.'; if (api) return json( { error: message }, { status: 403, headers: { 'Cache-Control': 'no-store' } } ); error(403, message); } // Launch APIs can also save credentials. Operators may launch with saved // keys, but only administrators may introduce or replace server keys. if ( session.user.role === 'operator' && method === 'POST' && ['/api/servers', '/api/deployment-collections'].includes(routeId ?? '') ) { try { const body = await authBody(event.request.clone(), 1024 * 1024); if ( body.plutoKey || (body.keys && typeof body.keys === 'object' && Object.values(body.keys).some(Boolean)) ) { return json( { error: 'An administrator must save server keys before an operator launches these servers.' }, { status: 403 } ); } } catch (cause) { return authError(cause); } } } else if (['/login', '/setup'].includes(routeId ?? '')) { if (session) redirect(303, session.user.mustChangePassword ? '/account' : '/'); if (routeId === '/setup' && !setup) redirect(303, '/login'); if (routeId === '/login' && setup) redirect(303, '/setup'); } let response = await withRequestUsername(session?.user.username ?? null, () => resolve(event)); if (session && !['GET', 'HEAD', 'OPTIONS'].includes(method) && response.status < 400) { recordAudit(session.user.id, 'request', routeId, { method, status: response.status, path: event.url.pathname }); } if (session && response.headers.get('content-type')?.startsWith('text/event-stream')) { response = protectEventStream( response, session.tokenHash, permission, event.request.signal ); } const headers = new Headers(response.headers); headers.set('Cache-Control', 'no-store'); headers.set('Vary', [headers.get('Vary'), 'Cookie'].filter(Boolean).join(', ')); headers.set('Referrer-Policy', 'same-origin'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Frame-Options', 'DENY'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); };