From c3079b689a62d0747aeeff7b8bd4392665468ee5 Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Tue, 18 Aug 2026 21:45:42 +0800 Subject: [PATCH 1/2] fix(shared): pin Google OAuth redirect_uri to decocms.com/deco.site allowlist createGoogleOAuth() forwarded the runtime-supplied callback URL verbatim as redirect_uri to Google and the token endpoint. Google delivers the auth code to that host, so an attacker-influenced origin could hijack the code/token. Mirrors the github MCP fix (#453), extended to also allow deco.site since most Google MCPs are hosted there. Co-Authored-By: Claude Sonnet 5 --- shared/google-oauth.ts | 5 ++ shared/redirect-allowlist.test.ts | 76 +++++++++++++++++++++++++++++++ shared/redirect-allowlist.ts | 63 +++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 shared/redirect-allowlist.test.ts create mode 100644 shared/redirect-allowlist.ts diff --git a/shared/google-oauth.ts b/shared/google-oauth.ts index b76bcf06..ce9a9c72 100644 --- a/shared/google-oauth.ts +++ b/shared/google-oauth.ts @@ -14,6 +14,8 @@ * ``` */ +import { assertAllowedRedirectUri } from "./redirect-allowlist.ts"; + export interface GoogleOAuthConfig { /** * Google OAuth scopes required by this MCP @@ -61,6 +63,8 @@ export function createGoogleOAuth(config: GoogleOAuthConfig) { * Handles Google's requirement for clean redirect_uri (without state param) */ authorizationUrl: (callbackUrl: string, env?: any) => { + assertAllowedRedirectUri(callbackUrl); + const callbackUrlObj = new URL(callbackUrl); const state = callbackUrlObj.searchParams.get("state"); @@ -104,6 +108,7 @@ export function createGoogleOAuth(config: GoogleOAuthConfig) { "redirect_uri is required for Google OAuth token exchange", ); } + assertAllowedRedirectUri(redirect_uri); const params = new URLSearchParams({ code, diff --git a/shared/redirect-allowlist.test.ts b/shared/redirect-allowlist.test.ts new file mode 100644 index 00000000..86d2ef51 --- /dev/null +++ b/shared/redirect-allowlist.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + assertAllowedRedirectUri, + isAllowedRedirectUri, +} from "./redirect-allowlist.ts"; + +describe("isAllowedRedirectUri", () => { + test("accepts decocms.com apex and arbitrary subdomains over https", () => { + expect(isAllowedRedirectUri("https://decocms.com/cb")).toBe(true); + expect( + isAllowedRedirectUri( + "https://google-gmail-mcp.decocms.com/oauth/callback", + ), + ).toBe(true); + expect(isAllowedRedirectUri("https://a.b.decocms.com/cb")).toBe(true); + }); + + test("accepts deco.site apex and arbitrary subdomains over https", () => { + expect(isAllowedRedirectUri("https://deco.site/cb")).toBe(true); + expect( + isAllowedRedirectUri( + "https://sites-youtube-channel-admin.deco.site/oauth/callback", + ), + ).toBe(true); + expect(isAllowedRedirectUri("https://a.b.deco.site/cb")).toBe(true); + }); + + test("allows loopback over http for local dev", () => { + expect(isAllowedRedirectUri("http://localhost:8787/oauth/callback")).toBe( + true, + ); + expect(isAllowedRedirectUri("http://127.0.0.1:8787/cb")).toBe(true); + }); + + test("rejects non-loopback http", () => { + expect(isAllowedRedirectUri("http://sites-google-drive.deco.site/cb")).toBe( + false, + ); + }); + + test("rejects look-alike and suffix-confusion hosts", () => { + expect(isAllowedRedirectUri("https://evildecocms.com/cb")).toBe(false); + expect(isAllowedRedirectUri("https://decocms.com.attacker.io/cb")).toBe( + false, + ); + expect(isAllowedRedirectUri("https://notdeco.site/cb")).toBe(false); + expect(isAllowedRedirectUri("https://deco.site.attacker.io/cb")).toBe( + false, + ); + }); + + test("rejects other schemes and malformed input", () => { + expect(isAllowedRedirectUri("javascript:alert(1)")).toBe(false); + expect(isAllowedRedirectUri("ftp://decocms.com/cb")).toBe(false); + expect(isAllowedRedirectUri("not a url")).toBe(false); + expect(isAllowedRedirectUri("")).toBe(false); + }); + + test("is case-insensitive on the host", () => { + expect(isAllowedRedirectUri("https://Sites-Gmail.Deco.Site/cb")).toBe(true); + }); +}); + +describe("assertAllowedRedirectUri", () => { + test("passes for an allowed uri", () => { + expect(() => + assertAllowedRedirectUri("https://sites-google-docs.deco.site/cb"), + ).not.toThrow(); + }); + + test("throws for a disallowed uri", () => { + expect(() => assertAllowedRedirectUri("https://attacker.io/cb")).toThrow( + /Refusing OAuth redirect_uri/, + ); + }); +}); diff --git a/shared/redirect-allowlist.ts b/shared/redirect-allowlist.ts new file mode 100644 index 00000000..499dbe78 --- /dev/null +++ b/shared/redirect-allowlist.ts @@ -0,0 +1,63 @@ +/** + * OAuth `redirect_uri` allowlist, shared by every OAuth-based MCP. + * + * The runtime hands `authorizationUrl()` a callback URL that we forward to + * the identity provider (e.g. Google) as the `redirect_uri`. The provider + * delivers the authorization `code` to whatever host that URL points at, so + * if the origin is ever attacker-influenced (spoofed Host header, an + * injected query/redirect param, a misconfigured route) the code — and the + * access token minted from it — leaks to that host. + * + * We close this by refusing any `redirect_uri` whose host isn't one of our + * own domains (or a subdomain of one). MCPs are hosted under either + * decocms.com or deco.site depending on how they were deployed, so both are + * allowed. Loopback hosts are allowed over http for local dev (RFC 8252 §7.3); + * everything else must be https. + */ + +export const ALLOWED_REDIRECT_HOST_SUFFIXES = [ + "decocms.com", + "deco.site", +] as const; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +/** Returns true if `redirectUri` is a well-formed URL pointing at an allowed host. */ +export function isAllowedRedirectUri(redirectUri: string): boolean { + let url: URL; + try { + url = new URL(redirectUri); + } catch { + return false; + } + + const host = url.hostname.toLowerCase(); + const isLoopback = LOOPBACK_HOSTS.has(host); + + // https everywhere; http only for loopback dev hosts. + if (url.protocol === "https:") { + // ok + } else if (url.protocol === "http:" && isLoopback) { + return true; + } else { + return false; + } + + if (isLoopback) return true; + + // `endsWith(".decocms.com")` enforces a label boundary, so "evildecocms.com" + // and "decocms.com.attacker.io" are both rejected. + return ALLOWED_REDIRECT_HOST_SUFFIXES.some( + (suffix) => host === suffix || host.endsWith(`.${suffix}`), + ); +} + +/** Throws if `redirectUri` is not on the allowlist. */ +export function assertAllowedRedirectUri(redirectUri: string): void { + if (!isAllowedRedirectUri(redirectUri)) { + throw new Error( + `Refusing OAuth redirect_uri outside the allowed domains ` + + `(${ALLOWED_REDIRECT_HOST_SUFFIXES.join(", ")}): ${redirectUri}`, + ); + } +} From 848550cdbd702fb9798f27dc4635c12e997ab5f7 Mon Sep 17 00:00:00 2001 From: Jaynel Patiarba Date: Tue, 18 Aug 2026 21:57:33 +0800 Subject: [PATCH 2/2] fix(shared): allow deco.host in OAuth redirect_uri allowlist deco.host is the local-dev tunnel domain (cloudflared-style) used to test OAuth flows against providers that reject http://localhost redirect URIs. Co-Authored-By: Claude Sonnet 5 --- shared/redirect-allowlist.test.ts | 12 ++++++++++++ shared/redirect-allowlist.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/shared/redirect-allowlist.test.ts b/shared/redirect-allowlist.test.ts index 86d2ef51..81e95789 100644 --- a/shared/redirect-allowlist.test.ts +++ b/shared/redirect-allowlist.test.ts @@ -32,10 +32,18 @@ describe("isAllowedRedirectUri", () => { expect(isAllowedRedirectUri("http://127.0.0.1:8787/cb")).toBe(true); }); + test("accepts deco.host apex and arbitrary subdomains over https", () => { + expect(isAllowedRedirectUri("https://deco.host/cb")).toBe(true); + expect( + isAllowedRedirectUri("https://localhost-c056dce8.deco.host/cb"), + ).toBe(true); + }); + test("rejects non-loopback http", () => { expect(isAllowedRedirectUri("http://sites-google-drive.deco.site/cb")).toBe( false, ); + expect(isAllowedRedirectUri("http://tunnel.deco.host/cb")).toBe(false); }); test("rejects look-alike and suffix-confusion hosts", () => { @@ -47,6 +55,10 @@ describe("isAllowedRedirectUri", () => { expect(isAllowedRedirectUri("https://deco.site.attacker.io/cb")).toBe( false, ); + expect(isAllowedRedirectUri("https://notdeco.host/cb")).toBe(false); + expect(isAllowedRedirectUri("https://deco.host.attacker.io/cb")).toBe( + false, + ); }); test("rejects other schemes and malformed input", () => { diff --git a/shared/redirect-allowlist.ts b/shared/redirect-allowlist.ts index 499dbe78..90315423 100644 --- a/shared/redirect-allowlist.ts +++ b/shared/redirect-allowlist.ts @@ -10,14 +10,17 @@ * * We close this by refusing any `redirect_uri` whose host isn't one of our * own domains (or a subdomain of one). MCPs are hosted under either - * decocms.com or deco.site depending on how they were deployed, so both are - * allowed. Loopback hosts are allowed over http for local dev (RFC 8252 §7.3); - * everything else must be https. + * decocms.com or deco.site depending on how they were deployed; deco.host is + * additionally allowed since it's the local-dev tunnel domain (cloudflared- + * style), needed to test OAuth flows against providers that reject + * http://localhost redirect URIs. Loopback hosts are allowed over http for + * local dev (RFC 8252 §7.3); everything else must be https. */ export const ALLOWED_REDIRECT_HOST_SUFFIXES = [ "decocms.com", "deco.site", + "deco.host", ] as const; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);