Skip to content
Merged
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
50 changes: 47 additions & 3 deletions monitoring/mcp-status/checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,10 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
});
}

// 5. Dynamic Client Registration (RFC 7591) must mint a client_id.
// 5. Dynamic Client Registration (RFC 7591) must mint a client_id. Registered
// with a LOOPBACK redirect: loopback is always permitted (the hosted-redirect
// allow-list exempts it), so this is the dependency-free way to confirm a
// client can self-register without manual approval, which is what DCR is for.
{
const url = `${mcpOrigin}/oauth/register`;
const r = await probe(url, {
Expand All @@ -391,7 +394,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: "imcp-status-dashboard",
redirect_uris: ["https://example.org/callback"],
redirect_uris: ["http://127.0.0.1:8765/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
Expand All @@ -406,7 +409,7 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
id: "oauth-register",
label: "OAuth Dynamic Client Registration",
description:
"Confirms Dynamic Client Registration (RFC 7591) issues a client_id, so MCP clients can self-register without manual setup.",
"Confirms Dynamic Client Registration (RFC 7591) issues a client_id for a loopback client, so native/CLI MCP clients can self-register without manual setup.",
target: `POST ${url}`,
expected: "200/201 JSON with client_id",
status: pass ? "pass" : "fail",
Expand All @@ -420,6 +423,47 @@ export const checkMcpEndpoints = async (mcpOrigin, timeoutMs) => {
});
}

// 5b. The hosted-redirect allow-list must REJECT a hosted redirect_uri on a
// domain that isn't approved (CWE-601 auth-code phishing guard). A
// non-allow-listed hosted redirect is refused with 400 invalid_redirect_uri
// before any client_id is issued; loopback (above) stays exempt. The probe
// uses a reserved `.invalid` host (RFC 2606) that can never be legitimately
// allow-listed by a deployment, so this can't false-alert if the allow-list
// is widened via OAUTH_ALLOWED_REDIRECT_DOMAINS.
{
const url = `${mcpOrigin}/oauth/register`;
const r = await probe(url, {
timeoutMs,
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
client_name: "imcp-status-dashboard-probe",
redirect_uris: ["https://not-allowlisted.invalid/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
}),
});
const json = tryJson(r.bodyText);
const pass = r.status === 400 && json && json.error === "invalid_redirect_uri";
checks.push({
id: "oauth-register-allowlist",
label: "OAuth redirect allow-list enforced",
description:
"Confirms a hosted redirect_uri on a non-allow-listed domain is rejected (400 invalid_redirect_uri), closing the open-registration auth-code phishing vector (CWE-601). Loopback redirects stay exempt.",
target: `POST ${url}`,
expected: "400 invalid_redirect_uri",
status: pass ? "pass" : "fail",
httpStatus: r.status,
latencyMs: r.latencyMs,
detail: r.error
? `request failed: ${r.error.message}`
: pass
? "rejected non-allow-listed hosted redirect_uri"
: `expected 400 invalid_redirect_uri, got ${r.status}: ${r.bodyText.slice(0, 120)}`,
});
}

// 6. Authorization endpoint liveness: rejects malformed input with 4xx
// (rather than 5xx / connection error). It is interactive, so we only
// assert it is alive and validating, not a full successful redirect.
Expand Down
85 changes: 77 additions & 8 deletions monitoring/mcp-status/checks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,51 @@ const resp = (status, { headers = {}, body = "" } = {}) => ({
});

/**
* Install a fetch stub that dispatches on "METHOD url".
* @param {Record<string, ReturnType<typeof resp>>} routes
* Install a fetch stub that dispatches on "METHOD url". A route value is either a
* `resp(...)` object or a function `(init) => resp(...)` that answers by request
* (used to mirror the /oauth/register allow-list, which branches on the body).
* @param {Record<string, ReturnType<typeof resp> | ((init: RequestInit) => ReturnType<typeof resp>)>} routes
*/
const stubFetch = (routes) => {
const original = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
const key = `${init.method ?? "GET"} ${url.split("?")[0]}`;
if (routes[key]) return routes[key];
const route = routes[key];
if (route) return typeof route === "function" ? route(init) : route;
Comment thread
aterga marked this conversation as resolved.
Resolved
throw new Error(`unexpected fetch: ${key}`);
};
return () => {
globalThis.fetch = original;
};
};

