From 9b1396648471129eb3fb7fcda58588de7eef5c44 Mon Sep 17 00:00:00 2001 From: ahbiz Date: Thu, 30 Jul 2026 06:35:06 +0100 Subject: [PATCH] feat(apis): enforce CORS allowlist from env on /api/apis [b#055] - Implement createApisCorsMiddleware in src/middleware/cors.ts lazily reading APIS_CORS_ALLOWED_ORIGINS - Wire CORS middleware into createApisRouter in src/routes/apis.ts - Restore recordApisLatency helper in src/metrics/registry.ts - Add unit tests for createApisCorsMiddleware in src/middleware/cors.test.ts Closes #1117 --- src/metrics/registry.ts | 28 ++++++++++++++++++++++ src/middleware/cors.ts | 43 ++++++++++++++++++++++++++++++++++ src/routes/apis.cursor.test.ts | 3 ++- src/routes/apis.ts | 4 +++- 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index 3321295c..f48395c3 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -133,6 +133,32 @@ export function resetAdminMetrics(): void { adminDuration.reset(); } +const apisLatencyDuration = new client.Histogram({ + name: 'apis_request_duration_seconds', + help: 'Latency of /api/apis requests in seconds (FWC26 #893)', + labelNames: ['route', 'method', 'status_code'], + buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], +}); + +export function recordApisLatency( + method: string, + statusCode: number, + durationMs: number, +): void { + apisLatencyDuration.observe( + { + route: '/api/apis', + method: method.toUpperCase(), + status_code: String(statusCode), + }, + durationMs / 1000, + ); +} + +export function resetApisMetrics(): void { + apisLatencyDuration.reset(); +} + export { billingDeductDuration, refreshTokenDuration, @@ -140,4 +166,6 @@ export { creditsDuration, adminDuration, subscriptionsLatencyDuration, + apisLatencyDuration, }; + diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index 5d77efc3..91f3787c 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -344,3 +344,46 @@ export function createExportsCorsMiddleware(): ( middleware(req, res, next); }; } + +/** + * Apis-route CORS middleware factory. + * + * Reads the `APIS_CORS_ALLOWED_ORIGINS` environment variable on first + * use (lazy) so unit tests that mutate `process.env` after module load + * continue to work. The factory returns the same middleware instance on + * every subsequent request to avoid re-parsing the allowlist per request; + * to pick up runtime changes the operator must restart the process. + * + * Defaults to: + * - `allowCredentials: true` — authenticated POST/PATCH endpoints under /api/apis. + * - `maxAgeSeconds: 600` — 10 minute preflight cache. + * + * If `APIS_CORS_ALLOWED_ORIGINS` is unset/empty every cross-origin + * request to the apis route is denied (deny by default). + */ +export function createApisCorsMiddleware(): ( + req: Request, + res: Response, + next: NextFunction, +) => void { + let middleware: ReturnType | null = + null; + + return (req: Request, res: Response, next: NextFunction): void => { + if (!middleware) { + const allowedOrigins = parseAllowedOrigins( + process.env.APIS_CORS_ALLOWED_ORIGINS, + ); + logger.info('[cors] apis allowlist loaded', { + originCount: allowedOrigins.length, + }); + middleware = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: DEFAULT_MAX_AGE_SECONDS, + }); + } + middleware(req, res, next); + }; +} + diff --git a/src/routes/apis.cursor.test.ts b/src/routes/apis.cursor.test.ts index f7e5adf6..3f89cc47 100644 --- a/src/routes/apis.cursor.test.ts +++ b/src/routes/apis.cursor.test.ts @@ -64,6 +64,7 @@ function buildApp(repo: InMemoryApiRepository) { apiRepository: repo, developerRepository, cache: new ListingsCache({ ttlMs: 0 }), // TTL=0 → immediate expiry, never serves from cache + corsMiddleware: (_req, _res, next) => next(), }), ); app.use(errorHandler); @@ -309,7 +310,7 @@ describe('GET /api/apis — cursor pagination', () => { }); it('treats empty cursor string as first page (cursor-based)', async () => { - const res = await request(buildFixtureApp()).get('/api/apis?cursor='); + const res = await request(buildFixtureApp()).get('/api/apis?cursor=&limit=2'); expect(res.status).toBe(200); expect(res.body.meta).toHaveProperty('hasMore'); diff --git a/src/routes/apis.ts b/src/routes/apis.ts index 370a901f..c6ab0ad7 100644 --- a/src/routes/apis.ts +++ b/src/routes/apis.ts @@ -52,6 +52,8 @@ export interface ApisRouterDeps { cache?: ListingsCache; /** Optional rate limit middleware for the public API routes. */ rateLimitMiddleware?: ReturnType; + /** Optional CORS middleware for the API routes. Defaults to env-driven createApisCorsMiddleware. */ + corsMiddleware?: ReturnType; /** Persists audit rows for state-changing calls. Defaults to the pg-backed service. */ auditService?: AuditService; } @@ -99,7 +101,7 @@ export function createApisRouter(deps: ApisRouterDeps = {}): Router { maxRequests: 60, }); - const apisCors = createApisCorsMiddleware(); + const apisCors = deps.corsMiddleware ?? createApisCorsMiddleware(); router.use(apisCors); router.use(rateLimitMiddleware);