fix(fleet-secrets): restrict bundle management to admin hub sessions (#1274)

* fix(fleet-secrets): restrict bundle management to admin hub sessions

Fleet Secrets exposes decrypted environment-variable values and writes
credentials across the fleet, so every route now requires an admin role,
runs only on the instance you are signed into, and rejects long-lived API
tokens:

- Add an admin-role check to all secrets routes; the frontend Secrets tab
  renders only for admin users so the affordance matches the backend gate.
- Add /api/secrets/ to the hub-only path list so a request carrying a
  remote node id cannot be proxied to read another node's decrypted values.
- Reject API tokens on every secrets route (browser admin sessions only),
  matching how registry credentials are handled.

Also adds lifecycle and developer-mode diagnostic logging (never the secret
values) and tests covering the admin boundary on every endpoint, API-token
rejection, hub-only enforcement, and diagnostic gating.

* fix(fleet-secrets): require a signed-in user session for all secrets routes

The earlier API-token rejection only blocked opaque API tokens. node_proxy
and pilot_tunnel JWTs are mapped to an admin role by the auth middleware
without an API-token scope, so they still passed the admin gate and could
read decrypted bundles via GET /api/secrets/:id.

Replace the API-token check with requireUserSession, which rejects API tokens
and node_proxy / pilot_tunnel machine credentials (userId 0) on every secrets
route, returning SESSION_REQUIRED. The admin role is still enforced after.

Tests now assert SESSION_REQUIRED for a full-admin API token across all nine
routes and for node_proxy and pilot_tunnel JWTs.

* test(fleet-secrets): mint the rejection-test token via the real endpoint

The machine-credential test reconstructed an API token by sha256-hashing a
raw key inline. That duplicated a hashing sink that CodeQL's
js/insufficient-password-hash query flags (a false positive for a 256-bit
random token, but a new occurrence in the diff). Create the token through
POST /api/api-tokens instead, so the hashing stays in the production path
and the test carries none of its own. Behavior and coverage are unchanged.
This commit is contained in:
Anso
2026-06-01 19:47:06 -04:00
committed by GitHub
parent 53be6a258e
commit 9fb4ccccff
7 changed files with 227 additions and 9 deletions
@@ -201,6 +201,31 @@ describe('hubOnlyGuard', () => {
expect(res.body?.code).not.toBe('HUB_ONLY_ENDPOINT');
});
// Regression for Fleet Secrets: bundles are stored and decrypted per instance,
// and GET /api/secrets/:id returns plaintext values. A proxied secrets request
// would read a remote node's decrypted values as the node-proxy admin, and the
// routes' own requireAdmin gate lives in the local handler the proxy skips.
// Cover the collection (no trailing slash) and a sub-path.
it('rejects /api/secrets with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('rejects a secrets sub-path (/api/secrets/5) with 403 when nodeId targets a remote node', async () => {
const res = await request(app)
.get('/api/secrets/5')
.set('Authorization', authHeader)
.set('x-node-id', String(remoteNodeId));
expect(res.status).toBe(403);
expect(res.body?.code).toBe('HUB_ONLY_ENDPOINT');
});
it('does not interfere with non-hub paths even when nodeId targets a remote node', async () => {
// /api/stacks is not hub-only and should be forwarded by the proxy.
// The exact upstream-error status is not the contract here; what
+144 -2
View File
@@ -5,10 +5,12 @@
* - encrypt round-trip via CryptoService
* - DatabaseService secret + version + push CRUD
* - SecretsService versioning, importFromStack, executePush aggregation
* - Route guards (requirePaid 403, push lock 409)
* - Route guards (requirePaid 403, requireAdmin 403, requireUserSession 403, push lock 409)
* - Hub-only enforcement is covered in hub-only-guard.test.ts
* - developer_mode diagnostics gating (and that diagnostics never log the secret value)
* - getAuditSummary patterns for /secrets routes
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import path from 'path';
import fs from 'fs';
@@ -42,6 +44,30 @@ function clearSecretsTables(): void {
db.prepare('DELETE FROM secrets').run();
}
// Shared by the admin-role and machine-credential matrices: one representative
// call per secrets endpoint. requireUserSession and requireAdmin both run before
// requireBody and param parsing, so a bodyless request still surfaces the guard.
const SECRET_ENDPOINTS: Array<[string, string]> = [
['get', '/api/secrets'],
['post', '/api/secrets'],
['get', '/api/secrets/1'],
['put', '/api/secrets/1'],
['delete', '/api/secrets/1'],
['get', '/api/secrets/1/versions'],
['post', '/api/secrets/1/import-from-stack'],
['post', '/api/secrets/1/push/preview'],
['post', '/api/secrets/1/push'],
];
function callWithToken(method: string, p: string, token: string) {
const agent = request(app);
const r = method === 'get' ? agent.get(p)
: method === 'post' ? agent.post(p)
: method === 'put' ? agent.put(p)
: agent.delete(p);
return r.set('Authorization', `Bearer ${token}`);
}
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
@@ -399,3 +425,119 @@ describe('Routes /api/secrets tier gating and lock', () => {
expect(res.status).toBe(400);
});
});
// ---- Admin-role gating: secrets reveal decrypted values, so every route is admin-only ----
describe('Routes /api/secrets admin-role gating', () => {
// authMiddleware resolves the role from the DB (not the JWT), so a real
// non-admin user must exist for the gate to see a non-admin role.
function viewerToken(): string {
const db = DatabaseService.getInstance();
let user = db.getUserByUsername('sec-viewer');
if (!user) {
db.addUser({ username: 'sec-viewer', password_hash: 'x', role: 'viewer' });
user = db.getUserByUsername('sec-viewer')!;
}
return authToken('sec-viewer', 'viewer', user.token_version);
}
it.each(SECRET_ENDPOINTS)('403s a non-admin paid user on %s %s', async (method, p) => {
const res = await callWithToken(method, p, viewerToken());
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('lets an admin paid user list (200)', async () => {
const res = await request(app)
.get('/api/secrets')
.set('Authorization', `Bearer ${adminToken()}`);
expect(res.status).toBe(200);
});
});
// ---- Machine-credential rejection: secrets need a real signed-in user session ----
describe('Routes /api/secrets machine-credential rejection', () => {
// A full-admin API token resolves to role 'admin' and would otherwise pass
// requireAdmin and reach the decrypted-value GET. requireUserSession runs
// first and blocks it. Mint the token through the real endpoint so the test
// exercises the production creation path and carries no hashing of its own.
let fullAdminToken: string;
beforeAll(async () => {
const res = await request(app)
.post('/api/api-tokens')
.set('Authorization', `Bearer ${adminToken()}`)
.send({ name: `secrets-rejection-${Date.now()}`, scope: 'full-admin' });
fullAdminToken = res.body.token as string;
});
// node_proxy / pilot_tunnel JWTs are signed with this instance's secret and
// map to { username: 'node-proxy', role: 'admin', userId: 0 } in authMiddleware.
// They carry no apiTokenScope, so only the userId-0 check blocks them.
function machineJwt(scope: 'node_proxy' | 'pilot_tunnel'): string {
return jwt.sign({ scope }, TEST_JWT_SECRET, { expiresIn: '1m' });
}
it.each(SECRET_ENDPOINTS)('403s a full-admin API token on %s %s with SESSION_REQUIRED', async (method, p) => {
const res = await callWithToken(method, p, fullAdminToken);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SESSION_REQUIRED');
});
it.each([
['node_proxy', '/api/secrets'],
['node_proxy', '/api/secrets/1'],
['pilot_tunnel', '/api/secrets/1'],
] as const)('403s a %s JWT on %s with SESSION_REQUIRED', async (scope, p) => {
const res = await request(app)
.get(p)
.set('Authorization', `Bearer ${machineJwt(scope)}`);
expect(res.status).toBe(403);
expect(res.body.code).toBe('SESSION_REQUIRED');
});
});
// ---- developer_mode diagnostics gating ----
describe('SecretsService.executePush developer_mode diagnostics', () => {
beforeEach(() => {
const composeDir = process.env.COMPOSE_DIR!;
const stackDir = path.join(composeDir, 'devmodestack');
fs.mkdirSync(stackDir, { recursive: true });
fs.writeFileSync(path.join(stackDir, '.env'), 'EXISTING=keep\n');
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
});
afterEach(() => {
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
});
async function runPush(name: string): Promise<void> {
const db = DatabaseService.getInstance();
const localNode = db.getNodes().find(n => n.type === 'local')!;
const svc = SecretsService.getInstance();
const { id } = svc.create({ name, kv: { TOKEN: 'supersecretvalue' }, user: TEST_USERNAME });
await svc.executePush(id, { type: 'nodes', ids: [localNode.id] }, 'devmodestack', '.env', TEST_USERNAME);
}
it('emits [Secrets:diag] only when developer_mode is on, and never the secret value', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
try {
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
await runPush('devmode-off');
const offLogs = logSpy.mock.calls.map(c => c.join(' '));
expect(offLogs.some(l => l.includes('[Secrets:diag]'))).toBe(false);
logSpy.mockClear();
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
await runPush('devmode-on');
const onLogs = logSpy.mock.calls.map(c => c.join(' '));
expect(onLogs.some(l => l.includes('[Secrets:diag]'))).toBe(true);
// Diagnostics summarize counts only; the decrypted value must never appear.
expect([...offLogs, ...onLogs].some(l => l.includes('supersecretvalue'))).toBe(false);
} finally {
logSpy.mockRestore();
}
});
});