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
74 changes: 74 additions & 0 deletions airtable/server/lib/redirect-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
65 changes: 65 additions & 0 deletions airtable/server/lib/redirect-allowlist.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
5 changes: 5 additions & 0 deletions airtable/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});

Expand All @@ -14,6 +15,8 @@ const runtime = withRuntime<Env, typeof StateSchema>({
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");
Expand All @@ -27,6 +30,8 @@ const runtime = withRuntime<Env, typeof StateSchema>({
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 ?? "";

Expand Down
74 changes: 74 additions & 0 deletions dropbox/server/lib/redirect-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
);
});
});
65 changes: 65 additions & 0 deletions dropbox/server/lib/redirect-allowlist.ts
Original file line number Diff line number Diff line change
@@ -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}`,
);
}
}
5 changes: 5 additions & 0 deletions dropbox/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -38,6 +39,8 @@ const runtime = withRuntime<Env, typeof StateSchema, Registry>({
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");
Expand All @@ -62,6 +65,8 @@ const runtime = withRuntime<Env, typeof StateSchema, Registry>({
},

exchangeCode: async ({ code, code_verifier, redirect_uri }) => {
assertAllowedRedirectUri(redirect_uri ?? "");

const { clientId, clientSecret } = getOAuthCredentials();

const tokenResponse = await exchangeCodeForToken({
Expand Down
Loading
Loading