diff --git a/lib/mcp-gateway.ts b/lib/mcp-gateway.ts new file mode 100644 index 0000000..b57735b --- /dev/null +++ b/lib/mcp-gateway.ts @@ -0,0 +1,15 @@ +// Only the Moshcode product host exposes this gateway. Other tenant domains +// remain independent, and neither a query parameter nor a forwarded host can +// choose the destination receiving a client's bearer token. +const MCP_UPSTREAM = "https://app.moshcode.sh"; + +export function mcpGatewayTarget(host: string, pathname: string, search = ""): URL | null { + if (host.toLowerCase() !== "moshcode.sh") return null; + const metadata = pathname === "/.well-known/oauth-authorization-server" + || /^\/\.well-known\/oauth-protected-resource(?:\/mcp|\/api\/v1\/mcp\/[A-Za-z0-9_-]{1,128})?$/.test(pathname); + const share = /^\/api\/v1\/mcp\/[A-Za-z0-9_-]{1,128}(?:\/[A-Za-z0-9_-]{1,128})?$/.test(pathname); + if (!metadata && !share) return null; + const target = new URL(pathname, MCP_UPSTREAM); + target.search = search; + return target; +} diff --git a/middleware.ts b/middleware.ts index 8d57bc2..64521bb 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { mcpGatewayTarget } from "./lib/mcp-gateway"; // Parked domains allowed to frame the app (Porkbun masked/frameset forwarding, // e.g. moshcode.sh). Read at RUNTIME so FRAME_ANCESTORS env changes take effect @@ -33,6 +34,13 @@ export function middleware(req: NextRequest) { url.port = ""; return NextResponse.redirect(url, 308); } + const mcpTarget = mcpGatewayTarget(host, req.nextUrl.pathname, req.nextUrl.search); + if (mcpTarget) { + // A rewrite preserves methods, request bodies, Authorization and streaming + // responses. OAuth consent and token endpoints remain on the app origin; + // the resource URL the client added remains on moshcode.sh. + return NextResponse.rewrite(mcpTarget); + } const res = NextResponse.next(); res.headers.set("Content-Security-Policy", `frame-ancestors ${frameAncestors(req)}`); diff --git a/tests/mcp-gateway.test.mjs b/tests/mcp-gateway.test.mjs new file mode 100644 index 0000000..ae8d30f --- /dev/null +++ b/tests/mcp-gateway.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mcpGatewayTarget } from "../lib/mcp-gateway.ts"; + +test("Moshcode share and discovery URLs reach the fixed app authority", () => { + for (const path of [ + "/api/v1/mcp/mcs_example", "/api/v1/mcp/shares", "/api/v1/mcp/shares/mcs_example", + "/.well-known/oauth-authorization-server", "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-protected-resource/api/v1/mcp/mcs_example", + ]) assert.equal(mcpGatewayTarget("moshcode.sh", path).href, `https://app.moshcode.sh${path}`); +}); + +test("a caller cannot change the bearer-token destination through path or query input", () => { + assert.equal(mcpGatewayTarget("moshcode.sh", "/api/v1/mcp/mcs_example", "?upstream=https://evil.example").origin, + "https://app.moshcode.sh"); + for (const path of ["//evil.example/api/v1/mcp/x", "/api/v1/mcp/../oauth/token", "/api/v1/mcp/%2f%2fevil.example", "/api/v1/mcp/x/../../../oauth/token"]) + assert.equal(mcpGatewayTarget("moshcode.sh", path), null); +}); + +test("tenant sites, account routes and ordinary pages stay outside the gateway", () => { + for (const host of ["moshcoding.com", "moshscript.com", "moshcode.sh.evil.example", "moshcode.sh:8080", ""]) + assert.equal(mcpGatewayTarget(host, "/api/v1/mcp/mcs_example"), null); + for (const path of ["/", "/oauth/authorize", "/oauth/token", "/api/v1/me", "/api/sessions", "/api/v1/mcp"]) + assert.equal(mcpGatewayTarget("moshcode.sh", path), null); +});