From a5a01cb740ea31b2ca59c32a9ffa74ab836a599a Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 30 Jan 2026 14:38:42 +0000 Subject: [PATCH 1/3] feat: Add CORS for Commerce Manager endpoints Add restricted CORS middleware for CM origins: - integration/staging/useast/euwest.cm.elasticpath.com - localhost:3000 - Vercel preview deployments ({MR}--{env}-commerce-manager.vercel.app) Endpoints updated: - /api/v1/auth/self, /api/v1/auth/csrf - /api/v1/app-config - /api/v1/projects (list, create, clone, update, delete, meta, update-host) - /api/v1/cmse/databases (list, create, clone, delete) --- platform/wab/src/wab/server/AppServer.ts | 38 ++++++++++------ platform/wab/src/wab/server/cm-cors.ts | 55 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 platform/wab/src/wab/server/cm-cors.ts diff --git a/platform/wab/src/wab/server/AppServer.ts b/platform/wab/src/wab/server/AppServer.ts index 8a0d5d7c7..3a3fa63f4 100644 --- a/platform/wab/src/wab/server/AppServer.ts +++ b/platform/wab/src/wab/server/AppServer.ts @@ -33,6 +33,7 @@ import { trackPostgresPool, } from "@/wab/server/promstats"; import { createRateLimiter } from "@/wab/server/rate-limit"; +import { cmCors, cmCorsPreflight } from "@/wab/server/cm-cors"; import * as adminRoutes from "@/wab/server/routes/admin"; import * as provisioningRoutes from "@/wab/server/routes/provisioning"; import { @@ -714,6 +715,14 @@ function addOptionsRoutes(app: express.Application) { // For mailing list subscriptions // allow subscription requests from anywhere (e.g. localhost or www.plasmic.app) app.options("/api/v1/mail/subscribe", cors()); + + // Commerce Manager CORS preflight handlers (restricted origins) + app.options("/api/v1/auth/self", cmCorsPreflight()); + app.options("/api/v1/auth/csrf", cmCorsPreflight()); + app.options("/api/v1/app-config", cmCorsPreflight()); + app.options("/api/v1/projects", cmCorsPreflight()); + app.options("/api/v1/projects/*", cmCorsPreflight()); + app.options("/api/v1/cmse/*", cmCorsPreflight()); } export function addCmsPublicRoutes(app: express.Application) { @@ -739,14 +748,14 @@ export function addCmsPublicRoutes(app: express.Application) { export function addCmsEditorRoutes(app: express.Application) { // CMS API for use by studio to crud; access by usual browser login - app.get("/api/v1/cmse/databases", listDatabases); - app.post("/api/v1/cmse/databases", withNext(createDatabase)); - app.post("/api/v1/cmse/databases/:dbId/clone", withNext(cloneDatabase)); + app.get("/api/v1/cmse/databases", cmCors, listDatabases); + app.post("/api/v1/cmse/databases", cmCors, withNext(createDatabase)); + app.post("/api/v1/cmse/databases/:dbId/clone", cmCors, withNext(cloneDatabase)); app.get("/api/v1/cmse/databases/:dbId", getCmsDatabaseAndSecretTokenById); app.get("/api/v1/cmse/databases-meta/:dbId", getDatabaseMeta); app.get("/api/v1/cmse/databases-meta", listDatabasesMeta); app.put("/api/v1/cmse/databases/:dbId", withNext(updateDatabase)); - app.delete("/api/v1/cmse/databases/:dbId", withNext(deleteDatabase)); + app.delete("/api/v1/cmse/databases/:dbId", cmCors, withNext(deleteDatabase)); app.post("/api/v1/cmse/databases/:dbId/tables", withNext(createTable)); app.put("/api/v1/cmse/tables/:tableId", withNext(updateTable)); @@ -1188,7 +1197,7 @@ export function addMainAppServerRoutes( /** * Auth Routes */ - app.get("/api/v1/auth/csrf", authRoutes.csrf); + app.get("/api/v1/auth/csrf", cmCors, authRoutes.csrf); app.post( "/api/v1/auth/login", sensitiveRateLimiter, @@ -1199,9 +1208,9 @@ export function addMainAppServerRoutes( sensitiveRateLimiter, withNext(authRoutes.signUp) ); - app.get("/api/v1/auth/self", authRoutes.self); - app.post("/api/v1/auth/self", withNext(authRoutes.updateSelf)); - app.delete("/api/v1/auth/self", withNext(authRoutes.deleteSelf)); + app.get("/api/v1/auth/self", cmCors, authRoutes.self); + app.post("/api/v1/auth/self", cmCors, withNext(authRoutes.updateSelf)); + app.delete("/api/v1/auth/self", cmCors, withNext(authRoutes.deleteSelf)); app.post( "/api/v1/auth/self/password", sensitiveRateLimiter, @@ -1457,7 +1466,7 @@ export function addMainAppServerRoutes( /** * Self routes */ - app.get("/api/v1/app-config", getAppConfig); + app.get("/api/v1/app-config", cmCors, getAppConfig); app.get("/api/v1/app-ctx", withNext(getAppCtx)); /** @@ -1489,15 +1498,16 @@ export function addMainAppServerRoutes( */ app.get( "/api/v1/projects", + cmCors, safeCast(authRoutes.teamApiUserAuth), withNext(listProjects) ); - app.post("/api/v1/projects", withNext(createProject)); + app.post("/api/v1/projects", cmCors, withNext(createProject)); app.post( "/api/v1/projects/create-project-with-hostless-packages", withNext(createProjectWithHostlessPackages) ); - app.post("/api/v1/projects/:projectId/clone", withNext(cloneProject)); + app.post("/api/v1/projects/:projectId/clone", cmCors, withNext(cloneProject)); app.post( "/api/v1/templates/:projectId/clone", safeCast(authRoutes.teamApiUserAuth), @@ -1517,6 +1527,7 @@ export function addMainAppServerRoutes( ); app.put( "/api/v1/projects/:projectId/meta", + cmCors, safeCast(authRoutes.teamApiUserAuth), updateProjectMeta ); @@ -1546,14 +1557,15 @@ export function addMainAppServerRoutes( getProjectRevWithoutData ); app.get("/api/v1/project-data/:projectId", adminOnly, getFullProjectData); - app.put("/api/v1/projects/:projectId", updateProject); + app.put("/api/v1/projects/:projectId", cmCors, updateProject); app.delete( "/api/v1/projects/:projectId", + cmCors, safeCast(authRoutes.teamApiUserAuth), withNext(deleteProject) ); app.put("/api/v1/projects/:projectId/revert-to-version", revertToVersion); - app.put("/api/v1/projects/:projectId/update-host", withNext(updateHostUrl)); + app.put("/api/v1/projects/:projectId/update-host", cmCors, withNext(updateHostUrl)); app.delete("/api/v1/projects/:projectId/perm", withNext(removeSelfPerm)); app.get("/api/v1/projects/:projectId/updates", getModelUpdates); app.post( diff --git a/platform/wab/src/wab/server/cm-cors.ts b/platform/wab/src/wab/server/cm-cors.ts new file mode 100644 index 000000000..c12ed3ecd --- /dev/null +++ b/platform/wab/src/wab/server/cm-cors.ts @@ -0,0 +1,55 @@ +import cors from "cors"; +import express, { RequestHandler } from "express"; +import { safeCast } from "@/wab/shared/common"; + +// CORS configuration restricted to Commerce Manager origins +const cmAllowedOrigins = [ + "https://integration.cm.elasticpath.com", + "https://staging.cm.elasticpath.com", + "https://useast.cm.elasticpath.com", + "https://euwest.cm.elasticpath.com", + "http://localhost:3000", +]; + +// Regex for Vercel preview deployments: {MR_NUMBER}--{env}-commerce-manager.vercel.app +const cmPreviewOriginPattern = + /^https:\/\/\d+--\w+-commerce-manager\.vercel\.app$/; + +function isCmOriginAllowed(origin: string | undefined): boolean { + if (!origin) return false; + if (cmAllowedOrigins.includes(origin)) return true; + if (cmPreviewOriginPattern.test(origin)) return true; + return false; +} + +const cmCorsOptions: cors.CorsOptions = { + origin: (origin, callback) => { + if (isCmOriginAllowed(origin)) { + callback(null, true); + } else { + callback(new Error("Not allowed by CORS")); + } + }, + credentials: true, +}; + +export const cmCors = cors(cmCorsOptions); + +export function cmCorsPreflight() { + const corsHandler = cors({ + ...cmCorsOptions, + maxAge: 30 * 24 * 60 * 60, + allowedHeaders: "*", + }); + + const handler: express.RequestHandler = safeCast( + async (req, res, next) => { + res.set( + "Cache-Control", + `max-age=${30 * 24 * 60 * 60}, s-maxage=${30 * 24 * 60 * 60}` + ); + corsHandler(req, res, next); + } + ); + return handler; +} From d199002b4871b6259eda23db68ecf987a136c0f8 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 30 Jan 2026 14:59:41 +0000 Subject: [PATCH 2/3] refactor: Use wildcard pattern for CM CORS origins Replace hardcoded environment URLs with regex pattern for *.cm.elasticpath.com to avoid exposing internal environment names in public code. --- platform/wab/src/wab/server/cm-cors.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/platform/wab/src/wab/server/cm-cors.ts b/platform/wab/src/wab/server/cm-cors.ts index c12ed3ecd..71bc3497b 100644 --- a/platform/wab/src/wab/server/cm-cors.ts +++ b/platform/wab/src/wab/server/cm-cors.ts @@ -3,21 +3,21 @@ import express, { RequestHandler } from "express"; import { safeCast } from "@/wab/shared/common"; // CORS configuration restricted to Commerce Manager origins -const cmAllowedOrigins = [ - "https://integration.cm.elasticpath.com", - "https://staging.cm.elasticpath.com", - "https://useast.cm.elasticpath.com", - "https://euwest.cm.elasticpath.com", - "http://localhost:3000", -]; + +// Regex for *.cm.elasticpath.com (any subdomain) +const cmOriginPattern = /^https:\/\/[\w-]+\.cm\.elasticpath\.com$/; // Regex for Vercel preview deployments: {MR_NUMBER}--{env}-commerce-manager.vercel.app const cmPreviewOriginPattern = - /^https:\/\/\d+--\w+-commerce-manager\.vercel\.app$/; + /^https:\/\/\d+--[\w-]+-commerce-manager\.vercel\.app$/; + +// localhost for development +const cmLocalOrigin = "http://localhost:3000"; function isCmOriginAllowed(origin: string | undefined): boolean { if (!origin) return false; - if (cmAllowedOrigins.includes(origin)) return true; + if (origin === cmLocalOrigin) return true; + if (cmOriginPattern.test(origin)) return true; if (cmPreviewOriginPattern.test(origin)) return true; return false; } From 2f3e92536ffe1f1e404db0b8e1d3b7325ffcd990 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 30 Jan 2026 15:49:34 +0000 Subject: [PATCH 3/3] fix: Address PR review feedback for CM CORS - Use stricter regex [a-zA-Z0-9-] instead of \w (no underscores) - Add logging for rejected origins - Use callback(null, false) instead of Error for CORS rejection - Add cmCors to missing cmse database endpoints - Add test coverage for isCmOriginAllowed function --- platform/wab/src/wab/server/AppServer.ts | 4 +- platform/wab/src/wab/server/cm-cors.spec.ts | 87 +++++++++++++++++++++ platform/wab/src/wab/server/cm-cors.ts | 12 +-- 3 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 platform/wab/src/wab/server/cm-cors.spec.ts diff --git a/platform/wab/src/wab/server/AppServer.ts b/platform/wab/src/wab/server/AppServer.ts index 3a3fa63f4..1ca01d1f8 100644 --- a/platform/wab/src/wab/server/AppServer.ts +++ b/platform/wab/src/wab/server/AppServer.ts @@ -751,10 +751,10 @@ export function addCmsEditorRoutes(app: express.Application) { app.get("/api/v1/cmse/databases", cmCors, listDatabases); app.post("/api/v1/cmse/databases", cmCors, withNext(createDatabase)); app.post("/api/v1/cmse/databases/:dbId/clone", cmCors, withNext(cloneDatabase)); - app.get("/api/v1/cmse/databases/:dbId", getCmsDatabaseAndSecretTokenById); + app.get("/api/v1/cmse/databases/:dbId", cmCors, getCmsDatabaseAndSecretTokenById); app.get("/api/v1/cmse/databases-meta/:dbId", getDatabaseMeta); app.get("/api/v1/cmse/databases-meta", listDatabasesMeta); - app.put("/api/v1/cmse/databases/:dbId", withNext(updateDatabase)); + app.put("/api/v1/cmse/databases/:dbId", cmCors, withNext(updateDatabase)); app.delete("/api/v1/cmse/databases/:dbId", cmCors, withNext(deleteDatabase)); app.post("/api/v1/cmse/databases/:dbId/tables", withNext(createTable)); diff --git a/platform/wab/src/wab/server/cm-cors.spec.ts b/platform/wab/src/wab/server/cm-cors.spec.ts new file mode 100644 index 000000000..894fb9579 --- /dev/null +++ b/platform/wab/src/wab/server/cm-cors.spec.ts @@ -0,0 +1,87 @@ +import { isCmOriginAllowed } from "@/wab/server/cm-cors"; + +describe("isCmOriginAllowed", () => { + describe("valid origins", () => { + it("should allow *.cm.elasticpath.com origins", () => { + expect(isCmOriginAllowed("https://integration.cm.elasticpath.com")).toBe( + true + ); + expect(isCmOriginAllowed("https://staging.cm.elasticpath.com")).toBe(true); + expect(isCmOriginAllowed("https://useast.cm.elasticpath.com")).toBe(true); + expect(isCmOriginAllowed("https://euwest.cm.elasticpath.com")).toBe(true); + expect(isCmOriginAllowed("https://new-env.cm.elasticpath.com")).toBe(true); + }); + + it("should allow localhost:3000", () => { + expect(isCmOriginAllowed("http://localhost:3000")).toBe(true); + }); + + it("should allow Vercel preview deployments", () => { + expect( + isCmOriginAllowed( + "https://3492--integration-commerce-manager.vercel.app" + ) + ).toBe(true); + expect( + isCmOriginAllowed("https://123--staging-commerce-manager.vercel.app") + ).toBe(true); + expect( + isCmOriginAllowed("https://99999--prod-commerce-manager.vercel.app") + ).toBe(true); + }); + }); + + describe("invalid origins", () => { + it("should reject undefined/null origins", () => { + expect(isCmOriginAllowed(undefined)).toBe(false); + }); + + it("should reject malicious domains", () => { + expect(isCmOriginAllowed("https://malicious.com")).toBe(false); + expect(isCmOriginAllowed("https://evil.cm.elasticpath.com.attacker.com")).toBe( + false + ); + }); + + it("should reject domains trying to bypass with similar names", () => { + expect(isCmOriginAllowed("https://cm.elasticpath.com")).toBe(false); + expect(isCmOriginAllowed("https://fakecm.elasticpath.com")).toBe(false); + expect(isCmOriginAllowed("https://test.cm.elasticpath.com.evil.com")).toBe( + false + ); + }); + + it("should reject origins with underscores (invalid DNS)", () => { + expect(isCmOriginAllowed("https://test_env.cm.elasticpath.com")).toBe( + false + ); + }); + + it("should reject http instead of https for production domains", () => { + expect(isCmOriginAllowed("http://integration.cm.elasticpath.com")).toBe( + false + ); + }); + + it("should reject localhost on other ports", () => { + expect(isCmOriginAllowed("http://localhost:3001")).toBe(false); + expect(isCmOriginAllowed("http://localhost:8080")).toBe(false); + }); + + it("should reject invalid Vercel preview URLs", () => { + expect(isCmOriginAllowed("https://random.vercel.app")).toBe(false); + expect( + isCmOriginAllowed("https://not-a-number--integration-commerce-manager.vercel.app") + ).toBe(false); + expect( + isCmOriginAllowed("https://123--other-app.vercel.app") + ).toBe(false); + }); + + it("should reject origins with ports for production domains", () => { + expect(isCmOriginAllowed("https://integration.cm.elasticpath.com:8080")).toBe( + false + ); + }); + }); +}); diff --git a/platform/wab/src/wab/server/cm-cors.ts b/platform/wab/src/wab/server/cm-cors.ts index 71bc3497b..a275aaf87 100644 --- a/platform/wab/src/wab/server/cm-cors.ts +++ b/platform/wab/src/wab/server/cm-cors.ts @@ -1,24 +1,26 @@ import cors from "cors"; import express, { RequestHandler } from "express"; import { safeCast } from "@/wab/shared/common"; +import { logger } from "@/wab/server/observability"; // CORS configuration restricted to Commerce Manager origins -// Regex for *.cm.elasticpath.com (any subdomain) -const cmOriginPattern = /^https:\/\/[\w-]+\.cm\.elasticpath\.com$/; +// Regex for *.cm.elasticpath.com (any subdomain, alphanumerics and hyphens only) +const cmOriginPattern = /^https:\/\/[a-zA-Z0-9-]+\.cm\.elasticpath\.com$/; // Regex for Vercel preview deployments: {MR_NUMBER}--{env}-commerce-manager.vercel.app const cmPreviewOriginPattern = - /^https:\/\/\d+--[\w-]+-commerce-manager\.vercel\.app$/; + /^https:\/\/\d+--[a-zA-Z0-9-]+-commerce-manager\.vercel\.app$/; // localhost for development const cmLocalOrigin = "http://localhost:3000"; -function isCmOriginAllowed(origin: string | undefined): boolean { +export function isCmOriginAllowed(origin: string | undefined): boolean { if (!origin) return false; if (origin === cmLocalOrigin) return true; if (cmOriginPattern.test(origin)) return true; if (cmPreviewOriginPattern.test(origin)) return true; + logger().warn(`CM CORS rejected origin: ${origin}`); return false; } @@ -27,7 +29,7 @@ const cmCorsOptions: cors.CorsOptions = { if (isCmOriginAllowed(origin)) { callback(null, true); } else { - callback(new Error("Not allowed by CORS")); + callback(null, false); } }, credentials: true,