fix(security): gate production diagnostics

This commit is contained in:
Saravana Achu Mac 2026-05-05 09:51:29 -07:00
parent 9d3c0774ec
commit 56a051a422
4 changed files with 100 additions and 30 deletions

View File

@ -1,27 +1,16 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { getAllFlags } from './lib/feature-flags.js';
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
import { SignJWT } from 'jose';
import { config } from './lib/config.js';
import { PRODUCT_ID, productConfig } from './lib/product-config.js';
import { productConfig } from './lib/product-config.js';
import { assertDiagnosticsAccess, diagnosticsRoutes } from './lib/diagnostics-routes.js';
let app: FastifyInstance;
beforeAll(async () => {
app = Fastify({ logger: false });
app.get('/api/diagnostics/flags', async () => getAllFlags());
app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() }));
app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length }));
app.get('/api/diagnostics/config', async () => ({
productId: PRODUCT_ID,
serviceName: config.SERVICE_NAME,
port: config.PORT,
nodeEnv: config.NODE_ENV,
dbProvider: config.DB_PROVIDER,
telemetryEnabled: config.TELEMETRY_ENABLED,
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
}));
await diagnosticsRoutes(app);
app.get('/health', async (req) => ({
status: 'ok',
service: config.SERVICE_NAME,
@ -41,6 +30,18 @@ afterAll(async () => {
await app.close();
});
function makeToken(role: string) {
return new SignJWT({
sub: 'diagnostics-user',
role,
type: 'access',
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.sign(new TextEncoder().encode(config.JWT_SECRET));
}
describe('diagnostics routes', () => {
it('GET /api/diagnostics/flags returns feature flags', async () => {
const res = await app.inject({ method: 'GET', url: '/api/diagnostics/flags' });
@ -101,4 +102,29 @@ describe('diagnostics routes', () => {
expect(typeof body.displayName).toBe('string');
expect(typeof body.backendPort).toBe('number');
});
it('keeps diagnostics open outside production for local smoke ergonomics', async () => {
await expect(assertDiagnosticsAccess({ headers: {} }, 'development')).resolves.toBeUndefined();
await expect(assertDiagnosticsAccess({ headers: {} }, 'test')).resolves.toBeUndefined();
});
it('requires auth for diagnostics in production', async () => {
await expect(assertDiagnosticsAccess({ headers: {} }, 'production')).rejects.toThrow('Unauthorized');
});
it('allows production diagnostics for admin or owner roles only', async () => {
const adminToken = await makeToken('admin');
const ownerToken = await makeToken('owner');
const viewerToken = await makeToken('viewer');
await expect(
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${adminToken}` } }, 'production')
).resolves.toBeUndefined();
await expect(
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${ownerToken}` } }, 'production')
).resolves.toBeUndefined();
await expect(
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${viewerToken}` } }, 'production')
).rejects.toThrow('Insufficient permissions');
});
});

View File

@ -0,0 +1,53 @@
import { requireRole } from './auth.js';
import { config } from './config.js';
import { getAllFlags } from './feature-flags.js';
import { PRODUCT_ID } from './product-config.js';
import { flushEvents, getBufferedEvents } from './telemetry.js';
type AuthLikeRequest = { headers: { authorization?: string } };
type DiagnosticsHandler = (req: AuthLikeRequest) => Promise<unknown>;
type DiagnosticsRouteApp = {
get: (path: string, handler: DiagnosticsHandler) => unknown;
post: (path: string, handler: DiagnosticsHandler) => unknown;
};
export async function assertDiagnosticsAccess(
req: AuthLikeRequest,
nodeEnv: string = config.NODE_ENV,
): Promise<void> {
if (nodeEnv !== 'production') {
return;
}
await requireRole(req, 'admin', 'owner');
}
export async function diagnosticsRoutes(app: DiagnosticsRouteApp) {
app.get('/api/diagnostics/flags', async req => {
await assertDiagnosticsAccess(req);
return getAllFlags();
});
app.get('/api/diagnostics/telemetry', async req => {
await assertDiagnosticsAccess(req);
return { events: getBufferedEvents() };
});
app.post('/api/diagnostics/telemetry/flush', async req => {
await assertDiagnosticsAccess(req);
return { flushed: flushEvents().length };
});
app.get('/api/diagnostics/config', async req => {
await assertDiagnosticsAccess(req);
return {
productId: PRODUCT_ID,
serviceName: config.SERVICE_NAME,
port: config.PORT,
nodeEnv: config.NODE_ENV,
dbProvider: config.DB_PROVIDER,
telemetryEnabled: config.TELEMETRY_ENABLED,
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
};
});
}

View File

@ -5,6 +5,7 @@ const registerOptionalJwtContextMock = vi.fn(async () => undefined);
const startServiceMock = vi.fn(async () => undefined);
const initCosmosIfNeededMock = vi.fn(async () => undefined);
const initDatastoreMock = vi.fn(() => undefined);
const diagnosticsRoutesMock = vi.fn(async () => undefined);
const appMock = {
register: vi.fn(async () => undefined),
@ -61,6 +62,7 @@ vi.mock('./lib/field-encrypt.js', () => ({ initEncryption: vi.fn(async () => und
vi.mock('./lib/request-context.js', () => ({ getUserId: vi.fn(), getRequestProductId: vi.fn() }));
vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) }));
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock }));
vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) }));
vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) }));
@ -81,6 +83,7 @@ describe('server bootstrap', () => {
expect(createServiceAppMock).toHaveBeenCalledOnce();
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalledTimes(14);
expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
});
});

View File

@ -19,9 +19,8 @@ import { initEncryption } from './lib/field-encrypt.js';
import { initWebhookSubscriber, stopWebhookSubscriber } from './lib/webhook-subscriber.js';
import { initDatastore } from './lib/datastore.js';
import { config } from './lib/config.js';
import { getAllFlags } from './lib/feature-flags.js';
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js';
import { diagnosticsRoutes } from './lib/diagnostics-routes.js';
import type { JwtPayload } from './lib/request-context.js';
import { findShareByToken } from './modules/note-shares/repository.js';
import * as noteRepo from './modules/notes/repository.js';
@ -108,19 +107,8 @@ app.get('/api/bootstrap', async () => ({
backendPort: config.PORT,
}));
// ── Diagnostics routes (no auth) ────────────────────────────────
app.get('/api/diagnostics/flags', async () => getAllFlags());
app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() }));
app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length }));
app.get('/api/diagnostics/config', async () => ({
productId: PRODUCT_ID,
serviceName: config.SERVICE_NAME,
port: config.PORT,
nodeEnv: config.NODE_ENV,
dbProvider: config.DB_PROVIDER,
telemetryEnabled: config.TELEMETRY_ENABLED,
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
}));
// ── Diagnostics routes (dev/test open, production admin/owner gated) ───────
await diagnosticsRoutes(app);
await initEncryption(PRODUCT_ID, app.log);