diff --git a/bun.lock b/bun.lock index 412e9b85..cd55ab12 100644 --- a/bun.lock +++ b/bun.lock @@ -310,9 +310,9 @@ "version": "1.0.0", "dependencies": { "@decocms/bindings": "^1.4.0", - "@decocms/runtime": "^1.6.0", + "@decocms/runtime": "^3.0.0", "@modelcontextprotocol/sdk": "^1.27.1", - "zod": "^4.0.0", + "zod": "4.3.6", }, "devDependencies": { "@cloudflare/workers-types": "^4.20251014.0", @@ -4284,13 +4284,13 @@ "github/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], - "github/@decocms/runtime": ["@decocms/runtime@1.6.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.10", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.29.0", "jose": "^6.0.11", "zod": "^4.0.0" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-r6O4z+ISJpBnhDw4x1K8IYNqfQ+xdwSjNcg6vDqU42I6x82H8snfAg/E6u9WfcMClap7sXxMAdKuDcEMxPq55A=="], + "github/@decocms/runtime": ["@decocms/runtime@3.0.0", "", { "dependencies": { "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "jose": "^6.0.11", "zod": "4.3.6" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-nv0NyNmsGJsdA/B9dwGPXcHBJPeLn1/OIM8WBjawkvKWXFumFjoZnXgXG3G9/dYixgnO2320EwYxmuCVxaHcQA=="], "github/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "github/@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], - "github/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "github/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "github-repo-reports/@decocms/runtime": ["@decocms/runtime@1.6.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.10", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.29.0", "jose": "^6.0.11", "zod": "^4.0.0" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-r6O4z+ISJpBnhDw4x1K8IYNqfQ+xdwSjNcg6vDqU42I6x82H8snfAg/E6u9WfcMClap7sXxMAdKuDcEMxPq55A=="], @@ -4890,8 +4890,12 @@ "github/@decocms/bindings/@tanstack/react-router": ["@tanstack/react-router@1.169.2", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.169.2", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-OJM7Kguc7ERnweaNRWsyWgIKcl3z23rD1B4jaxjzd9RGdnzpt2HfrWa9rggbT0Hfzhfo4D2ZmsfoTme035tniQ=="], + "github/@decocms/bindings/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "github/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "github/@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "github/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "google-analytics/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.4.9", "", { "dependencies": { "@decocms/mcp-utils": "^1.0.5", "@modelcontextprotocol/sdk": "1.29.0", "@tanstack/react-router": "1.169.2", "react": "^19.2.6", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NvhuHsKL0YpSUaAZqEe0PAzF41/JJSi+H0X7HpMBScOVpALcGqAC+DaJvcKeo3aRXXW7z6du0oCdkhLf2A6Vjw=="], diff --git a/github/package.json b/github/package.json index 4bd5ad0e..50edfb75 100644 --- a/github/package.json +++ b/github/package.json @@ -13,9 +13,9 @@ }, "dependencies": { "@decocms/bindings": "^1.4.0", - "@decocms/runtime": "^1.6.0", + "@decocms/runtime": "^3.0.0", "@modelcontextprotocol/sdk": "^1.27.1", - "zod": "^4.0.0" + "zod": "4.3.6" }, "devDependencies": { "@cloudflare/workers-types": "^4.20251014.0", diff --git a/github/server/lib/redirect-allowlist.test.ts b/github/server/lib/redirect-allowlist.test.ts deleted file mode 100644 index 450e5658..00000000 --- a/github/server/lib/redirect-allowlist.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - assertAllowedRedirectUri, - isAllowedRedirectUri, -} from "./redirect-allowlist.ts"; - -describe("isAllowedRedirectUri", () => { - test("accepts the canonical origin", () => { - expect( - isAllowedRedirectUri("https://github-mcp.decocms.com/oauth/callback"), - ).toBe(true); - }); - - test("accepts apex and arbitrary subdomains over https", () => { - expect(isAllowedRedirectUri("https://decocms.com/cb")).toBe(true); - expect(isAllowedRedirectUri("https://preview.decocms.com/cb")).toBe(true); - expect(isAllowedRedirectUri("https://a.b.decocms.com/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://github-mcp.decocms.com/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://decocms.com.evil/cb")).toBe(false); - expect(isAllowedRedirectUri("https://notdecocms.com/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://GitHub-MCP.DecoCMS.com/cb")).toBe( - true, - ); - }); -}); - -describe("assertAllowedRedirectUri", () => { - test("passes for an allowed uri", () => { - expect(() => - assertAllowedRedirectUri("https://github-mcp.decocms.com/oauth/callback"), - ).not.toThrow(); - }); - - test("throws for a disallowed uri", () => { - expect(() => assertAllowedRedirectUri("https://attacker.io/cb")).toThrow( - /Refusing OAuth redirect_uri/, - ); - }); -}); diff --git a/github/server/lib/redirect-allowlist.ts b/github/server/lib/redirect-allowlist.ts deleted file mode 100644 index f867b6a6..00000000 --- a/github/server/lib/redirect-allowlist.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * OAuth `redirect_uri` allowlist. - * - * The runtime hands `authorizationUrl()` a callback URL that we forward to - * GitHub as the `redirect_uri`. GitHub 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 decocms.com or - * one of its subdomains. Loopback hosts are allowed over http for local dev - * (RFC 8252 §7.3); everything else must be https. - */ - -import { ALLOWED_REDIRECT_HOST_SUFFIXES } from "../constants.ts"; - -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/github/server/main.ts b/github/server/main.ts index 9fe78c71..8ac27ad0 100644 --- a/github/server/main.ts +++ b/github/server/main.ts @@ -25,8 +25,11 @@ import { handleRepoGrantTokenRequest, } from "./lib/repo-grant.ts"; import { setRepoGrantKV } from "./lib/repo-grant-store.ts"; -import { assertAllowedRedirectUri } from "./lib/redirect-allowlist.ts"; -import { REPO_GRANT_REVOKE_PATH, REPO_GRANT_TOKEN_PATH } from "./constants.ts"; +import { + ALLOWED_REDIRECT_HOST_SUFFIXES, + REPO_GRANT_REVOKE_PATH, + REPO_GRANT_TOKEN_PATH, +} from "./constants.ts"; import { setTriggerKV } from "./lib/trigger-store.ts"; import { getTools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; @@ -65,6 +68,7 @@ async function getRuntime(): Promise { oauth: { mode: "PKCE", authorizationServer: "https://github.com", + allowedRedirectHosts: [...ALLOWED_REDIRECT_HOST_SUFFIXES], authorizationUrl: (callbackUrl) => { const clientId = process.env.GITHUB_CLIENT_ID || ""; @@ -75,11 +79,6 @@ async function getRuntime(): Promise { callbackUrlObj.searchParams.delete("state"); const redirectUri = callbackUrlObj.toString(); - // Defense-in-depth: never forward a redirect_uri off the decocms.com - // origin to GitHub. GitHub returns the authorization `code` here, so - // an attacker-influenced host would hijack the code/token. - assertAllowedRedirectUri(redirectUri); - const url = new URL("https://github.com/login/oauth/authorize"); url.searchParams.set("client_id", clientId); url.searchParams.set("redirect_uri", redirectUri);