/**
* `/oauth/register` mock mirroring the hosted-redirect allow-list: a loopback
* redirect mints a client_id (201); a non-allow-listed hosted redirect is
* rejected (400 invalid_redirect_uri).
*/
const registerRoute = (init) => {
const ru = (JSON.parse(init.body ?? "{}").redirect_uris ?? [])[0] ?? "";
let loopback = false;
try {
const u = new URL(ru);
// Match the server's is_loopback_redirect: http scheme, NO userinfo, and a
// loopback HOSTNAME (so a look-alike like http://localhost.evil.com, or an
// authority trick like http://user@127.0.0.1, is NOT loopback). URL.hostname
// yields the bracketed [::1]; accept the bare ::1 too for robustness.
loopback =
u.protocol === "http:" &&
u.username === "" &&
u.password === "" &&
["localhost", "127.0.0.1", "[::1]", "::1"].includes(u.hostname);
} catch {
loopback = false;
}
return loopback
? resp(201, { body: JSON.stringify({ client_id: "client-123" }) })
: resp(400, { body: JSON.stringify({ error: "invalid_redirect_uri" }) });
};

test("worstStatus picks the most severe status", () => {
assert.equal(worstStatus(["pass", "pass"]), "pass");
assert.equal(worstStatus(["pass", "warn", "pass"]), "warn");
Expand Down Expand Up @@ -155,9 +185,7 @@ test("checkMcpEndpoints passes for a well-behaved server", async () => {
},
body: JSON.stringify({ error: "invalid_token" }),
}),
[`POST ${origin}/oauth/register`]: resp(201, {
body: JSON.stringify({ client_id: "client-123" }),
}),
[`POST ${origin}/oauth/register`]: registerRoute,
[`GET ${origin}/oauth/authorize`]: resp(400, { body: "missing client_id" }),
[`POST ${origin}/oauth/token`]: resp(400, {
body: JSON.stringify({ error: "invalid_grant" }),
Expand All @@ -172,6 +200,7 @@ test("checkMcpEndpoints passes for a well-behaved server", async () => {
assert.equal(byId(section, "metadata-consistency").status, "pass");
assert.equal(byId(section, "mcp-challenge").status, "pass");
assert.equal(byId(section, "oauth-register").status, "pass");
assert.equal(byId(section, "oauth-register-allowlist").status, "pass");
assert.equal(byId(section, "oauth-authorize").status, "pass");
assert.equal(byId(section, "oauth-token").status, "pass");
// Deployment facts are captured and a GitHub commit link is derived.
Expand Down Expand Up @@ -213,8 +242,45 @@ test("checkMcpEndpoints flags a missing OAuth challenge", async () => {
}),
// 200 instead of a 401 challenge → wrong contract.
[`POST ${origin}/mcp`]: resp(200, { body: "{}" }),
[`POST ${origin}/oauth/register`]: registerRoute,
[`GET ${origin}/oauth/authorize`]: resp(400),
[`POST ${origin}/oauth/token`]: resp(400, {
body: JSON.stringify({ error: "invalid_grant" }),
Comment thread
aterga marked this conversation as resolved.
Resolved
}),
});
try {
const { section } = await checkMcpEndpoints(origin, 2000);
assert.equal(byId(section, "mcp-challenge").status, "fail");
} finally {
restore();
}
});

test("checkMcpEndpoints flags a missing hosted-redirect allow-list", async () => {
const origin = "https://mcp.beta.test";
const restore = stubFetch({
[`GET ${origin}/`]: resp(200, { headers: { "content-type": "text/html" } }),
[`GET ${origin}/version`]: resp(404),
[`GET ${origin}/.well-known/oauth-protected-resource`]: resp(200, {
body: JSON.stringify({ authorization_servers: [origin], resource: `${origin}/mcp` }),
}),
[`GET ${origin}/.well-known/oauth-authorization-server`]: resp(200, {
body: JSON.stringify({
issuer: origin,
authorization_endpoint: `${origin}/oauth/authorize`,
token_endpoint: `${origin}/oauth/token`,
registration_endpoint: `${origin}/oauth/register`,
}),
}),
[`POST ${origin}/mcp`]: resp(401, {
headers: {
"www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
},
body: JSON.stringify({ error: "invalid_token" }),
}),
// Guard MISSING: the server accepts ANY redirect (even a hosted one) with 201.
[`POST ${origin}/oauth/register`]: resp(201, {
body: JSON.stringify({ client_id: "x" }),
body: JSON.stringify({ client_id: "leaked" }),
}),
[`GET ${origin}/oauth/authorize`]: resp(400),
[`POST ${origin}/oauth/token`]: resp(400, {
Expand All @@ -223,7 +289,10 @@ test("checkMcpEndpoints flags a missing OAuth challenge", async () => {
});
try {
const { section } = await checkMcpEndpoints(origin, 2000);
assert.equal(byId(section, "mcp-challenge").status, "fail");
// DCR still mints a client_id for the loopback probe, but the allow-list
// probe (a hosted redirect) wrongly succeeds, so the guard check goes red.
assert.equal(byId(section, "oauth-register").status, "pass");
assert.equal(byId(section, "oauth-register-allowlist").status, "fail");
} finally {
restore();
}
Expand Down