From 15fb90200a02c8339cd4c5e3c45a06a9a3fae0c4 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Fri, 9 Oct 2026 16:57:22 -0400 Subject: [PATCH] Restrict expensive viewer endpoints (Refs #72) --- docs/guides/authentication.md | 5 +++++ src/lib/server/auth/access.ts | 3 +++ tests/unit/auth-guard.test.ts | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/docs/guides/authentication.md b/docs/guides/authentication.md index 54325b5..f413f7e 100644 --- a/docs/guides/authentication.md +++ b/docs/guides/authentication.md @@ -47,6 +47,11 @@ cannot save keys indirectly through a launch request. An operator's content permissions include executable scripts and plugins; assign that role only to people trusted to operate the game fleet. +Drift previews (`/api/drift`) and GitHub release lookups +(`/api/plugins/github/release`) require operator or administrator access, including +`GET` and `HEAD` requests. These endpoints initiate fleet file scans or use GitHub +API quota; viewers cannot trigger them. + RCON failure replies are available only to operators and administrators in the server's Diagnostics tab. Player addresses are redacted before diagnostics are saved; successful checks clear the saved reply. Fleet snapshots and live status diff --git a/src/lib/server/auth/access.ts b/src/lib/server/auth/access.ts index d094c7d..727eca4 100644 --- a/src/lib/server/auth/access.ts +++ b/src/lib/server/auth/access.ts @@ -60,6 +60,9 @@ export function routePermission(routeId: string | null, method: string): RoutePe if (!reading && ['/api/maintenance', '/api/plutonium', '/api/watchdog'].includes(routeId)) return 'admin'; + // These reads initiate fleet file scans or spend external API quota. + if (routeId === '/api/drift' || routeId === '/api/plugins/github/release') return 'operator'; + // Summaries remain visible to viewers; raw configuration, historical file // bytes, and unfiltered diagnostics may contain runtime credentials. if ( diff --git a/tests/unit/auth-guard.test.ts b/tests/unit/auth-guard.test.ts index eff5693..070ae6e 100644 --- a/tests/unit/auth-guard.test.ts +++ b/tests/unit/auth-guard.test.ts @@ -272,6 +272,24 @@ describe('browser authentication guard', () => { } } ); + it.each([ + ['GET', '/api/drift'], + ['HEAD', '/api/drift'], + ['GET', '/api/plugins/github/release'], + ['HEAD', '/api/plugins/github/release'] + ])('restricts expensive %s %s requests before handler execution', async (method, route) => { + const anonymous = requestFor(route, { method, cookie: null }); + expect((await anonymous.invoke()).status).toBe(401); + expect(anonymous.resolve).not.toHaveBeenCalled(); + + for (const role of ['viewer', 'operator', 'admin'] as const) { + signIn(role); + const call = requestFor(route, { method }); + 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({