mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user