Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions platform/wab/src/wab/server/AppServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -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/:dbId", getCmsDatabaseAndSecretTokenById);
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", 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.delete("/api/v1/cmse/databases/:dbId", withNext(deleteDatabase));
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));
app.put("/api/v1/cmse/tables/:tableId", withNext(updateTable));
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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));

/**
Expand Down Expand Up @@ -1489,15 +1498,16 @@ export function addMainAppServerRoutes(
*/
app.get(
"/api/v1/projects",
cmCors,
safeCast<RequestHandler>(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<RequestHandler>(authRoutes.teamApiUserAuth),
Expand All @@ -1517,6 +1527,7 @@ export function addMainAppServerRoutes(
);
app.put(
"/api/v1/projects/:projectId/meta",
cmCors,
safeCast<RequestHandler>(authRoutes.teamApiUserAuth),
updateProjectMeta
);
Expand Down Expand Up @@ -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<RequestHandler>(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(
Expand Down
87 changes: 87 additions & 0 deletions platform/wab/src/wab/server/cm-cors.spec.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
});
57 changes: 57 additions & 0 deletions platform/wab/src/wab/server/cm-cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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, 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+--[a-zA-Z0-9-]+-commerce-manager\.vercel\.app$/;

// localhost for development
const cmLocalOrigin = "http://localhost:3000";

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;
}

const cmCorsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
if (isCmOriginAllowed(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
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<RequestHandler>(
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;
}
Loading