Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/__tests__/inspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 35 additions & 1 deletion src/cli/commands/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/probe-endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down