Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+174
-52
@@ -1,112 +1,234 @@
|
||||
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 {
|
||||
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));
|
||||
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 {
|
||||
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<typeof setInterval> | undefined;
|
||||
let closed = false;
|
||||
let closeStream: () => void = () => {};
|
||||
const cleanup = () => { if (timer) clearInterval(timer); signal.removeEventListener('abort', closeStream); };
|
||||
const cleanup = () => {
|
||||
if (timer) clearInterval(timer);
|
||||
signal.removeEventListener('abort', closeStream);
|
||||
};
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
closeStream = () => {
|
||||
if (closed) return; closed = true; cleanup();
|
||||
void reader.cancel().catch(() => {}); controller.close();
|
||||
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; }
|
||||
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); } }
|
||||
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(); }
|
||||
cancel() {
|
||||
closed = true;
|
||||
cleanup();
|
||||
return reader.cancel();
|
||||
}
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
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;
|
||||
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 (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; }
|
||||
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;
|
||||
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 (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 (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);
|
||||
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??'')) {
|
||||
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});
|
||||
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); }
|
||||
} 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');
|
||||
} 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 && !['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);
|
||||
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});
|
||||
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
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user