From 7dedc49211506317b13a70d7916503e5c700e339 Mon Sep 17 00:00:00 2001 From: Nexory Date: Thu, 11 Jun 2026 00:42:53 +0200 Subject: [PATCH] fix(inspect): validate metadata URI before fetching (SSRF) `inspect` reads `metadataURI` from the on-chain registry, which any registrant can write, and fetched it with no checks. A hostile entry could point the fetch at an internal address (e.g. cloud metadata at 169.254.169.254) from the machine or CI runner that runs `inspect`. - Reject a metadata URI whose host is a private/internal address before fetching, reusing `isPrivateHostname`. - Reject non-http(s) schemes. - Add the link-local range (169.254.0.0/16, which covers the cloud metadata endpoint) to `PRIVATE_HOSTNAME_RE`; it was previously absent, so the endpoint probe did not flag it either. Adds a regression test that a link-local metadata URI is never fetched. --- src/__tests__/inspect.test.ts | 31 +++++++++++++++++++++++++ src/cli/commands/inspect.ts | 36 +++++++++++++++++++++++++++++- src/cli/commands/probe-endpoint.ts | 2 +- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/__tests__/inspect.test.ts b/src/__tests__/inspect.test.ts index 6c6968b..77b88d7 100644 --- a/src/__tests__/inspect.test.ts +++ b/src/__tests__/inspect.test.ts @@ -109,6 +109,37 @@ describe("inspect command", () => { logSpy.mockRestore() }) + it("refuses to fetch a metadata URI that points to a private address", async () => { + mockGetToolConfig.mockResolvedValueOnce({ + creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + metadataURI: + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + manifestHash: MANIFEST_HASH, + accessPredicate: "0x0000000000000000000000000000000000000000", + }) + const fetchMock = vi.fn(async () => new Response("{}", { status: 200 })) + vi.stubGlobal("fetch", fetchMock) + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit") + }) as never) + + const { inspectCommand } = await import("../cli/commands/inspect.js") + + await expect( + inspectCommand.parseAsync(["node", "inspect", "--tool-id", "1"]), + ).rejects.toThrow() + + // The link-local metadata address must never be fetched. + expect(fetchMock).not.toHaveBeenCalled() + expect(exitSpy).toHaveBeenCalledWith(1) + + logSpy.mockRestore() + errSpy.mockRestore() + exitSpy.mockRestore() + }) + it("reports MISMATCH when computed hash differs from onchain hash", async () => { mockGetToolConfig.mockResolvedValueOnce({ creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index 5c32e3c..db60dcc 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -19,7 +19,11 @@ import { decodeRequirement } from "../../lib/onchain/access.js" import { computeManifestHash } from "../../lib/onchain/hash.js" import { ToolRegistryClient } from "../../lib/onchain/registry.js" import { getChain } from "./get-chain.js" -import { printProbeResult, probeEndpoint } from "./probe-endpoint.js" +import { + isPrivateHostname, + printProbeResult, + probeEndpoint, +} from "./probe-endpoint.js" interface InspectOptions { toolId: string @@ -254,6 +258,36 @@ export const inspectCommand = new Command("inspect") console.log(pc.cyan("\nFetching manifest from metadata URI...")) + // metadataURI comes from the (permissionlessly writable) on-chain registry. + // Validate it before fetching so a hostile entry cannot point this fetch at + // an internal address (e.g. cloud metadata at 169.254.169.254) from the + // machine or CI runner that runs `inspect`. + let metadataUrl: URL + try { + metadataUrl = new URL(config.metadataURI) + } catch { + console.error( + pc.red(`Error: invalid metadata URI: ${config.metadataURI}`), + ) + process.exit(1) + } + if (metadataUrl.protocol !== "http:" && metadataUrl.protocol !== "https:") { + console.error( + pc.red( + `Error: metadata URI must use http(s), got "${metadataUrl.protocol}"`, + ), + ) + process.exit(1) + } + if (isPrivateHostname(metadataUrl.hostname)) { + console.error( + pc.red( + `Error: metadata URI host "${metadataUrl.hostname}" is a private/internal address; refusing to fetch`, + ), + ) + process.exit(1) + } + let response: globalThis.Response try { response = await fetch(config.metadataURI, { diff --git a/src/cli/commands/probe-endpoint.ts b/src/cli/commands/probe-endpoint.ts index d5bfc66..287d2dc 100644 --- a/src/cli/commands/probe-endpoint.ts +++ b/src/cli/commands/probe-endpoint.ts @@ -7,7 +7,7 @@ export interface ProbeResult { } const PRIVATE_HOSTNAME_RE = - /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|\[?::1\]?|\[?fe80:)/i + /^(localhost|127\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+|\[?::1\]?|\[?fe80:)/i export function isPrivateHostname(hostname: string): boolean { return PRIVATE_HOSTNAME_RE.test(hostname)