Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+463
-157
@@ -4,54 +4,124 @@ import type { AuthUser, UserRole } from '../../src/lib/auth.js';
|
||||
|
||||
type Session = { user: AuthUser; tokenHash: string; expiresAt: number };
|
||||
const auth = vi.hoisted(() => ({
|
||||
session: null as Session | null, current: null as Session | null, setup: false,
|
||||
readSession: vi.fn(), readSessionHash: vi.fn(), recordAudit: vi.fn(), setupRequired: vi.fn()
|
||||
session: null as Session | null,
|
||||
current: null as Session | null,
|
||||
setup: false,
|
||||
readSession: vi.fn(),
|
||||
readSessionHash: vi.fn(),
|
||||
recordAudit: vi.fn(),
|
||||
setupRequired: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/auth/index.js', () => ({
|
||||
SESSION_COOKIE: 'ec_session',
|
||||
AuthError: class AuthError extends Error { constructor(message: string, public status = 400) { super(message); } },
|
||||
readSession: auth.readSession, readSessionHash: auth.readSessionHash, recordAudit: auth.recordAudit, setupRequired: auth.setupRequired
|
||||
AuthError: class AuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
readSession: auth.readSession,
|
||||
readSessionHash: auth.readSessionHash,
|
||||
recordAudit: auth.recordAudit,
|
||||
setupRequired: auth.setupRequired
|
||||
}));
|
||||
import { authenticationHandle, protectEventStream } from '../../src/lib/server/auth/guard.js';
|
||||
|
||||
const token = 'x'.repeat(43);
|
||||
function signIn(role: UserRole = 'viewer', mustChangePassword = false): Session {
|
||||
const user: AuthUser = { id: 'user-1', username: 'tester', displayName: 'Test User', role, disabled: false,
|
||||
mustChangePassword, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', lastLoginAt: null };
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: 'Test User',
|
||||
role,
|
||||
disabled: false,
|
||||
mustChangePassword,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
lastLoginAt: null
|
||||
};
|
||||
auth.session = { user, tokenHash: 'hash-of-session', expiresAt: Date.now() + 3600_000 };
|
||||
auth.current = auth.session;
|
||||
return auth.session;
|
||||
}
|
||||
type Resolve = Parameters<Handle>[0]['resolve'];
|
||||
function requestFor(routeId: string | null, options: {
|
||||
method?: string; path?: string; base?: string; origin?: string | null; headers?: HeadersInit; cookie?: string | null;
|
||||
body?: unknown; rawBody?: BodyInit; signal?: AbortSignal; resolve?: Resolve;
|
||||
} = {}) {
|
||||
function requestFor(
|
||||
routeId: string | null,
|
||||
options: {
|
||||
method?: string;
|
||||
path?: string;
|
||||
base?: string;
|
||||
origin?: string | null;
|
||||
headers?: HeadersInit;
|
||||
cookie?: string | null;
|
||||
body?: unknown;
|
||||
rawBody?: BodyInit;
|
||||
signal?: AbortSignal;
|
||||
resolve?: Resolve;
|
||||
} = {}
|
||||
) {
|
||||
const url = new URL(options.path ?? routeId ?? '/not-found', options.base ?? 'https://ec.test');
|
||||
const method = options.method ?? 'GET'; const headers = new Headers(options.headers);
|
||||
if (!['GET', 'HEAD'].includes(method) && options.origin !== null) headers.set('Origin', options.origin ?? url.origin);
|
||||
if (options.body !== undefined && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
const request = new Request(url, { method, headers, body: options.rawBody ?? (options.body === undefined ? undefined : JSON.stringify(options.body)), signal: options.signal });
|
||||
const cookies = { get: vi.fn(() => options.cookie === null ? undefined : options.cookie ?? token) };
|
||||
const event = { request, url, route: { id: routeId }, locals: { user: null, sessionHash: null }, cookies } as unknown as RequestEvent;
|
||||
const resolve = vi.fn(options.resolve ?? (async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } })));
|
||||
return { event, resolve, cookies, invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0]) };
|
||||
const method = options.method ?? 'GET';
|
||||
const headers = new Headers(options.headers);
|
||||
if (!['GET', 'HEAD'].includes(method) && options.origin !== null)
|
||||
headers.set('Origin', options.origin ?? url.origin);
|
||||
if (options.body !== undefined && !headers.has('Content-Type'))
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const request = new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body:
|
||||
options.rawBody ??
|
||||
(options.body === undefined ? undefined : JSON.stringify(options.body)),
|
||||
signal: options.signal
|
||||
});
|
||||
const cookies = {
|
||||
get: vi.fn(() => (options.cookie === null ? undefined : (options.cookie ?? token)))
|
||||
};
|
||||
const event = {
|
||||
request,
|
||||
url,
|
||||
route: { id: routeId },
|
||||
locals: { user: null, sessionHash: null },
|
||||
cookies
|
||||
} as unknown as RequestEvent;
|
||||
const resolve = vi.fn(
|
||||
options.resolve ??
|
||||
(async () => new Response('resolved', { headers: { 'Content-Type': 'text/plain' } }))
|
||||
);
|
||||
return {
|
||||
event,
|
||||
resolve,
|
||||
cookies,
|
||||
invoke: () => authenticationHandle({ event, resolve } as Parameters<Handle>[0])
|
||||
};
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); auth.session = null; auth.current = null; auth.setup = false;
|
||||
vi.clearAllMocks();
|
||||
auth.session = null;
|
||||
auth.current = null;
|
||||
auth.setup = false;
|
||||
auth.readSession.mockImplementation(() => auth.session);
|
||||
auth.readSessionHash.mockImplementation(() => auth.current);
|
||||
auth.setupRequired.mockImplementation(() => auth.setup);
|
||||
vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'false');
|
||||
});
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllEnvs(); });
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('browser authentication guard', () => {
|
||||
it('carries the session username through an authenticated action, ignoring requester fields from the client', async () => {
|
||||
const { currentRequestUsername } = await import('../../src/lib/server/auth/request-context.js');
|
||||
const { currentRequestUsername } =
|
||||
await import('../../src/lib/server/auth/request-context.js');
|
||||
signIn('operator');
|
||||
const call = requestFor('/api/provisioning', { method: 'POST', body: { requested_by: 'someone-else' },
|
||||
resolve: async event => {
|
||||
const call = requestFor('/api/provisioning', {
|
||||
method: 'POST',
|
||||
body: { requested_by: 'someone-else' },
|
||||
resolve: async (event) => {
|
||||
await event.request.json();
|
||||
expect(currentRequestUsername()).toBe('tester');
|
||||
return new Response('queued');
|
||||
@@ -61,18 +131,26 @@ describe('browser authentication guard', () => {
|
||||
expect(currentRequestUsername()).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['GET', 'HEAD'])('allows anonymous leaderboard %s requests even before account setup', async method => {
|
||||
for (const setup of [false, true]) {
|
||||
auth.setup = setup;
|
||||
const call = requestFor('/api/leaderboard', { method, path: '/api/leaderboard?game=t6&metric=kills', origin: null, cookie: null });
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(200);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(call.event.locals.user).toBeNull();
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('vary')).toContain('Cookie');
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'allows anonymous leaderboard %s requests even before account setup',
|
||||
async (method) => {
|
||||
for (const setup of [false, true]) {
|
||||
auth.setup = setup;
|
||||
const call = requestFor('/api/leaderboard', {
|
||||
method,
|
||||
path: '/api/leaderboard?game=t6&metric=kills',
|
||||
origin: null,
|
||||
cookie: null
|
||||
});
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(200);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(call.event.locals.user).toBeNull();
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('vary')).toContain('Cookie');
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
it('allows leaderboard reads when a supplied session has expired', async () => {
|
||||
const call = requestFor('/api/leaderboard', { cookie: token });
|
||||
expect((await call.invoke()).status).toBe(200);
|
||||
@@ -92,8 +170,13 @@ describe('browser authentication guard', () => {
|
||||
expect((await call.invoke()).status).toBe(401);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
for (const route of ['/api/leaderboard/players/[guid]', '/api/leaderboard-extra',
|
||||
'/api/matches', '/api/servers/[name]/matches', '/api/fleet']) {
|
||||
for (const route of [
|
||||
'/api/leaderboard/players/[guid]',
|
||||
'/api/leaderboard-extra',
|
||||
'/api/matches',
|
||||
'/api/servers/[name]/matches',
|
||||
'/api/fleet'
|
||||
]) {
|
||||
const call = requestFor(route);
|
||||
expect((await call.invoke()).status).toBe(401);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
@@ -106,173 +189,318 @@ describe('browser authentication guard', () => {
|
||||
});
|
||||
it('redirects anonymous pages to login while APIs return JSON 401 without resolving', async () => {
|
||||
const page = requestFor('/metrics', { path: '/metrics?days=7' });
|
||||
await expect(page.invoke()).rejects.toMatchObject({ status: 303, location: '/login?next=%2Fmetrics%3Fdays%3D7' });
|
||||
await expect(page.invoke()).rejects.toMatchObject({
|
||||
status: 303,
|
||||
location: '/login?next=%2Fmetrics%3Fdays%3D7'
|
||||
});
|
||||
expect(page.resolve).not.toHaveBeenCalled();
|
||||
const api = requestFor('/api/metrics'); const response = await api.invoke();
|
||||
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: false });
|
||||
expect(response.headers.get('cache-control')).toBe('no-store'); expect(api.resolve).not.toHaveBeenCalled();
|
||||
const api = requestFor('/api/metrics');
|
||||
const response = await api.invoke();
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ setupRequired: false });
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(api.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it('directs first-run browsers to setup and tells APIs setup is required', async () => {
|
||||
auth.setup = true;
|
||||
await expect(requestFor('/').invoke()).rejects.toMatchObject({ status: 303, location: '/setup' });
|
||||
await expect(requestFor('/').invoke()).rejects.toMatchObject({
|
||||
status: 303,
|
||||
location: '/setup'
|
||||
});
|
||||
const response = await requestFor('/api/fleet').invoke();
|
||||
expect(response.status).toBe(401); expect(await response.json()).toMatchObject({ setupRequired: true });
|
||||
expect(response.status).toBe(401);
|
||||
expect(await response.json()).toMatchObject({ setupRequired: true });
|
||||
expect((await requestFor('/setup').invoke()).status).toBe(200);
|
||||
});
|
||||
it('handles login/setup redirects without exposing management pages', async () => {
|
||||
await expect(requestFor('/setup').invoke()).rejects.toMatchObject({ location: '/login' });
|
||||
auth.setup = true;
|
||||
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/setup' });
|
||||
auth.setup = false; signIn('viewer');
|
||||
auth.setup = false;
|
||||
signIn('viewer');
|
||||
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/' });
|
||||
signIn('operator', true);
|
||||
await expect(requestFor('/login').invoke()).rejects.toMatchObject({ location: '/account' });
|
||||
});
|
||||
it.each([
|
||||
['viewer', '/api/fleet', 'GET', 200], ['viewer', '/api/servers', 'POST', 403],
|
||||
['viewer', '/api/configs/[id]', 'GET', 403], ['operator', '/api/configs/[id]', 'GET', 200],
|
||||
['viewer', '/api/fleet', 'GET', 200],
|
||||
['viewer', '/api/servers', 'POST', 403],
|
||||
['viewer', '/api/configs/[id]', 'GET', 403],
|
||||
['operator', '/api/configs/[id]', 'GET', 200],
|
||||
['viewer', '/api/servers/[name]/rcon-diagnostics', 'GET', 403],
|
||||
['operator', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
|
||||
['admin', '/api/servers/[name]/rcon-diagnostics', 'GET', 200],
|
||||
['operator', '/api/provisioning', 'POST', 200], ['operator', '/api/hosts/[id]', 'PATCH', 403],
|
||||
['operator', '/api/users', 'GET', 403], ['admin', '/api/hosts/[id]', 'PATCH', 200], ['admin', '/api/users', 'GET', 200]
|
||||
['operator', '/api/provisioning', 'POST', 200],
|
||||
['operator', '/api/hosts/[id]', 'PATCH', 403],
|
||||
['operator', '/api/users', 'GET', 403],
|
||||
['admin', '/api/hosts/[id]', 'PATCH', 200],
|
||||
['admin', '/api/users', 'GET', 200]
|
||||
] as const)('enforces %s role for %s %s', async (role, routeId, method, expectedStatus) => {
|
||||
const session = signIn(role); const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
|
||||
const response = await call.invoke(); expect(response.status).toBe(expectedStatus);
|
||||
if (expectedStatus === 200) { expect(call.resolve).toHaveBeenCalledOnce(); expect(call.event.locals.user).toEqual(session.user); }
|
||||
else expect(call.resolve).not.toHaveBeenCalled();
|
||||
const session = signIn(role);
|
||||
const call = requestFor(routeId, { method, body: method === 'GET' ? undefined : {} });
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
if (expectedStatus === 200) {
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(call.event.locals.user).toEqual(session.user);
|
||||
} else expect(call.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(['GET', 'HEAD'])('restricts global settings %s requests to administrators', async method => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/settings', { method });
|
||||
expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403);
|
||||
if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce();
|
||||
else expect(call.resolve).not.toHaveBeenCalled();
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'restricts global settings %s requests to administrators',
|
||||
async (method) => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/settings', { method });
|
||||
expect((await call.invoke()).status).toBe(role === 'admin' ? 200 : 403);
|
||||
if (role === 'admin') expect(call.resolve).toHaveBeenCalledOnce();
|
||||
else expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
});
|
||||
it.each(['GET', 'HEAD'])('restricts raw beta file %s requests to operators and administrators', async method => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', {
|
||||
method, path: '/api/betas/1/revisions/1/files/1'
|
||||
});
|
||||
expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200);
|
||||
if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled();
|
||||
else expect(call.resolve).toHaveBeenCalledOnce();
|
||||
);
|
||||
it.each(['GET', 'HEAD'])(
|
||||
'restricts raw beta file %s requests to operators and administrators',
|
||||
async (method) => {
|
||||
for (const role of ['viewer', 'operator', 'admin'] as const) {
|
||||
signIn(role);
|
||||
const call = requestFor('/api/betas/[id]/revisions/[revision]/files/[scriptId]', {
|
||||
method,
|
||||
path: '/api/betas/1/revisions/1/files/1'
|
||||
});
|
||||
expect((await call.invoke()).status).toBe(role === 'viewer' ? 403 : 200);
|
||||
if (role === 'viewer') expect(call.resolve).not.toHaveBeenCalled();
|
||||
else expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
it('requires temporary-password users to change it even if their role is administrator', async () => {
|
||||
signIn('admin', true);
|
||||
await expect(requestFor('/settings').invoke()).rejects.toMatchObject({ status: 303, location: '/account' });
|
||||
await expect(requestFor('/settings').invoke()).rejects.toMatchObject({
|
||||
status: 303,
|
||||
location: '/account'
|
||||
});
|
||||
const blocked = await requestFor('/api/users').invoke();
|
||||
expect(blocked.status).toBe(403); expect(await blocked.json()).toMatchObject({ mustChangePassword: true });
|
||||
for (const [route, method] of [['/account', 'GET'], ['/api/auth/me', 'GET'], ['/api/auth/password', 'POST'], ['/api/auth/logout', 'POST']]) {
|
||||
expect((await requestFor(route, { method, body: method === 'POST' ? {} : undefined }).invoke()).status).toBe(200);
|
||||
expect(blocked.status).toBe(403);
|
||||
expect(await blocked.json()).toMatchObject({ mustChangePassword: true });
|
||||
for (const [route, method] of [
|
||||
['/account', 'GET'],
|
||||
['/api/auth/me', 'GET'],
|
||||
['/api/auth/password', 'POST'],
|
||||
['/api/auth/logout', 'POST']
|
||||
]) {
|
||||
expect(
|
||||
(
|
||||
await requestFor(route, {
|
||||
method,
|
||||
body: method === 'POST' ? {} : undefined
|
||||
}).invoke()
|
||||
).status
|
||||
).toBe(200);
|
||||
}
|
||||
});
|
||||
it('adds private response headers and audits successful writes without recording request secrets', async () => {
|
||||
signIn('admin');
|
||||
const call = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'secret-server-key' } });
|
||||
const call = requestFor('/api/servers', {
|
||||
method: 'POST',
|
||||
body: { plutoKey: 'secret-server-key' }
|
||||
});
|
||||
const response = await call.invoke();
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(response.headers.get('vary')).toContain('Cookie');
|
||||
expect(response.headers.get('x-frame-options')).toBe('DENY');
|
||||
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
|
||||
expect(auth.recordAudit).toHaveBeenCalledWith('user-1', 'request', '/api/servers', { method: 'POST', status: 200, path: '/api/servers' });
|
||||
expect(auth.recordAudit).toHaveBeenCalledWith('user-1', 'request', '/api/servers', {
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
path: '/api/servers'
|
||||
});
|
||||
expect(JSON.stringify(auth.recordAudit.mock.calls)).not.toContain('secret-server-key');
|
||||
});
|
||||
it('does not audit denied or unsuccessful writes as successful actions', async () => {
|
||||
signIn('viewer'); await requestFor('/api/servers', { method: 'POST', body: {} }).invoke();
|
||||
signIn('viewer');
|
||||
await requestFor('/api/servers', { method: 'POST', body: {} }).invoke();
|
||||
expect(auth.recordAudit).not.toHaveBeenCalled();
|
||||
signIn('admin'); await requestFor('/api/servers', { method: 'POST', body: {}, resolve: async () => new Response('bad', { status: 400 }) }).invoke();
|
||||
signIn('admin');
|
||||
await requestFor('/api/servers', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
resolve: async () => new Response('bad', { status: 400 })
|
||||
}).invoke();
|
||||
expect(auth.recordAudit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin and transport enforcement', () => {
|
||||
it.each(['/api/auth/login', '/api/auth/setup', '/api/servers'])('requires exact Origin for %s, including public auth requests', async route => {
|
||||
signIn('admin');
|
||||
for (const origin of [null, 'null', 'https://attacker.test', 'https://ec.test.attacker.test', 'http://ec.test', 'https://ec.test:8443']) {
|
||||
const call = requestFor(route, { method: 'POST', origin, body: {} });
|
||||
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
|
||||
it.each(['/api/auth/login', '/api/auth/setup', '/api/servers'])(
|
||||
'requires exact Origin for %s, including public auth requests',
|
||||
async (route) => {
|
||||
signIn('admin');
|
||||
for (const origin of [
|
||||
null,
|
||||
'null',
|
||||
'https://attacker.test',
|
||||
'https://ec.test.attacker.test',
|
||||
'http://ec.test',
|
||||
'https://ec.test:8443'
|
||||
]) {
|
||||
const call = requestFor(route, { method: 'POST', origin, body: {} });
|
||||
expect((await call.invoke()).status).toBe(403);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
expect((await requestFor(route, { method: 'POST', body: {} }).invoke()).status).toBe(
|
||||
200
|
||||
);
|
||||
}
|
||||
expect((await requestFor(route, { method: 'POST', body: {} }).invoke()).status).toBe(200);
|
||||
});
|
||||
);
|
||||
it('rejects explicitly cross-site requests even if their Origin header matches', async () => {
|
||||
signIn('admin');
|
||||
const call = requestFor('/api/auth/login', { method: 'POST', body: {}, headers: { 'Sec-Fetch-Site': 'cross-site' } });
|
||||
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
|
||||
const call = requestFor('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
headers: { 'Sec-Fetch-Site': 'cross-site' }
|
||||
});
|
||||
expect((await call.invoke()).status).toBe(403);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it('applies origin validation to multipart artifact uploads without consuming their body', async () => {
|
||||
signIn('operator');
|
||||
const form = new FormData(); form.set('name', 'script'); form.set('content', new Blob(['script bytes']), 'test.gsc');
|
||||
const form = new FormData();
|
||||
form.set('name', 'script');
|
||||
form.set('content', new Blob(['script bytes']), 'test.gsc');
|
||||
const denied = requestFor('/api/scripts', { method: 'POST', origin: null, rawBody: form });
|
||||
expect((await denied.invoke()).status).toBe(403);
|
||||
const allowed = requestFor('/api/scripts', { method: 'POST', rawBody: form, resolve: async event => {
|
||||
expect((await event.request.formData()).get('name')).toBe('script'); return new Response('uploaded', { status: 201 });
|
||||
} });
|
||||
const allowed = requestFor('/api/scripts', {
|
||||
method: 'POST',
|
||||
rawBody: form,
|
||||
resolve: async (event) => {
|
||||
expect((await event.request.formData()).get('name')).toBe('script');
|
||||
return new Response('uploaded', { status: 201 });
|
||||
}
|
||||
});
|
||||
expect((await allowed.invoke()).status).toBe(201);
|
||||
});
|
||||
it('requires HTTPS by default even on loopback', async () => {
|
||||
signIn('admin');
|
||||
for (const base of ['http://localhost', 'http://127.0.0.1', 'http://[::1]', 'http://private.lan']) {
|
||||
for (const base of [
|
||||
'http://localhost',
|
||||
'http://127.0.0.1',
|
||||
'http://[::1]',
|
||||
'http://private.lan'
|
||||
]) {
|
||||
const call = requestFor('/api/fleet', { base });
|
||||
expect((await call.invoke()).status).toBe(426); expect(call.resolve).not.toHaveBeenCalled();
|
||||
expect((await call.invoke()).status).toBe(426);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
it('permits insecure developer sessions only with the explicit flag and a literal loopback hostname', async () => {
|
||||
signIn('admin'); vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'true');
|
||||
for (const base of ['http://localhost:3000', 'http://127.0.0.1:3000', 'http://[::1]:3000']) {
|
||||
const call = requestFor('/api/fleet', { base }); expect((await call.invoke()).status).toBe(200);
|
||||
signIn('admin');
|
||||
vi.stubEnv('EC_AUTH_ALLOW_HTTP', 'true');
|
||||
for (const base of [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'http://[::1]:3000'
|
||||
]) {
|
||||
const call = requestFor('/api/fleet', { base });
|
||||
expect((await call.invoke()).status).toBe(200);
|
||||
expect(call.cookies.get).toHaveBeenCalledWith('ec_session_dev');
|
||||
}
|
||||
for (const base of ['http://private.lan', 'http://localhost.attacker.test', 'http://192.168.1.2']) {
|
||||
for (const base of [
|
||||
'http://private.lan',
|
||||
'http://localhost.attacker.test',
|
||||
'http://192.168.1.2'
|
||||
]) {
|
||||
expect((await requestFor('/api/fleet', { base }).invoke()).status).toBe(426);
|
||||
}
|
||||
const secure = requestFor('/api/fleet'); await secure.invoke(); expect(secure.cookies.get).toHaveBeenCalledWith('ec_session');
|
||||
const secure = requestFor('/api/fleet');
|
||||
await secure.invoke();
|
||||
expect(secure.cookies.get).toHaveBeenCalledWith('ec_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('machine and route identity boundaries', () => {
|
||||
it.each([['/api/agent/operations', 'GET'], ['/api/agent/enroll', 'POST'], ['/api/agent/heartbeat', 'POST'], ['/api/telemetry', 'POST']] as const)('passes %s to its machine authentication without consulting browser sessions', async (routeId, method) => {
|
||||
auth.readSession.mockImplementation(() => { throw new Error('Browser session must not be consulted'); });
|
||||
auth.setupRequired.mockImplementation(() => { throw new Error('Browser setup must not be consulted'); });
|
||||
const call = requestFor(routeId, { method, origin: null, base: 'http://host.lan', body: method === 'POST' ? {} : undefined,
|
||||
resolve: async () => new Response('machine credential rejected', { status: 401 }) });
|
||||
const response = await call.invoke(); expect(response.status).toBe(401); expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(auth.readSession).not.toHaveBeenCalled(); expect(auth.setupRequired).not.toHaveBeenCalled();
|
||||
expect(call.event.locals.user).toBeNull();
|
||||
});
|
||||
it.each([
|
||||
['/api/agent/operations', 'GET'],
|
||||
['/api/agent/enroll', 'POST'],
|
||||
['/api/agent/heartbeat', 'POST'],
|
||||
['/api/telemetry', 'POST']
|
||||
] as const)(
|
||||
'passes %s to its machine authentication without consulting browser sessions',
|
||||
async (routeId, method) => {
|
||||
auth.readSession.mockImplementation(() => {
|
||||
throw new Error('Browser session must not be consulted');
|
||||
});
|
||||
auth.setupRequired.mockImplementation(() => {
|
||||
throw new Error('Browser setup must not be consulted');
|
||||
});
|
||||
const call = requestFor(routeId, {
|
||||
method,
|
||||
origin: null,
|
||||
base: 'http://host.lan',
|
||||
body: method === 'POST' ? {} : undefined,
|
||||
resolve: async () => new Response('machine credential rejected', { status: 401 })
|
||||
});
|
||||
const response = await call.invoke();
|
||||
expect(response.status).toBe(401);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
expect(auth.readSession).not.toHaveBeenCalled();
|
||||
expect(auth.setupRequired).not.toHaveBeenCalled();
|
||||
expect(call.event.locals.user).toBeNull();
|
||||
}
|
||||
);
|
||||
it('does not allow host/enrollment bearer credentials to substitute for operator access even with a browser cookie', async () => {
|
||||
signIn('admin');
|
||||
for (const authorization of ['Bearer ec_host_secret', 'Bearer ec_enroll_secret']) {
|
||||
const call = requestFor('/api/fleet', { headers: { Authorization: authorization } });
|
||||
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
|
||||
expect((await call.invoke()).status).toBe(403);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
const machine = requestFor('/api/agent/operations', { headers: { Authorization: 'Bearer ec_host_secret' } });
|
||||
expect((await machine.invoke()).status).toBe(200); expect(machine.resolve).toHaveBeenCalledOnce();
|
||||
const machine = requestFor('/api/agent/operations', {
|
||||
headers: { Authorization: 'Bearer ec_host_secret' }
|
||||
});
|
||||
expect((await machine.invoke()).status).toBe(200);
|
||||
expect(machine.resolve).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('protects encoded management paths and Svelte page data using the resolved route ID', async () => {
|
||||
signIn('viewer');
|
||||
for (const path of ['/api/%75sers', '/api/%61gent/../users', '/_app/pretend-public.js']) {
|
||||
const call = requestFor('/api/users', { path });
|
||||
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
|
||||
expect((await call.invoke()).status).toBe(403);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
const data = requestFor('/settings/users', { path: '/settings/users/__data.json' });
|
||||
await expect(data.invoke()).rejects.toMatchObject({ status: 403 }); expect(data.resolve).not.toHaveBeenCalled();
|
||||
const rawConfig = requestFor('/api/configs/[id]', { path: '/api/configs/123?x=/_app/public.css' });
|
||||
await expect(data.invoke()).rejects.toMatchObject({ status: 403 });
|
||||
expect(data.resolve).not.toHaveBeenCalled();
|
||||
const rawConfig = requestFor('/api/configs/[id]', {
|
||||
path: '/api/configs/123?x=/_app/public.css'
|
||||
});
|
||||
expect((await rawConfig.invoke()).status).toBe(403);
|
||||
});
|
||||
it('exempts only static assets on unresolved routes and does not use arbitrary file extensions as public access', async () => {
|
||||
for (const path of ['/_app/immutable/start.js', '/favicon.svg', '/agent/ec-agent.py', '/agent/ec-agent-update.py', '/agent/install.sh', '/agent/manifest.json']) {
|
||||
const call = requestFor(null, { path }); expect((await call.invoke()).status).toBe(200); expect(call.resolve).toHaveBeenCalledOnce();
|
||||
for (const path of [
|
||||
'/_app/immutable/start.js',
|
||||
'/favicon.svg',
|
||||
'/agent/ec-agent.py',
|
||||
'/agent/ec-agent-update.py',
|
||||
'/agent/install.sh',
|
||||
'/agent/manifest.json'
|
||||
]) {
|
||||
const call = requestFor(null, { path });
|
||||
expect((await call.invoke()).status).toBe(200);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
for (const path of ['/private.json', '/secrets.css', '/settings/users/__data.json', '/agent/ec-agent-update.py.bak', '/agent/update-status.json']) {
|
||||
const call = requestFor(null, { path }); await expect(call.invoke()).rejects.toMatchObject({ status: 303 }); expect(call.resolve).not.toHaveBeenCalled();
|
||||
for (const path of [
|
||||
'/private.json',
|
||||
'/secrets.css',
|
||||
'/settings/users/__data.json',
|
||||
'/agent/ec-agent-update.py.bak',
|
||||
'/agent/update-status.json'
|
||||
]) {
|
||||
const call = requestFor(null, { path });
|
||||
await expect(call.invoke()).rejects.toMatchObject({ status: 303 });
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
}
|
||||
const write = requestFor(null, { path: '/_app/anything.js', method: 'POST', body: {} });
|
||||
await expect(write.invoke()).rejects.toMatchObject({ status: 303 }); expect(write.resolve).not.toHaveBeenCalled();
|
||||
await expect(write.invoke()).rejects.toMatchObject({ status: 303 });
|
||||
expect(write.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,26 +509,52 @@ describe('operator launch credential restrictions', () => {
|
||||
['/api/servers', { plutoKey: 'new-key' }],
|
||||
['/api/deployment-collections', { keys: { t5_zombie_theater_1: 'new-key' } }]
|
||||
])('rejects supplied administrator-managed keys at %s', async (route, body) => {
|
||||
signIn('operator'); const call = requestFor(route as string, { method: 'POST', body });
|
||||
expect((await call.invoke()).status).toBe(403); expect(call.resolve).not.toHaveBeenCalled();
|
||||
signIn('operator');
|
||||
const call = requestFor(route as string, { method: 'POST', body });
|
||||
expect((await call.invoke()).status).toBe(403);
|
||||
expect(call.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([
|
||||
['/api/servers', { previewId: 'reviewed', plutoKey: '' }],
|
||||
['/api/servers', { previewId: 'reviewed' }],
|
||||
['/api/deployment-collections', { previewId: 'reviewed', keys: { t5_zombie_theater_1: '' } }],
|
||||
[
|
||||
'/api/deployment-collections',
|
||||
{ previewId: 'reviewed', keys: { t5_zombie_theater_1: '' } }
|
||||
],
|
||||
['/api/deployment-collections', { previewId: 'reviewed', keys: {} }]
|
||||
])('allows saved-key launches at %s and preserves the body for the handler', async (route, body) => {
|
||||
signIn('operator');
|
||||
const call = requestFor(route as string, { method: 'POST', body, resolve: async event => {
|
||||
expect(await event.request.json()).toEqual(body); return new Response('queued', { status: 202 });
|
||||
} });
|
||||
expect((await call.invoke()).status).toBe(202); expect(call.resolve).toHaveBeenCalledOnce();
|
||||
});
|
||||
])(
|
||||
'allows saved-key launches at %s and preserves the body for the handler',
|
||||
async (route, body) => {
|
||||
signIn('operator');
|
||||
const call = requestFor(route as string, {
|
||||
method: 'POST',
|
||||
body,
|
||||
resolve: async (event) => {
|
||||
expect(await event.request.json()).toEqual(body);
|
||||
return new Response('queued', { status: 202 });
|
||||
}
|
||||
});
|
||||
expect((await call.invoke()).status).toBe(202);
|
||||
expect(call.resolve).toHaveBeenCalledOnce();
|
||||
}
|
||||
);
|
||||
it('allows administrators to supply keys and rejects oversized operator launch bodies before handler execution', async () => {
|
||||
signIn('admin'); expect((await requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'new-key' } }).invoke()).status).toBe(200);
|
||||
signIn('admin');
|
||||
expect(
|
||||
(
|
||||
await requestFor('/api/servers', {
|
||||
method: 'POST',
|
||||
body: { plutoKey: 'new-key' }
|
||||
}).invoke()
|
||||
).status
|
||||
).toBe(200);
|
||||
signIn('operator');
|
||||
const tooLarge = requestFor('/api/servers', { method: 'POST', body: { plutoKey: 'x'.repeat(1024 * 1024 + 1) } });
|
||||
expect((await tooLarge.invoke()).status).toBe(413); expect(tooLarge.resolve).not.toHaveBeenCalled();
|
||||
const tooLarge = requestFor('/api/servers', {
|
||||
method: 'POST',
|
||||
body: { plutoKey: 'x'.repeat(1024 * 1024 + 1) }
|
||||
});
|
||||
expect((await tooLarge.invoke()).status).toBe(413);
|
||||
expect(tooLarge.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,41 +562,93 @@ describe('authenticated event streams', () => {
|
||||
function streaming() {
|
||||
let source!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const cancel = vi.fn();
|
||||
const response = new Response(new ReadableStream<Uint8Array>({ start(controller) { source = controller; }, cancel }), { headers: { 'Content-Type': 'text/event-stream' } });
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
source = controller;
|
||||
},
|
||||
cancel
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'text/event-stream' } }
|
||||
);
|
||||
const abort = new AbortController();
|
||||
return { response, source, cancel, abort };
|
||||
}
|
||||
it.each(['revoked', 'password-reset', 'role-demoted', 'storage-failure'] as const)('closes and cancels an established event stream when %s', async change => {
|
||||
vi.useFakeTimers(); signIn('operator'); const source = streaming();
|
||||
const protectedResponse = protectEventStream(source.response, 'hash-of-session', 'operator', source.abort.signal);
|
||||
const reader = protectedResponse.body!.getReader();
|
||||
source.source.enqueue(new TextEncoder().encode('event: test\n\n'));
|
||||
expect((await reader.read()).done).toBe(false);
|
||||
if (change === 'revoked') auth.current = null;
|
||||
if (change === 'password-reset') auth.current = { ...auth.current!, user: { ...auth.current!.user, mustChangePassword: true } };
|
||||
if (change === 'role-demoted') auth.current = { ...auth.current!, user: { ...auth.current!.user, role: 'viewer' } };
|
||||
if (change === 'storage-failure') auth.readSessionHash.mockImplementation(() => { throw new Error('Storage unavailable'); });
|
||||
const ended = reader.read(); await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it.each(['revoked', 'password-reset', 'role-demoted', 'storage-failure'] as const)(
|
||||
'closes and cancels an established event stream when %s',
|
||||
async (change) => {
|
||||
vi.useFakeTimers();
|
||||
signIn('operator');
|
||||
const source = streaming();
|
||||
const protectedResponse = protectEventStream(
|
||||
source.response,
|
||||
'hash-of-session',
|
||||
'operator',
|
||||
source.abort.signal
|
||||
);
|
||||
const reader = protectedResponse.body!.getReader();
|
||||
source.source.enqueue(new TextEncoder().encode('event: test\n\n'));
|
||||
expect((await reader.read()).done).toBe(false);
|
||||
if (change === 'revoked') auth.current = null;
|
||||
if (change === 'password-reset')
|
||||
auth.current = {
|
||||
...auth.current!,
|
||||
user: { ...auth.current!.user, mustChangePassword: true }
|
||||
};
|
||||
if (change === 'role-demoted')
|
||||
auth.current = {
|
||||
...auth.current!,
|
||||
user: { ...auth.current!.user, role: 'viewer' }
|
||||
};
|
||||
if (change === 'storage-failure')
|
||||
auth.readSessionHash.mockImplementation(() => {
|
||||
throw new Error('Storage unavailable');
|
||||
});
|
||||
const ended = reader.read();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(await ended).toEqual({ done: true, value: undefined });
|
||||
expect(source.cancel).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
}
|
||||
);
|
||||
it('rechecks valid streams without refreshing idle sessions and cancels upstream when the request aborts', async () => {
|
||||
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
|
||||
const response = protectEventStream(source.response, 'hash-of-session', 'viewer', source.abort.signal);
|
||||
const reader = response.body!.getReader(); const ended = reader.read();
|
||||
vi.useFakeTimers();
|
||||
signIn('viewer');
|
||||
const source = streaming();
|
||||
const response = protectEventStream(
|
||||
source.response,
|
||||
'hash-of-session',
|
||||
'viewer',
|
||||
source.abort.signal
|
||||
);
|
||||
const reader = response.body!.getReader();
|
||||
const ended = reader.read();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(auth.readSessionHash).toHaveBeenCalledWith('hash-of-session'); expect(source.cancel).not.toHaveBeenCalled();
|
||||
expect(auth.readSessionHash).toHaveBeenCalledWith('hash-of-session');
|
||||
expect(source.cancel).not.toHaveBeenCalled();
|
||||
source.abort.abort();
|
||||
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
|
||||
expect(await ended).toEqual({ done: true, value: undefined });
|
||||
expect(source.cancel).toHaveBeenCalledOnce();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
it('wraps event-stream responses at the guard while preserving content type and denying anonymous subscriptions', async () => {
|
||||
const anonymous = requestFor('/api/events/servers'); expect((await anonymous.invoke()).status).toBe(401); expect(anonymous.resolve).not.toHaveBeenCalled();
|
||||
vi.useFakeTimers(); signIn('viewer'); const source = streaming();
|
||||
const call = requestFor('/api/events/servers', { signal: source.abort.signal, resolve: async () => source.response });
|
||||
const response = await call.invoke(); expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader(); const ended = reader.read();
|
||||
auth.current = null; await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(await ended).toEqual({ done: true, value: undefined }); expect(source.cancel).toHaveBeenCalledOnce();
|
||||
const anonymous = requestFor('/api/events/servers');
|
||||
expect((await anonymous.invoke()).status).toBe(401);
|
||||
expect(anonymous.resolve).not.toHaveBeenCalled();
|
||||
vi.useFakeTimers();
|
||||
signIn('viewer');
|
||||
const source = streaming();
|
||||
const call = requestFor('/api/events/servers', {
|
||||
signal: source.abort.signal,
|
||||
resolve: async () => source.response
|
||||
});
|
||||
const response = await call.invoke();
|
||||
expect(response.headers.get('content-type')).toBe('text/event-stream');
|
||||
const reader = response.body!.getReader();
|
||||
const ended = reader.read();
|
||||
auth.current = null;
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(await ended).toEqual({ done: true, value: undefined });
|
||||
expect(source.cancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user