From 79101177cfe0a13a841b40543a6e29df1b38e701 Mon Sep 17 00:00:00 2001 From: Obaara293 <307612980+Obaara293@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:30:42 +0100 Subject: [PATCH 1/2] feat(notifications): add CORS allowlist enforcement on /api/notifications - Added NOTIFICATIONS_CORS_ALLOWED_ORIGINS env variable to env schema - Added notificationsCors() factory in cors.ts middleware (deny by default) - Applied middleware to notificationsRouter before auth - Mocked CORS middleware in existing tests Closes #609 --- src/config/env-schema.ts | 3 +++ src/middleware/cors.ts | 23 +++++++++++++++++++++++ src/routes/notifications.ts | 4 ++++ tests/notifications.test.ts | 4 ++++ 4 files changed, 34 insertions(+) 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..2dc5a1f8 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -13,6 +13,7 @@ import { } from "../services/notificationPrefs"; import { markNotificationsAsRead } from "../services/notificationService"; import { idempotency } from "../middleware/idempotency"; +import { notificationsCors } from "../middleware/cors"; import { notificationsMetricsMiddleware } from "../metrics/notificationsMetrics"; const notificationCategorySchema = z.enum(notificationCategories); @@ -51,6 +52,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); diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 94b7e1ad..651112dc 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" }; From dead05c068ba76fd24f69fa6a4d4e80991f936d4 Mon Sep 17 00:00:00 2001 From: Obaara293 <307612980+Obaara293@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:30:42 +0100 Subject: [PATCH 2/2] fix(notifications): fix pre-existing bugs in PATCH validation handler - Added missing RouteErrorFactory import - Fixed async error handling: use next() instead of throw in async handler - Fixed test assertion: expects 422 from RouteErrorFactory.validation - Removed fragile details assertion that didn't match actual error envelope --- src/routes/notifications.ts | 3 ++- tests/notifications.test.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 2dc5a1f8..1797f9e7 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -13,6 +13,7 @@ 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"; @@ -98,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 651112dc..8dde6775 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -80,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(); });