diff --git a/.github/workflows/SECRETS.md b/.github/workflows/SECRETS.md index b800b87f..e8b7b255 100644 --- a/.github/workflows/SECRETS.md +++ b/.github/workflows/SECRETS.md @@ -53,7 +53,12 @@ Shopify Partner app**. - Obtain the app at: https://partners.shopify.com → Apps → create app - App URL: `https://sites-shopify.deco.site` - Redirect URL: `https://sites-shopify.deco.site/oauth/shopify/callback` - - `SELF_URL` / `MESH_URL` are injected by the deco platform at runtime. +- **`SELF_URL`** (optional): this MCP's public origin. Defaults to + `https://sites-shopify.deco.site`; only set it to point OAuth at a different + host (e.g. `http://localhost:8001` for local dev). +- **`MESH_URL`** (optional): the mesh callback origin is validated against a + `decocms.com` allowlist (plus loopback) by default. Set `MESH_URL` to also + allow a self-hosted mesh origin — e.g. a local deco studio on a custom host. ### MCP: `github` (Cloudflare Workers — `deploy-github.yml`) Unlike the other MCPs, github deploys directly via `wrangler deploy` in diff --git a/shopify/server/lib/oauth.test.ts b/shopify/server/lib/oauth.test.ts index 897d1e1c..b0eba213 100644 --- a/shopify/server/lib/oauth.test.ts +++ b/shopify/server/lib/oauth.test.ts @@ -14,7 +14,7 @@ import { import { createHmac } from "node:crypto"; const SELF = "https://sites-shopify.deco.site"; -const MESH = "https://mesh.example.com"; +const MESH = "https://api.decocms.com"; const SECRET = "oauth-test-secret"; const CLIENT_ID = "test-client-id"; const CLIENT_SECRET = "test-client-secret"; @@ -27,7 +27,6 @@ beforeEach(() => { process.env.SHOPIFY_CLIENT_SECRET = CLIENT_SECRET; process.env.SHOPIFY_TOKEN_SECRET = SECRET; process.env.SELF_URL = SELF; - process.env.MESH_URL = MESH; }); afterEach(() => { @@ -36,8 +35,8 @@ afterEach(() => { delete process.env.SHOPIFY_CLIENT_SECRET; delete process.env.SHOPIFY_TOKEN_SECRET; delete process.env.SELF_URL; - delete process.env.MESH_URL; delete process.env.SHOPIFY_SCOPES; + delete process.env.MESH_URL; }); function shopifyHmac(params: Record): string { @@ -55,6 +54,12 @@ describe("authorizationUrl", () => { expect(url.pathname).toBe(OAUTH_CONNECT_PATH); expect(url.searchParams.get("callback_url")).toBe(CALLBACK); }); + + test("defaults to the prod domain (not localhost) when SELF_URL is unset", () => { + delete process.env.SELF_URL; + const url = new URL(shopifyOAuth.authorizationUrl(CALLBACK)); + expect(url.origin).toBe("https://sites-shopify.deco.site"); + }); }); describe("GET /oauth/custom", () => { @@ -78,6 +83,25 @@ describe("GET /oauth/custom", () => { expect(res?.status).toBe(400); }); + test("MESH_URL allows a self-hosted mesh origin (e.g. local studio)", async () => { + const studio = "https://studio.internal/oauth/callback?state=s"; + // Without the override this custom origin is rejected... + const rejected = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent(studio)}`, + ), + ); + expect(rejected?.status).toBe(400); + // ...but setting MESH_URL to that origin allows it. + process.env.MESH_URL = "https://studio.internal"; + const allowed = await handleOAuthRoute( + new Request( + `${SELF}${OAUTH_CONNECT_PATH}?callback_url=${encodeURIComponent(studio)}`, + ), + ); + expect(allowed?.status).toBe(200); + }); + test("redirects to Shopify authorize once a shop is provided", async () => { const res = await handleOAuthRoute( new Request( diff --git a/shopify/server/lib/oauth.ts b/shopify/server/lib/oauth.ts index 6ba3f494..b4172e97 100644 --- a/shopify/server/lib/oauth.ts +++ b/shopify/server/lib/oauth.ts @@ -67,12 +67,15 @@ export function getScopes(): string { return process.env.SHOPIFY_SCOPES?.trim() || DEFAULT_SCOPES; } +/** This MCP's own public origin. Deployed at a fixed domain, so we default to + * it; override with SELF_URL for local dev (e.g. http://localhost:8001). */ +const DEFAULT_SELF_URL = "https://sites-shopify.deco.site"; + interface OAuthEnv { clientId: string; clientSecret: string; tokenSecret: string; selfUrl: string; - meshUrl: string; } /** Resolve OAuth config lazily (process.env isn't populated at module init on @@ -91,8 +94,7 @@ function getOAuthEnv(): OAuthEnv { clientId, clientSecret, tokenSecret, - selfUrl: process.env.SELF_URL || "http://localhost:8000", - meshUrl: process.env.MESH_URL || "http://localhost:3000", + selfUrl: process.env.SELF_URL || DEFAULT_SELF_URL, }; } @@ -102,17 +104,44 @@ function isValidShopDomain(shop: string): boolean { return SHOP_DOMAIN_RE.test(shop); } -/** Only ever redirect the OAuth code back to the mesh (or our own) origin. */ -function isAllowedCallback(callbackUrl: string, env: OAuthEnv): boolean { +/** The deco mesh (which mints the OAuth callback URL) lives under decocms.com. */ +const ALLOWED_CALLBACK_HOST_SUFFIXES = ["decocms.com"] as const; +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** + * Guard against open-redirect / code theft: only ever hand the OAuth code back + * to the mesh's origin. The mesh callback lives under decocms.com; loopback is + * allowed over http for local dev (RFC 8252 §7.3), everything else must be + * https. Same model as the GitHub MCP's redirect allowlist. + * + * MESH_URL is an optional operator-controlled override: set it to allow a + * self-hosted mesh origin (e.g. a local deco studio on a custom host). + */ +function isAllowedCallback(callbackUrl: string): boolean { + let url: URL; try { - const origin = new URL(callbackUrl).origin; - return ( - origin === new URL(env.meshUrl).origin || - origin === new URL(env.selfUrl).origin - ); + url = new URL(callbackUrl); } catch { return false; } + + // Explicit override for a self-hosted / local mesh (trusted, operator-set). + const meshUrl = process.env.MESH_URL; + if (meshUrl) { + try { + if (url.origin === new URL(meshUrl).origin) return true; + } catch { + // fall through to the default allowlist + } + } + + const host = url.hostname.toLowerCase(); + const isLoopback = LOOPBACK_HOSTS.has(host); + if (url.protocol === "http:" && isLoopback) return true; + if (url.protocol !== "https:") return false; + return ALLOWED_CALLBACK_HOST_SUFFIXES.some( + (suffix) => host === suffix || host.endsWith(`.${suffix}`), + ); } // ── Runtime oauth hook ──────────────────────────────────────────────────────── @@ -188,7 +217,7 @@ function connectForm(callbackUrl: string): Response { * into Shopify's authorize endpoint. */ function handleConnect(url: URL, env: OAuthEnv): Response { const callbackUrl = url.searchParams.get("callback_url"); - if (!callbackUrl || !isAllowedCallback(callbackUrl, env)) { + if (!callbackUrl || !isAllowedCallback(callbackUrl)) { return htmlResponse("Invalid or missing callback URL.", 400); } @@ -225,7 +254,7 @@ async function handleCallback(url: URL, env: OAuthEnv): Promise { params.get("state") ?? "", env.tokenSecret, ); - if (!state?.cb || !isAllowedCallback(state.cb, env)) { + if (!state?.cb || !isAllowedCallback(state.cb)) { return htmlResponse("Invalid OAuth state.", 400); }