diff --git a/nemoclaw/src/blueprint/runner.test.ts b/nemoclaw/src/blueprint/runner.test.ts index 40e433cb304..4dcbf3ee574 100644 --- a/nemoclaw/src/blueprint/runner.test.ts +++ b/nemoclaw/src/blueprint/runner.test.ts @@ -71,9 +71,19 @@ vi.mock("execa", () => ({ execa: (...args: unknown[]) => mockExeca(...args), })); -vi.mock("./ssrf.js", () => ({ - validateEndpointUrl: vi.fn(async (url: string) => ({ url, pinnedUrl: url })), -})); +vi.mock("./ssrf.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + validateEndpointUrl: vi.fn(async (url: string) => ({ + url, + pinnedUrl: url, + protocol: url.startsWith("http:") ? "http:" : "https:", + hostname: new URL(url).hostname, + dnsResolved: false, + })), + }; +}); const { validateEndpointUrl } = await import("./ssrf.js"); const mockedValidateEndpoint = vi.mocked(validateEndpointUrl); @@ -553,17 +563,6 @@ describe("runner", () => { expect(plan.dry_run).toBe(true); }); - it("validates and applies endpoint URL override", async () => { - captureStdout(); - mockExeca.mockResolvedValue({ exitCode: 0 }); - - const plan = await actionPlan("default", minimalBlueprint(), { - endpointUrl: "https://override.example.com/v1", - }); - expect(plan.inference.endpoint).toBe("https://override.example.com/v1"); - expect(mockedValidateEndpoint).toHaveBeenCalledWith("https://override.example.com/v1"); - }); - it("SSRF-validates the blueprint-defined endpoint even without --endpoint-url override", async () => { captureStdout(); mockExeca.mockResolvedValue({ exitCode: 0 }); @@ -1081,9 +1080,28 @@ describe("runner", () => { it("validates and applies endpoint URL override", async () => { await actionApply("default", minimalBlueprint(), { - endpointUrl: "https://override.example.com/v1", + endpointUrl: "https://93.184.216.34/v1", + }); + expect(mockedValidateEndpoint).toHaveBeenCalledWith("https://93.184.216.34/v1"); + }); + + it("fails closed before provider creation for DNS-backed HTTPS endpoint overrides", async () => { + mockedValidateEndpoint.mockResolvedValueOnce({ + url: "https://override.example.com/v1", + pinnedUrl: "https://93.184.216.34/v1", + protocol: "https:", + hostname: "override.example.com", + dnsResolved: true, }); - expect(mockedValidateEndpoint).toHaveBeenCalledWith("https://override.example.com/v1"); + + await expect( + actionApply("default", minimalBlueprint(), { + endpointUrl: "https://override.example.com/v1", + }), + ).rejects.toThrow(/DNS-backed HTTPS endpoint/); + expect( + mockExeca.mock.calls.some((c) => Array.isArray(c[1]) && c[1].includes("provider")), + ).toBe(false); }); it("passes --timeout when timeout_secs is set in profile", async () => { diff --git a/nemoclaw/src/blueprint/runner.ts b/nemoclaw/src/blueprint/runner.ts index 43555203f82..8f64b113f15 100644 --- a/nemoclaw/src/blueprint/runner.ts +++ b/nemoclaw/src/blueprint/runner.ts @@ -20,7 +20,7 @@ import { join, sep } from "node:path"; import { execa } from "execa"; import YAML from "yaml"; -import { validateEndpointUrl } from "./ssrf.js"; +import { safeEndpointUrlForDownstream, validateEndpointUrl } from "./ssrf.js"; import { buildSubprocessEnv } from "../lib/subprocess-env.js"; import { DASHBOARD_PORT } from "../lib/ports.js"; @@ -432,18 +432,13 @@ async function resolveRunConfig( let inferenceCfg = { ...inferenceProfiles[profile] }; if (endpointUrl) { const validated = await validateEndpointUrl(endpointUrl); - // Use DNS-pinned URL for HTTP (full SSRF/rebinding protection). For HTTPS, - // keep the original hostname — TLS certificate validation prevents rebinding - // since the attacker cannot present a valid cert for the target. - const safe = endpointUrl.startsWith("https:") ? validated.url : validated.pinnedUrl; - inferenceCfg = { ...inferenceCfg, endpoint: safe }; + inferenceCfg = { ...inferenceCfg, endpoint: safeEndpointUrlForDownstream(validated) }; } // Validate the final endpoint (whether from CLI override or blueprint profile) if (inferenceCfg.endpoint) { const validated = await validateEndpointUrl(inferenceCfg.endpoint); - const safe = inferenceCfg.endpoint.startsWith("https:") ? validated.url : validated.pinnedUrl; - inferenceCfg = { ...inferenceCfg, endpoint: safe }; + inferenceCfg = { ...inferenceCfg, endpoint: safeEndpointUrlForDownstream(validated) }; } const sandboxCfg = blueprint.components?.sandbox ?? {}; diff --git a/nemoclaw/src/blueprint/ssrf.test.ts b/nemoclaw/src/blueprint/ssrf.test.ts index b1db007870e..17e851b7c16 100644 --- a/nemoclaw/src/blueprint/ssrf.test.ts +++ b/nemoclaw/src/blueprint/ssrf.test.ts @@ -12,7 +12,9 @@ vi.mock("node:dns", () => ({ promises: { lookup: (...args: unknown[]) => mockLookup(...(args as [string, { all: true }])) }, })); -const { isPrivateIp, validateEndpointUrl } = await import("./ssrf.js"); +const { isPrivateIp, safeEndpointUrlForDownstream, validateEndpointUrl } = await import( + "./ssrf.js" +); // ── isPrivateIp ───────────────────────────────────────────────── @@ -340,10 +342,29 @@ describe("validateEndpointUrl – DNS pinning", () => { expect(result.url).toBe("http://attacker.com:8080/v1"); }); - it("pins HTTPS URL to resolved IP", async () => { + it("pins HTTPS URL to resolved IP metadata without marking it downstream-safe", async () => { mockPublicDns(); const result = await validateEndpointUrl("https://api.example.com/v1"); expect(result.pinnedUrl).toBe("https://93.184.216.34/v1"); + expect(result).toMatchObject({ + protocol: "https:", + hostname: "api.example.com", + resolvedAddress: "93.184.216.34", + resolvedFamily: 4, + dnsResolved: true, + }); + expect(() => safeEndpointUrlForDownstream(result)).toThrow(/DNS-backed HTTPS endpoint/); + }); + + it("keeps HTTPS IP-literal endpoints downstream-safe", async () => { + const result = await validateEndpointUrl("https://93.184.216.34/v1"); + expect(safeEndpointUrlForDownstream(result)).toBe("https://93.184.216.34/v1"); + }); + + it("returns pinned HTTP endpoints for downstream use", async () => { + mockPublicDns(); + const result = await validateEndpointUrl("http://api.example.com/v1"); + expect(safeEndpointUrlForDownstream(result)).toBe("http://93.184.216.34/v1"); }); it("pins IPv6 address with brackets", async () => { @@ -369,25 +390,28 @@ describe("validateEndpointUrl – URL parsing edge cases", () => { ); }); - it("allows URL with query parameters", async () => { + it("parses URL with query parameters but does not mark DNS-backed HTTPS downstream-safe", async () => { mockPublicDns(); const url = "https://api.example.com/v1?key=abc&model=gpt"; const result = await validateEndpointUrl(url); expect(result.url).toBe(url); + expect(() => safeEndpointUrlForDownstream(result)).toThrow(/DNS-backed HTTPS endpoint/); }); - it("allows URL with fragment", async () => { + it("parses URL with fragment but does not mark DNS-backed HTTPS downstream-safe", async () => { mockPublicDns(); const url = "https://api.example.com/v1#section"; const result = await validateEndpointUrl(url); expect(result.url).toBe(url); + expect(() => safeEndpointUrlForDownstream(result)).toThrow(/DNS-backed HTTPS endpoint/); }); - it("allows URL with userinfo/basic auth", async () => { + it("parses URL with userinfo/basic auth but does not mark DNS-backed HTTPS downstream-safe", async () => { mockPublicDns(); - // URL parser extracts hostname correctly even with userinfo + // URL parser extracts hostname correctly even with userinfo. const url = "https://user:pass@api.example.com/v1"; const result = await validateEndpointUrl(url); expect(result.url).toBe(url); + expect(() => safeEndpointUrlForDownstream(result)).toThrow(/DNS-backed HTTPS endpoint/); }); }); diff --git a/nemoclaw/src/blueprint/ssrf.ts b/nemoclaw/src/blueprint/ssrf.ts index fccf840b6e9..4e86936d65b 100644 --- a/nemoclaw/src/blueprint/ssrf.ts +++ b/nemoclaw/src/blueprint/ssrf.ts @@ -24,13 +24,21 @@ function hostnameForDnsLookup(hostname: string): string { * DNS rebinding TOCTOU attacks where an attacker returns a public IP at * validation time and a private IP at connection time. * - * Callers should use `pinnedUrl` for HTTP endpoints (full protection) and `url` - * for HTTPS endpoints (TLS certificate validation prevents rebinding since the - * attacker cannot present a valid cert for the rebinding target). + * Callers should use `safeEndpointUrlForDownstream` before passing the endpoint + * to a downstream provider. DNS-backed HTTP endpoints are rewritten to + * `pinnedUrl`; DNS-backed HTTPS endpoints currently fail closed because the + * downstream provider would otherwise perform a second DNS lookup while NemoClaw + * cannot pin the TCP peer and preserve TLS SNI/Host across the OpenShell runtime + * boundary. */ export interface ValidatedEndpoint { url: string; pinnedUrl: string; + protocol: "http:" | "https:"; + hostname: string; + resolvedAddress?: string; + resolvedFamily?: number; + dnsResolved: boolean; } export async function validateEndpointUrl(url: string): Promise { @@ -60,8 +68,9 @@ export async function validateEndpointUrl(url: string): Promise; @@ -89,5 +98,27 @@ export async function validateEndpointUrl(url: string): Promise=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@smithy/core": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", @@ -3466,6 +3485,20 @@ "typescript": ">=5" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3837,6 +3870,60 @@ "node": ">=0.10.0" } }, + "node_modules/execa": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3987,6 +4074,21 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-type": { "version": "21.3.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", @@ -4507,6 +4609,15 @@ "node": ">= 14" } }, + "node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -4640,7 +4751,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4649,6 +4759,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -4661,6 +4795,12 @@ "node": ">=8" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -5388,6 +5528,34 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5528,6 +5696,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -5574,6 +5754,15 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -5652,6 +5841,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -5882,6 +6086,27 @@ "node": ">=10" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5991,6 +6216,18 @@ "node": ">=8" } }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -6224,6 +6461,18 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", @@ -6434,6 +6683,21 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -6595,6 +6859,18 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 99258a9a9fe..0a0d773d9dd 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1046.0", "@oclif/core": "^4.10.5", + "execa": "^9.6.1", "js-yaml": "^4.1.1", "p-retry": "^4.6.2", "qrcode-terminal": "^0.12.0", diff --git a/src/lib/actions/inference-set-endpoint-security.test.ts b/src/lib/actions/inference-set-endpoint-security.test.ts index 5efa78f6739..586481cae2a 100644 --- a/src/lib/actions/inference-set-endpoint-security.test.ts +++ b/src/lib/actions/inference-set-endpoint-security.test.ts @@ -48,13 +48,13 @@ describe("custom inference endpoint DNS pinning", () => { expect(lookup).toHaveBeenCalledWith("public-endpoint.example", { all: true }); }); - it("preserves a validated HTTPS hostname for certificate verification", async () => { + it("fails closed for DNS-backed HTTPS endpoints until runtime-aware pinning exists", async () => { const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); await expect( normalizeCustomEndpointUrl("https://public-endpoint.example/v1/", (value) => rewriteConfigUrlsWithDnsPinning(value, lookup), ), - ).resolves.toBe("https://public-endpoint.example/v1"); + ).rejects.toThrow(/DNS-backed HTTPS URLs are not supported/); }); }); diff --git a/src/lib/onboard/inference-providers/hermes.test.ts b/src/lib/onboard/inference-providers/hermes.test.ts index 186e99191ac..df0f7c28ee0 100644 --- a/src/lib/onboard/inference-providers/hermes.test.ts +++ b/src/lib/onboard/inference-providers/hermes.test.ts @@ -210,22 +210,30 @@ describe("setupHermesProviderInference SSRF guard (#6072)", () => { expect(deps.runOpenshell).not.toHaveBeenCalled(); }); - it("accepts a public HTTPS endpoint (#6072)", async () => { + it("rejects a DNS-backed public HTTPS endpoint until runtime-aware pinning exists (#4684)", async () => { const deps = makeDeps({ lookup: publicLookup() }); await expect( setupHermesProviderInference(makeArgs("https://integrate.api.nvidia.com/v1"), deps as never), + ).rejects.toThrow(/DNS-backed HTTPS URLs are not supported/); + expect(deps.runOpenshell).not.toHaveBeenCalled(); + }); + + it("accepts a public HTTPS IP-literal endpoint (#6072)", async () => { + const deps = makeDeps(); + await expect( + setupHermesProviderInference(makeArgs("https://8.8.8.8/v1"), deps as never), ).resolves.toEqual({ ok: true }); expect(deps.runOpenshell).toHaveBeenCalled(); }); - it("accepts a public hostname that resolves to a public IP (#6073)", async () => { + it("rejects a public HTTPS hostname that resolves to a public IP until runtime-aware pinning exists (#4684)", async () => { const deps = makeDeps({ lookup: vi.fn(async () => [{ address: "8.8.8.8", family: 4 }]), }); await expect( setupHermesProviderInference(makeArgs("https://api.public.example.test/v1"), deps as never), - ).resolves.toEqual({ ok: true }); - expect(deps.runOpenshell).toHaveBeenCalled(); + ).rejects.toThrow(/DNS-backed HTTPS URLs are not supported/); + expect(deps.runOpenshell).not.toHaveBeenCalled(); }); it("rejects a public hostname that resolves to a private IP (DNS rebinding) (#6073)", async () => { diff --git a/src/lib/onboard/inference-providers/hermes.ts b/src/lib/onboard/inference-providers/hermes.ts index 36e75dee0ca..8a71e33db53 100644 --- a/src/lib/onboard/inference-providers/hermes.ts +++ b/src/lib/onboard/inference-providers/hermes.ts @@ -52,9 +52,10 @@ export async function setupHermesProviderInference( ); } // DNS-resolving + pinning validation closes the DNS-rebinding gap a - // string-only hostname check leaves open. For http this returns the - // pinned-IP URL; for https it returns the original hostname (preserving TLS - // SNI/cert validation). + // string-only hostname check leaves open. For HTTP this returns the + // pinned-IP URL. DNS-backed HTTPS fails closed until NemoClaw has a + // runtime-aware transport that can preserve TLS SNI/Host while pinning the + // resolved peer IP across the downstream OpenShell boundary. try { const validated = await rewriteConfigUrlsWithDnsPinning(endpointUrl, deps.lookup); resolvedEndpointUrl = typeof validated === "string" ? validated : endpointUrl; diff --git a/src/lib/sandbox/config.ts b/src/lib/sandbox/config.ts index 5a0beb21fa2..f36ad55140f 100644 --- a/src/lib/sandbox/config.ts +++ b/src/lib/sandbox/config.ts @@ -760,9 +760,18 @@ async function rewriteConfigUrlsWithDnsPinning( const validated = await validateUrlValueWithDnsResult(trimmed, lookup); if (!validated) return value; // HTTP has no TLS hostname binding, so persist the DNS-pinned URL to avoid - // a config-time/public → runtime/private DNS-rebinding window. For HTTPS, - // preserve the original hostname so normal certificate validation still - // protects the connection. + // a config-time/public → runtime/private DNS-rebinding window. DNS-backed + // HTTPS endpoints fail closed for generic persisted config because the + // downstream consumer would otherwise perform a second DNS lookup while + // NemoClaw cannot pin the peer IP and preserve TLS SNI/Host across the + // OpenShell runtime boundary. + if (validated.protocol === "https:" && validated.pinnedUrl !== validated.originalUrl) { + throw new Error( + "DNS-backed HTTPS URLs are not supported for persisted sandbox config yet. " + + "Use an HTTPS IP-literal endpoint, an HTTP endpoint that can be DNS-pinned, " + + "or wait for the runtime-aware HTTPS pinning transport.", + ); + } return validated.protocol === "http:" ? validated.pinnedUrl : validated.originalUrl; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/test/config-set.test.ts b/test/config-set.test.ts index 042e4f0a13f..f4bf928b8e5 100644 --- a/test/config-set.test.ts +++ b/test/config-set.test.ts @@ -513,11 +513,20 @@ describe("config set helpers", () => { ); }); - it("preserves HTTPS hostnames after DNS validation", async () => { + it("fails closed for DNS-backed HTTPS hostname URLs", async () => { const lookup = async () => [{ address: "93.184.216.34", family: 4 }]; - await expect(rewriteConfigUrlsWithDnsPinning("https://example.com/v1", lookup)).resolves.toBe( - "https://example.com/v1", - ); + await expect( + rewriteConfigUrlsWithDnsPinning("https://example.com/v1", lookup), + ).rejects.toThrow(/DNS-backed HTTPS URLs are not supported/); + }); + + it("preserves HTTPS IP-literal URLs without DNS lookup", async () => { + const lookup = async () => { + throw new Error("lookup should not run for IP literals"); + }; + await expect( + rewriteConfigUrlsWithDnsPinning("https://93.184.216.34/v1", lookup), + ).resolves.toBe("https://93.184.216.34/v1"); }); it("recursively rewrites nested HTTP URLs and leaves non-URLs unchanged", async () => { @@ -526,7 +535,6 @@ describe("config set helpers", () => { rewriteConfigUrlsWithDnsPinning( { primary: "http://api.example.com/v1", - secure: "https://secure.example.com/v1", label: "production", fallbacks: ["http://backup.example.com/v2"], }, @@ -534,7 +542,6 @@ describe("config set helpers", () => { ), ).resolves.toEqual({ primary: "http://93.184.216.34/v1", - secure: "https://secure.example.com/v1", label: "production", fallbacks: ["http://93.184.216.34/v2"], }); diff --git a/test/e2e/live/inference-routing.test.ts b/test/e2e/live/inference-routing.test.ts index d5ce377584e..a68589dea58 100644 --- a/test/e2e/live/inference-routing.test.ts +++ b/test/e2e/live/inference-routing.test.ts @@ -112,6 +112,21 @@ function clearOnboardState(): void { fs.rmSync(ONBOARD_SESSION_FILE, { force: true }); } +function writeFakeOpenShellForBlueprintFailClosed(binDir: string): string { + const commandLogPath = path.join(binDir, "openshell-commands.jsonl"); + const scriptPath = path.join(binDir, "openshell"); + fs.writeFileSync( + scriptPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(${JSON.stringify(commandLogPath)}, JSON.stringify({ args: process.argv.slice(2) }) + "\\n"); +process.exit(0); +`, + { mode: 0o755 }, + ); + return commandLogPath; +} + function redactedCommand(command: readonly string[], values: readonly string[]): string[] { return command.map((part) => redactString(part, values)); } @@ -624,6 +639,95 @@ liveTest( }, ); +liveTest( + "TC-INF-10 DNS-backed HTTPS blueprint endpoint fails closed before OpenShell runtime handoff", + { timeout: 5 * 60_000 }, + async ({ artifacts, cleanup }) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-https-dns-fail-closed-")); + const workdir = path.join(root, "blueprint"); + const fakeBinDir = path.join(root, "bin"); + const home = path.join(root, "home"); + fs.mkdirSync(workdir, { recursive: true }); + fs.mkdirSync(fakeBinDir, { recursive: true }); + fs.mkdirSync(home, { recursive: true }); + cleanup.add(`remove HTTPS DNS fail-closed temp root ${root}`, () => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const commandLogPath = writeFakeOpenShellForBlueprintFailClosed(fakeBinDir); + fs.writeFileSync( + path.join(workdir, "blueprint.yaml"), + [ + 'version: "1.0"', + "components:", + " sandbox:", + " image: openclaw", + " name: e2e-https-dns-fail-closed", + " inference:", + " profiles:", + " default:", + " provider_type: openai", + " provider_name: default", + " endpoint: https://rebinding.example.test/v1", + " model: e2e-model", + " credential_env: E2E_API_KEY", + "", + ].join("\n"), + ); + await artifacts.writeJson("target.json", { + id: "https-dns-backed-endpoint-fail-closed", + runner: "vitest", + issue: 4684, + contract: [ + "DNS-backed HTTPS endpoint validation fails closed before handing config to OpenShell", + "OpenShell sandbox/provider commands are not invoked for unsupported DNS-backed HTTPS endpoints", + "The real runtime namespace is not given a host-loopback pin proxy URL as a partial fix", + ], + }); + + const runnerScript = ` +import dns from "node:dns"; +const originalLookup = dns.promises.lookup; +dns.promises.lookup = ((hostname, options) => hostname === "rebinding.example.test" + ? Promise.resolve([{ address: "93.184.216.34", family: 4 }]) + : originalLookup.call(dns.promises, hostname, options)); +const { main } = await import(${JSON.stringify(path.join(REPO_ROOT, "nemoclaw/src/blueprint/runner.ts"))}); +await main(["apply"]); +`; + + const result = await runRawCommand( + process.execPath, + [ + path.join(REPO_ROOT, "node_modules/tsx/dist/cli.mjs"), + "--input-type=module", + "--eval", + runnerScript, + ], + { + artifactName: "tc-inf-10-blueprint-https-dns-fail-closed", + artifacts, + cwd: workdir, + env: { + HOME: home, + PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}`, + E2E_API_KEY: "e2e-fake-key", + }, + redactionValues: ["e2e-fake-key"], + timeoutMs: 60_000, + }, + ); + const raw = resultText(result); + const openshellLog = fs.existsSync(commandLogPath) + ? fs.readFileSync(commandLogPath, "utf8") + : ""; + await artifacts.writeText("tc-inf-10-openshell-commands.jsonl", openshellLog); + + expectOnboardFailure(result, "TC-INF-10 DNS-backed HTTPS fail-closed blueprint apply"); + expect(raw).toMatch(/DNS-backed HTTPS endpoint/); + expect(openshellLog).toBe(""); + }, +); + liveTest( "TC-INF-05 real NVIDIA key is isolated from sandbox env, process list, and filesystem", { timeout: 15 * 60_000 }, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 23abb7f4644..31fb9da1a06 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -592,7 +592,7 @@ startGateway(null).catch(() => {}); agent: "hermes", provider: "hermes-provider", model: "moonshotai/kimi-k2.6", - endpointUrl: "https://inference-api.nousresearch.com/v1", + endpointUrl: "https://8.8.8.8/v1", credentialEnv: "NOUS_API_KEY", hermesAuthMethod: "oauth", hermesToolGateways: ["nous-web"], @@ -828,7 +828,7 @@ process.env.NEMOCLAW_NON_INTERACTIVE = "1"; const { setupInference } = require(${onboardPath}); (async () => { - await setupInference("test-box", "moonshotai/kimi-k2.6", "hermes-provider", "https://inference-api.nousresearch.com/v1", "OPENAI_API_KEY", "oauth"); + await setupInference("test-box", "moonshotai/kimi-k2.6", "hermes-provider", "https://8.8.8.8/v1", "OPENAI_API_KEY", "oauth"); console.log(JSON.stringify(commands)); })().catch((error) => { console.error(error); @@ -1119,7 +1119,7 @@ onboardSession.saveSession( sandboxName: null, provider: "hermes-provider", model: "moonshotai/kimi-k2.6", - endpointUrl: "https://inference-api.nousresearch.com/v1", + endpointUrl: "https://8.8.8.8/v1", credentialEnv: "NOUS_API_KEY", hermesAuthMethod: "api_key", hermesToolGateways: [], @@ -1273,7 +1273,7 @@ const { setupInference } = require(${onboardPath}); "test-box", "moonshotai/kimi-k2.6", "hermes-provider", - "https://inference-api.nousresearch.com/v1", + "https://8.8.8.8/v1", "NOUS_API_KEY", "api_key", );