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..81e95789 --- /dev/null +++ b/shared/redirect-allowlist.test.ts @@ -0,0 +1,88 @@ +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("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", () => { + 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, + ); + 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", () => { + 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..90315423 --- /dev/null +++ b/shared/redirect-allowlist.ts @@ -0,0 +1,66 @@ +/** + * 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; 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"]); + +/** 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}`, + ); + } +}