Skip to content
Open
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
5 changes: 5 additions & 0 deletions shared/google-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
* ```
*/

import { assertAllowedRedirectUri } from "./redirect-allowlist.ts";

export interface GoogleOAuthConfig {
/**
* Google OAuth scopes required by this MCP
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions shared/redirect-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
66 changes: 66 additions & 0 deletions shared/redirect-allowlist.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
Loading