diff --git a/airtable/server/lib/redirect-allowlist.test.ts b/airtable/server/lib/redirect-allowlist.test.ts new file mode 100644 index 00000000..0aa57533 --- /dev/null +++ b/airtable/server/lib/redirect-allowlist.test.ts @@ -0,0 +1,74 @@ +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://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-airtable.deco.site/oauth/callback"), + ).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("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-airtable.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://notdeco.host/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://DecoCMS.com/cb")).toBe(true); + }); +}); + +describe("assertAllowedRedirectUri", () => { + test("passes for an allowed uri", () => { + expect(() => + assertAllowedRedirectUri("https://sites-airtable.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/airtable/server/lib/redirect-allowlist.ts b/airtable/server/lib/redirect-allowlist.ts new file mode 100644 index 00000000..22794178 --- /dev/null +++ b/airtable/server/lib/redirect-allowlist.ts @@ -0,0 +1,65 @@ +/** + * OAuth `redirect_uri` allowlist. + * + * The runtime hands `authorizationUrl()` a callback URL that we forward to + * Airtable as the `redirect_uri`. Airtable 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): decocms.com, deco.site (production + * hosting) or deco.host (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}`, + ); + } +} diff --git a/airtable/server/main.ts b/airtable/server/main.ts index 37643a96..b55931ff 100644 --- a/airtable/server/main.ts +++ b/airtable/server/main.ts @@ -4,6 +4,7 @@ import { serve } from "@decocms/mcps-shared/serve"; import { z } from "zod"; import { tools } from "./tools/index.ts"; import { AIRTABLE_SCOPES } from "./constants.ts"; +import { assertAllowedRedirectUri } from "./lib/redirect-allowlist.ts"; const StateSchema = z.object({}); @@ -14,6 +15,8 @@ const runtime = withRuntime({ mode: "PKCE", authorizationServer: "https://airtable.com", authorizationUrl: (callbackUrl) => { + assertAllowedRedirectUri(callbackUrl); + const callback = new URL(callbackUrl); const state = callback.searchParams.get("state"); callback.searchParams.delete("state"); @@ -27,6 +30,8 @@ const runtime = withRuntime({ return url.toString(); }, exchangeCode: async ({ code, code_verifier, redirect_uri }) => { + assertAllowedRedirectUri(redirect_uri ?? ""); + const clientId = process.env.AIRTABLE_CLIENT_ID ?? ""; const clientSecret = process.env.AIRTABLE_CLIENT_SECRET ?? ""; diff --git a/dropbox/server/lib/redirect-allowlist.test.ts b/dropbox/server/lib/redirect-allowlist.test.ts new file mode 100644 index 00000000..3b8394e4 --- /dev/null +++ b/dropbox/server/lib/redirect-allowlist.test.ts @@ -0,0 +1,74 @@ +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://dropbox-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); + }); + + 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("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://dropbox-mcp.decocms.com/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://notdeco.host/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://DecoCMS.com/cb")).toBe(true); + }); +}); + +describe("assertAllowedRedirectUri", () => { + test("passes for an allowed uri", () => { + expect(() => + assertAllowedRedirectUri("https://dropbox-mcp.decocms.com/cb"), + ).not.toThrow(); + }); + + test("throws for a disallowed uri", () => { + expect(() => assertAllowedRedirectUri("https://attacker.io/cb")).toThrow( + /Refusing OAuth redirect_uri/, + ); + }); +}); diff --git a/dropbox/server/lib/redirect-allowlist.ts b/dropbox/server/lib/redirect-allowlist.ts new file mode 100644 index 00000000..263696b7 --- /dev/null +++ b/dropbox/server/lib/redirect-allowlist.ts @@ -0,0 +1,65 @@ +/** + * OAuth `redirect_uri` allowlist. + * + * The runtime hands `authorizationUrl()` a callback URL that we forward to + * Dropbox as the `redirect_uri`. Dropbox 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): decocms.com, deco.site (production + * hosting) or deco.host (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}`, + ); + } +} diff --git a/dropbox/server/main.ts b/dropbox/server/main.ts index 978a4831..30d71fb3 100644 --- a/dropbox/server/main.ts +++ b/dropbox/server/main.ts @@ -17,6 +17,7 @@ import { refreshAccessToken, REQUESTED_SCOPES, } from "./lib/dropbox-oauth.ts"; +import { assertAllowedRedirectUri } from "./lib/redirect-allowlist.ts"; import { tools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; @@ -38,6 +39,8 @@ const runtime = withRuntime({ authorizationServer: "https://www.dropbox.com", authorizationUrl: (callbackUrl) => { + assertAllowedRedirectUri(callbackUrl); + const clientId = process.env.DROPBOX_CLIENT_ID || ""; const callbackUrlObj = new URL(callbackUrl); const state = callbackUrlObj.searchParams.get("state"); @@ -62,6 +65,8 @@ const runtime = withRuntime({ }, exchangeCode: async ({ code, code_verifier, redirect_uri }) => { + assertAllowedRedirectUri(redirect_uri ?? ""); + const { clientId, clientSecret } = getOAuthCredentials(); const tokenResponse = await exchangeCodeForToken({ diff --git a/microsoft-teams/server/lib/redirect-allowlist.test.ts b/microsoft-teams/server/lib/redirect-allowlist.test.ts new file mode 100644 index 00000000..7ec20d11 --- /dev/null +++ b/microsoft-teams/server/lib/redirect-allowlist.test.ts @@ -0,0 +1,72 @@ +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://graph-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); + }); + + 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("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://graph-mcp.decocms.com/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://notdeco.host/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://DecoCMS.com/cb")).toBe(true); + }); +}); + +describe("assertAllowedRedirectUri", () => { + test("passes for an allowed uri", () => { + expect(() => + assertAllowedRedirectUri("https://graph-mcp.decocms.com/cb"), + ).not.toThrow(); + }); + + test("throws for a disallowed uri", () => { + expect(() => assertAllowedRedirectUri("https://attacker.io/cb")).toThrow( + /Refusing OAuth redirect_uri/, + ); + }); +}); diff --git a/microsoft-teams/server/lib/redirect-allowlist.ts b/microsoft-teams/server/lib/redirect-allowlist.ts new file mode 100644 index 00000000..91b991f7 --- /dev/null +++ b/microsoft-teams/server/lib/redirect-allowlist.ts @@ -0,0 +1,65 @@ +/** + * OAuth `redirect_uri` allowlist. + * + * The runtime hands `authorizationUrl()` a callback URL that we forward to + * Microsoft as the `redirect_uri`. Microsoft 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): decocms.com, deco.site (production + * hosting) or deco.host (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}`, + ); + } +} diff --git a/microsoft-teams/server/main.ts b/microsoft-teams/server/main.ts index 1fcb7273..633df19f 100644 --- a/microsoft-teams/server/main.ts +++ b/microsoft-teams/server/main.ts @@ -17,6 +17,7 @@ import type { Registry } from "@decocms/mcps-shared/registry"; import { tools } from "./tools/index.ts"; import { StateSchema, type Env } from "./types/env.ts"; import { exchangeAuthCode, exchangeRefreshToken, SCOPES } from "./lib/oauth.ts"; +import { assertAllowedRedirectUri } from "./lib/redirect-allowlist.ts"; import { setKvNamespace } from "./lib/kv.ts"; import { app as webhookRouter } from "./router.ts"; @@ -57,6 +58,8 @@ function getRuntime(): Runtime { authorizationServer: "https://login.microsoftonline.com", authorizationUrl: (callbackUrl) => { + assertAllowedRedirectUri(callbackUrl); + const { tenantId, clientId } = getOAuthCredentials(); const callbackUrlObj = new URL(callbackUrl); const state = callbackUrlObj.searchParams.get("state"); @@ -78,6 +81,8 @@ function getRuntime(): Runtime { }, exchangeCode: async ({ code, code_verifier, redirect_uri }) => { + assertAllowedRedirectUri(redirect_uri ?? ""); + const { tenantId, clientId, clientSecret } = getOAuthCredentials(); const tokens = await exchangeAuthCode( tenantId, diff --git a/microsoft-teams/tsconfig.json b/microsoft-teams/tsconfig.json index 24e2b1c7..074730af 100644 --- a/microsoft-teams/tsconfig.json +++ b/microsoft-teams/tsconfig.json @@ -17,7 +17,7 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "types": ["@types/node", "@cloudflare/workers-types"] + "types": ["@types/node", "@cloudflare/workers-types", "@types/bun"] }, "include": ["server"] } diff --git a/openrouter/server/lib/redirect-allowlist.test.ts b/openrouter/server/lib/redirect-allowlist.test.ts new file mode 100644 index 00000000..8ecf105a --- /dev/null +++ b/openrouter/server/lib/redirect-allowlist.test.ts @@ -0,0 +1,74 @@ +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://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-openrouter.deco.site/oauth/callback"), + ).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("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-openrouter.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://notdeco.host/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://DecoCMS.com/cb")).toBe(true); + }); +}); + +describe("assertAllowedRedirectUri", () => { + test("passes for an allowed uri", () => { + expect(() => + assertAllowedRedirectUri("https://sites-openrouter.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/openrouter/server/lib/redirect-allowlist.ts b/openrouter/server/lib/redirect-allowlist.ts new file mode 100644 index 00000000..7ce630a7 --- /dev/null +++ b/openrouter/server/lib/redirect-allowlist.ts @@ -0,0 +1,65 @@ +/** + * OAuth `redirect_uri` allowlist. + * + * The runtime hands `authorizationUrl()` a callback URL that we forward to + * OpenRouter as the `callback_url`. OpenRouter 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 API key minted from it — + * leaks to that host. + * + * We close this by refusing any callback URL whose host isn't one of our + * own domains (or a subdomain of one): decocms.com, deco.site (production + * hosting) or deco.host (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}`, + ); + } +} diff --git a/openrouter/server/main.ts b/openrouter/server/main.ts index 8308e3ad..a4a1af4f 100644 --- a/openrouter/server/main.ts +++ b/openrouter/server/main.ts @@ -12,6 +12,7 @@ import { serve } from "@decocms/mcps-shared/serve"; import { type DefaultEnv, withRuntime } from "@decocms/runtime"; import { z } from "zod"; import { logger } from "./lib/logger.ts"; +import { assertAllowedRedirectUri } from "./lib/redirect-allowlist.ts"; import { tools } from "./tools/index.ts"; const StateSchema = z.object({}); @@ -29,6 +30,8 @@ const runtime = withRuntime({ // Generates the URL to redirect users to for authorization authorizationUrl: (callbackUrl) => { + assertAllowedRedirectUri(callbackUrl); + const url = new URL("https://openrouter.ai/auth"); url.searchParams.set("callback_url", callbackUrl); // Optional: Add PKCE code challenge for extra security