fix(security): gate production diagnostics
This commit is contained in:
parent
9d3c0774ec
commit
56a051a422
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
53
backend/src/lib/diagnostics-routes.ts
Normal file
53
backend/src/lib/diagnostics-routes.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user