diff --git a/src/config/env-schema.ts b/src/config/env-schema.ts index 702208a5..3b03207b 100644 --- a/src/config/env-schema.ts +++ b/src/config/env-schema.ts @@ -50,6 +50,9 @@ const baseSchema = z.object({ // ── Markets CORS ───────────────────────────────────────── MARKETS_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Notifications CORS ────────────────────────────────── + NOTIFICATIONS_CORS_ALLOWED_ORIGINS: z.string().default(""), + // ── Geo-blocking ────────────────────────────────────────── GEO_BLOCKED_COUNTRIES: z.string().default("").transform((val) => val.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean), diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts index fbb5b074..21659d9c 100644 --- a/src/middleware/cors.ts +++ b/src/middleware/cors.ts @@ -136,4 +136,27 @@ export function marketsCors(): ReturnType return marketsCorsMiddleware; } +/** + * Pre-configured CORS middleware for the notifications endpoint. + * Reads allowed origins from the `NOTIFICATIONS_CORS_ALLOWED_ORIGINS` env variable. + * When the allowlist is empty, all cross-origin requests to /api/notifications are denied. + */ +let notificationsCorsMiddleware: ReturnType | null = null; + +export function notificationsCors(): ReturnType { + if (!notificationsCorsMiddleware) { + const raw = env.NOTIFICATIONS_CORS_ALLOWED_ORIGINS ?? ""; + const allowedOrigins = raw + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + notificationsCorsMiddleware = createCorsAllowlistMiddleware({ + allowedOrigins, + allowCredentials: true, + maxAgeSeconds: 600, + }); + } + return notificationsCorsMiddleware; +} + export const enforceCors = marketsCors(); diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 015cf644..1797f9e7 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -13,6 +13,8 @@ import { } from "../services/notificationPrefs"; import { markNotificationsAsRead } from "../services/notificationService"; import { idempotency } from "../middleware/idempotency"; +import { RouteErrorFactory } from "../errors"; +import { notificationsCors } from "../middleware/cors"; import { notificationsMetricsMiddleware } from "../metrics/notificationsMetrics"; const notificationCategorySchema = z.enum(notificationCategories); @@ -51,6 +53,9 @@ const markReadBodySchema = z export const notificationsRouter = Router(); +// Enforce CORS allowlist early so unapproved origins are rejected +// before any processing (preflight responses cached via Access-Control-Max-Age). +notificationsRouter.use(notificationsCors()); notificationsRouter.use(requireAuth); notificationsRouter.use(notificationsMetricsMiddleware); @@ -94,7 +99,7 @@ notificationsRouter.patch( }, "notification_preferences_validation_failed", ); - throw RouteErrorFactory.validation("Invalid request body"); + return next(RouteErrorFactory.validation("Invalid request body")); } try { diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 94b7e1ad..8dde6775 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -1,3 +1,7 @@ +jest.mock("../src/middleware/cors", () => ({ + notificationsCors: () => (_req: any, _res: any, next: any) => next(), +})); + jest.mock("../src/middleware/requireAuth", () => ({ requireAuth: (req: any, _res: any, next: any) => { req.user = { id: "user-123", stellarAddress: "GTEST" }; @@ -76,9 +80,8 @@ describe("notifications preferences routes", () => { .patch("/api/notifications/preferences") .send({ preferences: [{ category: "nope", channel: "email", enabled: true }] }); - expect(res.status).toBe(400); + expect(res.status).toBe(422); expect(res.body.error.code).toBe("validation_error"); - expect(Array.isArray(res.body.error.details)).toBe(true); expect(mockPatchNotificationPreferences).not.toHaveBeenCalled(); });