From 4e726ea435b1c0a670944542deff97da44d9a0e7 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Tue, 29 Sep 2026 19:45:36 +0000 Subject: [PATCH] feat(api): gate Swagger/OpenAPI docs behind NODE_ENV/ENABLE_SWAGGER (#233) The Swagger UI at /api/docs was registered unconditionally, exposing the full API surface unauthenticated in production. Register the swagger and swagger-ui plugins only when NODE_ENV !== 'production', or when explicitly opted in via ENABLE_SWAGGER=true. Co-Authored-By: Claude Opus 4.8 --- src/api.test.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ src/api.ts | 39 ++++++++++++++++++++------------- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/api.test.ts b/src/api.test.ts index 19c44db..206d790 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -144,3 +144,60 @@ describe('security headers (CSP)', () => { expect(csp).not.toContain("default-src 'none'"); }); }); + +describe('Swagger docs gating', () => { + const buildServer = () => + api({ + title: 'my awesome service', + smbServerBaseUrl: 'http://localhost', + endpointIdleTimeout: '60', + publicHost: 'http://localhost', + dbManager: mockDbManager, + productionManager: mockProductionManager, + ingestManager: mockIngestManager, + coreFunctions: new CoreFunctions( + mockProductionManager, + new ConnectionQueue() + ) + }); + + const originalNodeEnv = process.env.NODE_ENV; + const originalEnableSwagger = process.env.ENABLE_SWAGGER; + + afterEach(() => { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + if (originalEnableSwagger === undefined) { + delete process.env.ENABLE_SWAGGER; + } else { + process.env.ENABLE_SWAGGER = originalEnableSwagger; + } + }); + + it('registers /api/docs by default (non-production)', async () => { + delete process.env.NODE_ENV; + delete process.env.ENABLE_SWAGGER; + const server = await buildServer(); + const response = await server.inject({ method: 'GET', url: '/api/docs/' }); + expect(response.statusCode).toBe(200); + }); + + it('does not register /api/docs when NODE_ENV=production', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.ENABLE_SWAGGER; + const server = await buildServer(); + const response = await server.inject({ method: 'GET', url: '/api/docs/' }); + expect(response.statusCode).toBe(404); + }); + + it('registers /api/docs in production when ENABLE_SWAGGER=true', async () => { + process.env.NODE_ENV = 'production'; + process.env.ENABLE_SWAGGER = 'true'; + const server = await buildServer(); + const response = await server.inject({ method: 'GET', url: '/api/docs/' }); + expect(response.statusCode).toBe(200); + }); +}); diff --git a/src/api.ts b/src/api.ts index 723e715..f9579d9 100644 --- a/src/api.ts +++ b/src/api.ts @@ -117,22 +117,31 @@ export default async (opts: ApiOptions) => { global: false // Only apply to specific routes }); - // register the swagger plugins, it will automagically do magic - api.register(swagger, { - swagger: { - info: { - title: opts.title, - description: 'Intercom Manager API', - version: 'v1' + // Gate the Swagger/OpenAPI docs so the full API surface is not exposed + // unauthenticated in production. The docs are registered when NOT running in + // production, or when explicitly opted in via ENABLE_SWAGGER=true (an escape + // hatch to enable the docs in a production deployment when desired). + const enableSwagger = + process.env.NODE_ENV !== 'production' || + process.env.ENABLE_SWAGGER === 'true'; + if (enableSwagger) { + // register the swagger plugins, it will automagically do magic + api.register(swagger, { + swagger: { + info: { + title: opts.title, + description: 'Intercom Manager API', + version: 'v1' + } } - } - }); - api.register(swaggerUI, { - routePrefix: '/api/docs', - // Emit a CSP tailored to Swagger UI's own assets so the docs page keeps - // working under the strict global helmet CSP registered above. - staticCSP: true - }); + }); + api.register(swaggerUI, { + routePrefix: '/api/docs', + // Emit a CSP tailored to Swagger UI's own assets so the docs page keeps + // working under the strict global helmet CSP registered above. + staticCSP: true + }); + } api.register(healthcheck, { title: opts.title }); // register other API routes here