Restrict expensive viewer endpoints (Refs #72)
CI / checks (push) Successful in 4m45s

This commit is contained in:
2026-10-09 16:57:22 -04:00
parent 9eb3aa84c3
commit 15fb90200a
3 changed files with 26 additions and 0 deletions
+5
View File
@@ -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
+3
View File
@@ -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 (
+18
View File
@@ -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({