Merge pull request #2326 from modelcontextprotocol/v2/fix/2305-cimd-loopback-http
fix: exempt loopback hosts from the CIMD metadata URL HTTPS requirement
This commit is contained in:
commit
336e045210
4 changed files with 267 additions and 25 deletions
|
|
@ -312,6 +312,60 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells the reader the loopback exemption exists, in both pieces of CIMD copy", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "",
|
||||
}}
|
||||
expandedSections={["cimd"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
/>,
|
||||
);
|
||||
|
||||
// The copy is part of this feature's contract, not decoration: the whole
|
||||
// point of #2305 is that a local fixture URL is enterable, and an
|
||||
// HTTPS-only instruction here puts that back out of reach for a reader
|
||||
// even while the validator stays permissive. Both places that state the
|
||||
// requirement are asserted, since either one alone would still mislead.
|
||||
const hint = screen.getByText(/must be served over HTTPS/);
|
||||
expect(hint).toHaveTextContent("localhost, 127.0.0.1 or [::1]");
|
||||
|
||||
expect(
|
||||
screen.getByText(/HTTPS URL of your OAuth client metadata JSON document/),
|
||||
).toHaveTextContent(
|
||||
"http:// is accepted only on localhost, 127.0.0.1 or [::1]",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no CIMD URL error for a loopback http URL", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "http://localhost:8092/client-metadata.json",
|
||||
}}
|
||||
expandedSections={["cimd"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no CIMD URL error for a valid URL", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
|
|
|
|||
|
|
@ -307,15 +307,16 @@ export function ClientSettingsForm({
|
|||
{settings.cimdEnabled && (
|
||||
<>
|
||||
<HintText>
|
||||
The metadata document must be served over HTTPS and list
|
||||
this redirect URI:{" "}
|
||||
The metadata document must be served over HTTPS — or over
|
||||
plain HTTP from localhost, 127.0.0.1 or [::1], for a local
|
||||
fixture — and list this redirect URI:{" "}
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/oauth/callback`
|
||||
: "http://localhost:6274/oauth/callback"}
|
||||
</HintText>
|
||||
<ClearableTextInput
|
||||
label="Client ID metadata document URL"
|
||||
description="Public HTTPS URL of your OAuth client metadata JSON document."
|
||||
description="HTTPS URL of your OAuth client metadata JSON document (http:// is accepted only on localhost, 127.0.0.1 or [::1])."
|
||||
value={settings.clientMetadataUrl}
|
||||
onChange={(e) =>
|
||||
patch({ clientMetadataUrl: e.currentTarget.value })
|
||||
|
|
|
|||
|
|
@ -206,6 +206,19 @@ describe("client config", () => {
|
|||
).toThrow(/HTTPS/);
|
||||
});
|
||||
|
||||
it("parseClientConfig accepts a loopback http CIMD URL", () => {
|
||||
for (const clientMetadataUrl of [
|
||||
"http://localhost:8090/client-metadata.json",
|
||||
"http://127.0.0.1:8090/client-metadata.json",
|
||||
"http://[::1]:8090/client-metadata.json",
|
||||
]) {
|
||||
const config = parseClientConfig({
|
||||
cimd: { enabled: true, clientMetadataUrl },
|
||||
});
|
||||
expect(config.cimd?.clientMetadataUrl).toBe(clientMetadataUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it("parseClientConfig rejects CIMD URL without path", () => {
|
||||
expect(() =>
|
||||
parseClientConfig({
|
||||
|
|
@ -299,6 +312,70 @@ describe("client config", () => {
|
|||
CIMD_METADATA_URL_PATH_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts plain http on the three exempt loopback literals", () => {
|
||||
expect(
|
||||
getCimdClientMetadataUrlError(
|
||||
"http://localhost:8090/client-metadata.json",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getCimdClientMetadataUrlError(
|
||||
"http://127.0.0.1:8090/client-metadata.json",
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getCimdClientMetadataUrlError("http://[::1]:8090/client-metadata.json"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still requires a path on an exempt loopback host", () => {
|
||||
expect(getCimdClientMetadataUrlError("http://localhost:8090")).toBe(
|
||||
CIMD_METADATA_URL_PATH_ERROR,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts alternate spellings that canonicalize to the same addresses", () => {
|
||||
// The check runs against the WHATWG-canonicalized `URL.hostname`, so the
|
||||
// exemption covers these three *addresses*, not three input strings. Each
|
||||
// of these reaches the loopback interface, which is what the security
|
||||
// argument is about — and the SDK canonicalizes identically, so an alias
|
||||
// accepted here is one it accepts for a token endpoint too.
|
||||
for (const value of [
|
||||
"http://127.1:8090/client-metadata.json",
|
||||
"http://2130706433:8090/client-metadata.json",
|
||||
"http://0x7f.0.0.1:8090/client-metadata.json",
|
||||
"http://[0:0:0:0:0:0:0:1]:8090/client-metadata.json",
|
||||
// WHATWG strips a root-anchored dot from an IP literal but not from a
|
||||
// name, which is why this passes where `localhost.` below does not.
|
||||
"http://127.0.0.1.:8090/client-metadata.json",
|
||||
"http://LOCALHOST:8090/client-metadata.json",
|
||||
]) {
|
||||
expect(getCimdClientMetadataUrlError(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not widen the exemption beyond those three addresses", () => {
|
||||
// `localhost.` and `*.localhost` resolve to loopback on every resolver,
|
||||
// but they are outside the SDK's allow-list — so they are outside ours
|
||||
// too, deliberately, rather than the two disagreeing about one URL.
|
||||
for (const value of [
|
||||
"http://tenant.app.localhost:3300/client-metadata.json",
|
||||
"http://127.0.0.2:8090/client-metadata.json",
|
||||
"http://localhost.example.com/client-metadata.json",
|
||||
]) {
|
||||
expect(getCimdClientMetadataUrlError(value)).toBe(
|
||||
CIMD_METADATA_URL_HTTPS_ERROR,
|
||||
);
|
||||
}
|
||||
// The root-anchored spelling never reaches the protocol check at all:
|
||||
// `isAbsoluteHttpUrl` rejects its trailing empty label first, so it is
|
||||
// flagged as unparseable rather than as non-HTTPS. Pinned so a later
|
||||
// change to either check has to decide about this case on purpose.
|
||||
expect(
|
||||
getCimdClientMetadataUrlError("http://localhost./client-metadata.json"),
|
||||
).toBe(CIMD_METADATA_URL_INVALID_ERROR);
|
||||
});
|
||||
});
|
||||
|
||||
it("formatClientConfigLoadError summarizes Zod validation failures", () => {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ import {
|
|||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import {
|
||||
getCimdClientMetadataUrlError,
|
||||
parseClientConfig,
|
||||
} from "@inspector/core/client/config-parse.js";
|
||||
|
||||
/**
|
||||
* Live coverage of `test-servers/configs/oauth-cimd-http.json` — the fixture
|
||||
|
|
@ -34,11 +38,13 @@ import {
|
|||
* - the document's `client_id` equals the URL it was fetched from, which is
|
||||
* what makes it a legal CIMD client id rather than an arbitrary blob.
|
||||
*
|
||||
* The document is served over plain HTTP here. That is deliberate and is *not*
|
||||
* a usable `clientMetadataUrl` for the Inspector, which requires HTTPS with no
|
||||
* loopback exemption (#2305) — driving the UI needs a self-signed HTTPS
|
||||
* listener, as `docs/test-servers.md` describes. This endpoint exists for the
|
||||
* authorization-server side of the flow and for exactly these assertions.
|
||||
* The document is served over plain HTTP here, on `localhost`. Since #2305 that
|
||||
* *is* a usable `clientMetadataUrl`: the Inspector's HTTPS requirement now
|
||||
* exempts the same three loopback literals the SDK exempts for token endpoints,
|
||||
* so the fixture can be driven from the web client with no self-signed HTTPS
|
||||
* listener. The last case below pins that, because it is the property the
|
||||
* fixture's whole reason for existing rests on — a re-tightened validator would
|
||||
* put the manual repro back out of reach without failing anything else here.
|
||||
*/
|
||||
describe("CIMD showcase fixture (#2242)", () => {
|
||||
let server: TestServerHttp | null = null;
|
||||
|
|
@ -121,6 +127,22 @@ describe("CIMD showcase fixture (#2242)", () => {
|
|||
expect(doc.token_endpoint_auth_method).toBe("none");
|
||||
});
|
||||
|
||||
it("serves the document at a URL the Inspector accepts as a clientMetadataUrl", async () => {
|
||||
const started = await startShowcase();
|
||||
const documentUrl = `${originOf(started)}/client-metadata.json`;
|
||||
|
||||
// The #2305 property: no self-signed HTTPS listener, no
|
||||
// NODE_TLS_REJECT_UNAUTHORIZED=0 — the served URL is legal config as-is,
|
||||
// both inline in the settings form and in `client.json` on disk.
|
||||
expect(new URL(documentUrl).protocol).toBe("http:");
|
||||
expect(getCimdClientMetadataUrlError(documentUrl)).toBeUndefined();
|
||||
expect(
|
||||
parseClientConfig({
|
||||
cimd: { enabled: true, clientMetadataUrl: documentUrl },
|
||||
}).cimd?.clientMetadataUrl,
|
||||
).toBe(documentUrl);
|
||||
});
|
||||
|
||||
it("preserves a query-bearing document URL in the client_id it publishes", async () => {
|
||||
const started = await startShowcase();
|
||||
const documentUrl = `${originOf(started)}/client-metadata.json?profile=a`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue