diff --git a/ci/source-architecture-budget.json b/ci/source-architecture-budget.json index 88b08d04a1..75b6fd5a81 100644 --- a/ci/source-architecture-budget.json +++ b/ci/source-architecture-budget.json @@ -17,7 +17,7 @@ "src/lib/core/json-types.ts": 36, "src/lib/core/ports.ts": 89, "src/lib/core/shell-quote.ts": 28, - "src/lib/core/url-utils.ts": 29, + "src/lib/core/url-utils.ts": 30, "src/lib/core/wait.ts": 36, "src/lib/credentials/store.ts": 46, "src/lib/inference/config.ts": 30, diff --git a/docs/inference/custom-endpoint-security.mdx b/docs/inference/custom-endpoint-security.mdx index 05faadb1e6..380bba99da 100644 --- a/docs/inference/custom-endpoint-security.mdx +++ b/docs/inference/custom-endpoint-security.mdx @@ -36,6 +36,14 @@ Custom endpoint onboarding rejects endpoint URLs that contain userinfo, query, o NemoClaw does not forward those components to the endpoint. Configure the provider credential separately instead of putting it in the endpoint URL. +Custom endpoint onboarding also rejects an endpoint URL that contains control characters, percent-encoded control characters, spaces within the URL, shell metacharacters, or other characters outside the URL-safe ASCII set. +The URL-safe ASCII set is ASCII letters, digits, and the characters `_ . / : = , @ % + - [ ] ~`. +NemoClaw trims ASCII spaces at the start and end of the URL before it applies these checks. +It also rejects an input that is not an absolute HTTP or HTTPS URL. +This rejection happens before any network request, provider registration, registry write, or sandbox and image mutation, so a rejected input changes no NemoClaw state. +The `inference set` command applies the same rejection classes to `--endpoint-url` before DNS resolution. +Sandbox rebuild applies the same rejection classes to recorded custom endpoint metadata and treats a violating value as unknown. + Managed provider defaults that do not provide an explicit custom endpoint through these paths are unaffected. Custom endpoint onboarding has one narrower operator-controlled exception for corporate inference gateways. diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index 2725c915ea..8e7d3b64f0 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -4737,7 +4737,7 @@ Set them before running `$$nemoclaw onboard`. | `NEMOCLAW_LLAMACPP_RECIPE` | repository-owned managed-inference recipe ID | Selects the exact managed llama.cpp recipe when `NEMOCLAW_PROVIDER=install-llama-cpp`, including a compatible lower-priority profile. When unset, NemoClaw selects the unique highest-priority compatible automatic profile. An unknown recipe, an ambiguous selection, or a stale or incompatible readiness report fails before image, model, or runtime effects. | | `NEMOCLAW_MODEL` | model ID | Selects an explicit model for a non-interactive onboarding run. NemoClaw preserves it across a detected provider switch, even when it matches the recorded provider's default. When this variable is unset during such a switch, NemoClaw ignores the `NEMOCLAW_PROVIDER_MODEL` compatibility fallback and uses normal provider model selection. | | `NEMOCLAW_TOOL_DISCLOSURE` | `progressive` or `direct` | Selects progressive tool discovery or the prior direct-exposure behavior. Defaults to `progressive`; `--tool-disclosure` takes precedence when both are set. | -| `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. Onboarding rejects a URL that contains userinfo, query, or fragment components. | +| `NEMOCLAW_ENDPOINT_URL` | URL | Custom endpoint URL. Used together with `NEMOCLAW_PROVIDER=custom` for OpenAI-compatible endpoints or `NEMOCLAW_PROVIDER=anthropicCompatible` for Anthropic-compatible endpoints. Onboarding rejects a URL that contains userinfo, query, or fragment components. It also rejects a URL that contains control characters, percent-encoded control characters, spaces within the URL, shell metacharacters, or other characters outside the URL-safe ASCII set, and a value that is not an absolute HTTP or HTTPS URL. NemoClaw trims ASCII spaces at the start and end of the URL before validation. | | `NEMOCLAW_COMPATIBLE_AUTH_MODE` | `none` or unset | Explicitly selects no authentication for an HTTP OpenAI-compatible endpoint using `localhost`, `127.0.0.1`, or `[::1]` and port `8000`, `11434`, or `11435` during non-interactive onboarding. | | `NEMOCLAW_TRUSTED_PRIVATE_HOSTS` | comma-separated exact hostnames or IP literals | Allows operator-owned RFC1918, CGNAT, or IPv6 unique local destinations through supported inference, managed MCP, and custom-policy registration paths. Link-local metadata and other reserved ranges remain blocked; DNS resolution and exact address pinning remain active; wildcards are not supported. | | `NEMOCLAW_TRUSTED_PRIVATE_INFERENCE_HOSTS` | comma-separated exact hostnames or IP literals | Inference-only compatibility alias. Inference onboarding combines entries from this variable and `NEMOCLAW_TRUSTED_PRIVATE_HOSTS`. | diff --git a/src/lib/actions/inference-set-endpoint-security.test.ts b/src/lib/actions/inference-set-endpoint-security.test.ts index f325e9c119..a078b56883 100644 --- a/src/lib/actions/inference-set-endpoint-security.test.ts +++ b/src/lib/actions/inference-set-endpoint-security.test.ts @@ -38,6 +38,44 @@ describe("custom inference endpoint DNS pinning", () => { ).rejects.toThrow(/endpoint-url is not allowed:.*private\/internal address/i); }); + it.each([ + [ + "shell metacharacters", + "http://public.example/v1$(id)", + /endpoint-url must contain only URL-safe ASCII characters\./, + ], + [ + "percent-encoded control characters", + "http://public.example/v1%0ainjected", + /endpoint-url must not contain percent-encoded control characters\./, + ], + [ + "a leading tab", + "\thttp://public.example/v1", + /endpoint-url must not contain control characters\./, + ], + [ + "a trailing newline", + "http://public.example/v1\n", + /endpoint-url must not contain control characters\./, + ], + [ + "a leading no-break space", + "\u00a0http://public.example/v1", + /endpoint-url must contain only URL-safe ASCII characters\./, + ], + ] as const)( + "rejects an endpoint URL with %s before DNS validation or any mutation (#9301)", + async (_label, endpointUrl, message) => { + const rewriteUrl = vi.fn(async () => { + throw new Error("unsafe endpoint unexpectedly reached DNS validation"); + }); + + await expect(normalizeCustomEndpointUrl(endpointUrl, rewriteUrl)).rejects.toThrow(message); + expect(rewriteUrl).not.toHaveBeenCalled(); + }, + ); + it("pins validated public HTTP endpoints before they become durable metadata", async () => { const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]); diff --git a/src/lib/actions/inference-set-route-containment.ts b/src/lib/actions/inference-set-route-containment.ts index fb6d250ad7..08c3bf7001 100644 --- a/src/lib/actions/inference-set-route-containment.ts +++ b/src/lib/actions/inference-set-route-containment.ts @@ -11,6 +11,7 @@ import { type HttpsPinCredentialProviderType, isHttpsPinRuntimeEligible, } from "../inference/https-pin-runtime"; +import { unsafeEndpointUrlViolation } from "../core/url-utils"; import { resolveSandboxGatewayName } from "../onboard/gateway-binding"; import { isAllowedOpenShellSandboxBridgeUrl } from "../private-networks"; import { ConfigUrlValidationError } from "../sandbox/config"; @@ -133,17 +134,29 @@ function normalizeEndpointUrlShape(value: string): { url: URL; normalized: strin } function normalizeCustomEndpointUrlWithoutDns(value: string | null | undefined): string { - const raw = typeof value === "string" ? value.trim() : ""; + const input = typeof value === "string" ? value : ""; + const raw = input.trim(); if (!raw) throw new InferenceSetError("endpoint-url is required for custom-compatible metadata.", 2); + let normalized: string; try { - return normalizeEndpointUrlShape(raw).normalized; + normalized = normalizeEndpointUrlShape(raw).normalized; } catch { throw new InferenceSetError( "endpoint-url must be a valid http(s) URL without userinfo, query, or fragment components.", 2, ); } + // #9301: reject control characters, percent-encoded control characters, + // spaces, and shell metacharacters before any provider, registry, or + // sandbox mutation, matching onboarding intake. The shape check above owns + // the userinfo, query, fragment, scheme, and parse classes and their + // established message. + const violation = unsafeEndpointUrlViolation(input); + if (violation) { + throw new InferenceSetError(`endpoint-url ${violation.reason}`, 2); + } + return normalized; } export async function normalizeCustomEndpointUrl( diff --git a/src/lib/actions/sandbox/rebuild-resume-config.test.ts b/src/lib/actions/sandbox/rebuild-resume-config.test.ts index fa507d9a3b..2bbdeb2530 100644 --- a/src/lib/actions/sandbox/rebuild-resume-config.test.ts +++ b/src/lib/actions/sandbox/rebuild-resume-config.test.ts @@ -133,6 +133,24 @@ describe("getRebuildEndpointFromRegistry", () => { expect( getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1?x=1"), ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1;id"), + ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1%0ax"), + ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "\thttps://example.test/v1"), + ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1\n"), + ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "\u00a0https://example.test/v1"), + ).toEqual({ known: false }); + expect( + getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1\u2029"), + ).toEqual({ known: false }); expect( getRebuildEndpointFromRegistry("compatible-endpoint", "http://@example.test/v1"), ).toEqual({ known: false }); @@ -342,23 +360,30 @@ describe("prepareRebuildResumeConfig", () => { ).toThrow("Cannot validate recreate endpoint"); }); - it("fails closed for a matching custom-endpoint session with an invalid endpoint", () => { - vi.spyOn(onboardSession, "loadSession").mockReturnValue({ - sandboxName: "alpha", - provider: "compatible-endpoint", - model: "m", - endpointUrl: "https://user:pass@example.test/v1", - }); - expect(() => - prepareRebuildResumeConfig( - "alpha", - entry({ provider: "compatible-endpoint", model: "m" }), - null, - noopLog, - throwingBail, - ), - ).toThrow("Cannot validate recreate endpoint"); - }); + it.each([ + ["userinfo", "https://user:pass@example.test/v1"], + ["a percent-encoded control character", "https://example.test/v1%0ainjected"], + ["a shell metacharacter", "https://example.test/v1;id"], + ])( + "fails closed for a matching custom-endpoint session with %s before rebuild deletion", + (_label, endpointUrl) => { + vi.spyOn(onboardSession, "loadSession").mockReturnValue({ + sandboxName: "alpha", + provider: "compatible-endpoint", + model: "m", + endpointUrl, + }); + expect(() => + prepareRebuildResumeConfig( + "alpha", + entry({ provider: "compatible-endpoint", model: "m" }), + null, + noopLog, + throwingBail, + ), + ).toThrow("Cannot validate recreate endpoint"); + }, + ); it("does not borrow a custom endpoint from a conflicting same-sandbox selection", () => { vi.spyOn(onboardSession, "loadSession").mockReturnValue({ diff --git a/src/lib/actions/sandbox/rebuild-resume-preflight.ts b/src/lib/actions/sandbox/rebuild-resume-preflight.ts index a71c5e99c7..aad66b6af7 100644 --- a/src/lib/actions/sandbox/rebuild-resume-preflight.ts +++ b/src/lib/actions/sandbox/rebuild-resume-preflight.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { D, R } from "../../cli/terminal-style"; -import { endpointUrlHasUserinfoQueryOrFragment } from "../../core/url-utils"; +import { unsafeEndpointUrlViolation } from "../../core/url-utils"; import type { InferenceSelection } from "../../inference/selection"; import type { RegistryInferenceRoute } from "../../onboard/rebuild-route-handoff"; import { isRecoveredProviderCredentialReuseSelectionKey } from "../../onboard/recovered-provider-reuse"; @@ -98,9 +98,9 @@ const SESSION_ONLY_ENDPOINT_PROVIDER_NAMES = new Set( export function canonicalCustomEndpointUrl(value: string | null | undefined): string | null { const raw = typeof value === "string" ? value.trim() : ""; - // #9106: reject userinfo, query, and fragment components instead of - // stripping them, matching onboarding intake. - if (endpointUrlHasUserinfoQueryOrFragment(raw)) return null; + // #9106/#9301: reject unsafe endpoint metadata instead of stripping or + // forwarding it, matching onboarding intake. + if (unsafeEndpointUrlViolation(value)) return null; try { const url = new URL(raw); const supportedProtocol = url.protocol === "http:" || url.protocol === "https:"; diff --git a/src/lib/core/url-utils.test.ts b/src/lib/core/url-utils.test.ts index f7a697a5d6..130e121684 100644 --- a/src/lib/core/url-utils.test.ts +++ b/src/lib/core/url-utils.test.ts @@ -12,6 +12,7 @@ import { normalizeProviderBaseUrl, parsePolicyPresetEnv, stripEndpointSuffix, + unsafeEndpointUrlViolation, } from "./url-utils"; describe("compactText", () => { @@ -145,6 +146,59 @@ describe("endpointUrlHasUserinfoQueryOrFragment", () => { }); }); +describe("unsafeEndpointUrlViolation", () => { + it.each([ + ["backtick command substitution", "http://127.0.0.1:8000/v1`whoami`", "unsupported-characters"], + ["dollar command substitution", "http://127.0.0.1:8000/v1$(id)", "unsupported-characters"], + ["semicolon in the path", "https://example.test/v1;id", "unsupported-characters"], + ["pipe in the path", "https://example.test/v1|cat", "unsupported-characters"], + ["ampersand in the path", "https://example.test/v1&x", "unsupported-characters"], + ["double quote", 'https://example.test/v1"q"', "unsupported-characters"], + ["single quote", "https://example.test/v1'q'", "unsupported-characters"], + ["interior space", "https://example.test/v 1", "unsupported-characters"], + ["encoded newline", "https://example.test/v1%0ainjected", "encoded-control-characters"], + ["encoded carriage return uppercase", "https://example.test/v1%0Dx", "encoded-control-characters"], + ["encoded NUL", "https://example.test/v1%00x", "encoded-control-characters"], + ["encoded UTF-8 C1 control", "https://example.test/v1%C2%80x", "encoded-control-characters"], + [ + "encoded UTF-8 zero-width space", + "https://example.test/v1%E2%80%8Bx", + "encoded-control-characters", + ], + ["raw tab", "https://example.test/v\t1", "control-characters"], + ["raw newline", "https://example.test/v\n1", "control-characters"], + ["leading tab", "\thttps://example.test/v1", "control-characters"], + ["trailing tab", "https://example.test/v1\t", "control-characters"], + ["leading newline", "\nhttps://example.test/v1", "control-characters"], + ["trailing newline", "https://example.test/v1\n", "control-characters"], + ["leading no-break space", "\u00a0https://example.test/v1", "unsupported-characters"], + ["trailing ogham space mark", "https://example.test/v1\u1680", "unsupported-characters"], + ["leading en quad", "\u2000https://example.test/v1", "unsupported-characters"], + ["trailing line separator", "https://example.test/v1\u2028", "unsupported-characters"], + ["leading paragraph separator", "\u2029https://example.test/v1", "unsupported-characters"], + ["query string", "http://127.0.0.1:8000/v1?param=value", "userinfo-query-fragment"], + ["userinfo", "https://user:password@example.test/v1", "userinfo-query-fragment"], + ["non-HTTP scheme", "ftp://example.test/v1", "unsupported-protocol"], + ["scheme-less host and port", "localhost:8000/v1", "unsupported-protocol"], + ["scheme-less host path", "example.test/v1", "invalid-url"], + ["non-ASCII host", "https://exämple.test/v1", "unsupported-characters"], + ] as const)("rejects %s (#9301)", (_label, input, kind) => { + expect(unsafeEndpointUrlViolation(input)?.kind).toBe(kind); + }); + + it.each([ + ["IPv6 loopback with port", "http://[::1]:8000/v1"], + ["host with port and deep path", "https://example.test:8443/deep/path-v1"], + ["path with URL-legal punctuation", "http://example.test/v1_x.y~z"], + ["percent-encoded space in the path", "https://example.test/v1/a%20b"], + ["clean origin", "https://proxy.example.com"], + ["empty input", ""], + ["whitespace input", " "], + ] as const)("accepts %s (#9301)", (_label, input) => { + expect(unsafeEndpointUrlViolation(input)).toBeNull(); + }); +}); + describe("isLoopbackHostname", () => { it.each([ ["localhost", true], diff --git a/src/lib/core/url-utils.ts b/src/lib/core/url-utils.ts index 0f249f7de9..2d2b6536ca 100644 --- a/src/lib/core/url-utils.ts +++ b/src/lib/core/url-utils.ts @@ -73,6 +73,90 @@ export function endpointUrlHasUserinfoQueryOrFragment(value: string | null | und } } +// Endpoint URL inputs feed provider registration, registry writes, Dockerfile +// ARGs, and container startup commands, so intake accepts only characters that +// stay inert across every downstream consumer. The set matches the +// startup-command token allowlist in onboard/docker-startup-command-env.ts +// plus "~"; the two sets stay separate because command tokens and endpoint +// URLs are distinct contracts. +const ENDPOINT_URL_ALLOWED_CHARACTERS = /^[A-Za-z0-9_./:=,@%+\-[\]~]+$/u; +const CONTROL_OR_FORMAT_CHARACTER = /[\p{Cc}\p{Cf}]/u; + +function trimEndpointUrlAsciiSpaces(value: string): string { + return value.replace(/^ +/u, "").replace(/ +$/u, ""); +} + +export type EndpointUrlViolation = { + kind: + | "userinfo-query-fragment" + | "control-characters" + | "encoded-control-characters" + | "unsupported-characters" + | "invalid-url" + | "unsupported-protocol"; + reason: string; +}; + +/** + * Classify an endpoint URL input that onboarding must reject before any + * network request, provider registration, registry write, or sandbox and + * image mutation (#9301). Returns null for an empty input (emptiness is a + * separate required-input error) and for a safe absolute HTTP(S) URL. The + * reason completes the sentence "Endpoint URL ..." and never echoes the + * input value. + */ +export function unsafeEndpointUrlViolation( + value: string | null | undefined, +): EndpointUrlViolation | null { + const input = String(value || ""); + const raw = trimEndpointUrlAsciiSpaces(input); + if (!raw) return null; + // Inspect the original input before surrounding ASCII spaces are + // normalized. The WHATWG parser and downstream consumers can discard + // boundary controls, but intake promises to reject them before mutation. + if (CONTROL_OR_FORMAT_CHARACTER.test(input)) { + return { kind: "control-characters", reason: "must not contain control characters." }; + } + if (endpointUrlHasUserinfoQueryOrFragment(raw)) { + return { + kind: "userinfo-query-fragment", + reason: "must not contain userinfo, query, or fragment components.", + }; + } + // Decode once and reclassify so a percent-encoded control or format + // character (ASCII %0A as well as UTF-8 forms such as %C2%80 and %E2%80%8B) + // cannot pass while its literal form is rejected. Downstream consumers + // decode at most once, so a double-encoded sequence stays inert text. + let decoded = raw; + try { + decoded = decodeURIComponent(raw); + } catch { + // Malformed percent-encoding carries no decoded controls; the remaining + // checks classify the raw input. + } + if (CONTROL_OR_FORMAT_CHARACTER.test(decoded)) { + return { + kind: "encoded-control-characters", + reason: "must not contain percent-encoded control characters.", + }; + } + if (!ENDPOINT_URL_ALLOWED_CHARACTERS.test(raw)) { + return { + kind: "unsupported-characters", + reason: "must contain only URL-safe ASCII characters.", + }; + } + try { + const url = new URL(raw); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { kind: "unsupported-protocol", reason: "must use HTTP or HTTPS." }; + } + } catch { + return { kind: "invalid-url", reason: "must be a valid HTTP or HTTPS URL." }; + } + return null; +} + /** Return the bounded canonical form of a credential-free HTTP(S) provider endpoint. */ export function canonicalEndpoint( value: string | null | undefined, diff --git a/src/lib/onboard/setup-nim-selection.ts b/src/lib/onboard/setup-nim-selection.ts index dda848652f..fcfaf72ee0 100644 --- a/src/lib/onboard/setup-nim-selection.ts +++ b/src/lib/onboard/setup-nim-selection.ts @@ -3,8 +3,8 @@ import { canonicalEndpoint, - endpointUrlHasUserinfoQueryOrFragment, normalizeProviderBaseUrl, + unsafeEndpointUrlViolation, } from "../core/url-utils"; import { applyCompatibleEndpointContextWindow } from "../inference/compatible-endpoint-context"; import type { TrustedPrivateEndpointCapability } from "../inference/endpoint-ssrf-preflight"; @@ -101,10 +101,13 @@ export async function resolveCompatibleEndpointInput(args: { nonInteractive: boolean; prompt: (message: string) => Promise; }): Promise { - const envUrl = (args.envUrl || "").trim(); - const recoveredUrl = (args.recoveredEndpointUrl || "").trim(); + const envInput = args.envUrl || ""; + const recoveredInput = args.recoveredEndpointUrl || ""; + const envUrl = envInput.trim(); + const recoveredUrl = recoveredInput.trim(); const defaultEndpointUrl = envUrl || recoveredUrl; - if (args.nonInteractive) return defaultEndpointUrl; + const defaultEndpointInput = envUrl ? envInput : recoveredUrl ? recoveredInput : ""; + if (args.nonInteractive) return defaultEndpointInput; return ( (await args.prompt( defaultEndpointUrl @@ -112,7 +115,7 @@ export async function resolveCompatibleEndpointInput(args: { : args.kind === "openai" ? " OpenAI-compatible base URL (e.g., https://openrouter.ai): " : " Anthropic-compatible base URL (e.g., https://proxy.example.com): ", - )) || defaultEndpointUrl + )) || defaultEndpointInput ); } @@ -142,21 +145,24 @@ export async function resolveCompatibleEndpointSelection(args: { if (navigation === "exit") { exitOnboardFromPrompt(); } - // #9106: reject instead of silently stripping components that NemoClaw - // cannot forward to the endpoint. - if (endpointUrlHasUserinfoQueryOrFragment(endpointInput)) { - console.error(" Endpoint URL must not contain userinfo, query, or fragment components."); - // canonicalEndpoint returns null unless the stripped base is a - // credential-free http(s) URL, so the hint never echoes userinfo or - // query values. - const strippedBaseUrl = canonicalEndpoint( - normalizeProviderBaseUrl(endpointInput, args.kind), - args.kind, - ); - if (strippedBaseUrl) { - console.error( - ` NemoClaw does not forward these components to the endpoint. Use: ${strippedBaseUrl}`, + // #9106/#9301: reject unsafe endpoint input here, before any network + // request, provider registration, registry write, or sandbox mutation. + const endpointViolation = unsafeEndpointUrlViolation(endpointInput); + if (endpointViolation) { + console.error(` Endpoint URL ${endpointViolation.reason}`); + if (endpointViolation.kind === "userinfo-query-fragment") { + // canonicalEndpoint returns null unless the stripped base is a + // credential-free http(s) URL, so the hint never echoes userinfo or + // query values. + const strippedBaseUrl = canonicalEndpoint( + normalizeProviderBaseUrl(endpointInput, args.kind), + args.kind, ); + if (strippedBaseUrl) { + console.error( + ` NemoClaw does not forward these components to the endpoint. Use: ${strippedBaseUrl}`, + ); + } } if (args.nonInteractive) { process.exit(1); diff --git a/test/onboard-endpoint-url-rejection.test.ts b/test/onboard-endpoint-url-rejection.test.ts index ef85938d1b..8f8ad1ed1c 100644 --- a/test/onboard-endpoint-url-rejection.test.ts +++ b/test/onboard-endpoint-url-rejection.test.ts @@ -77,6 +77,126 @@ setupNim(null).then( } }); + it.each([ + [ + "shell metacharacters", + "http://127.0.0.1:8000/v1$(id)", + /Endpoint URL must contain only URL-safe ASCII characters\./, + ], + [ + "percent-encoded control characters", + "http://127.0.0.1:8000/v1%0ainjected", + /Endpoint URL must not contain percent-encoded control characters\./, + ], + [ + "a leading tab", + "\thttp://127.0.0.1:8000/v1", + /Endpoint URL must not contain control characters\./, + ], + [ + "a trailing tab", + "http://127.0.0.1:8000/v1\t", + /Endpoint URL must not contain control characters\./, + ], + [ + "a leading newline", + "\nhttp://127.0.0.1:8000/v1", + /Endpoint URL must not contain control characters\./, + ], + [ + "a trailing newline", + "http://127.0.0.1:8000/v1\n", + /Endpoint URL must not contain control characters\./, + ], + [ + "a leading no-break space", + "\u00a0http://127.0.0.1:8000/v1", + /Endpoint URL must contain only URL-safe ASCII characters\./, + ], + [ + "a trailing paragraph separator", + "http://127.0.0.1:8000/v1\u2029", + /Endpoint URL must contain only URL-safe ASCII characters\./, + ], + ] as const)( + "rejects an unsafe NEMOCLAW_ENDPOINT_URL with %s before any network request or state write (#9301)", + (_label, endpointUrl, expectedMessage) => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-endpoint-url-unsafe-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "check.js"); + const curlMarkerPath = path.join(tmpDir, "curl-invoked"); + const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts")); + const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync( + path.join(fakeBin, "curl"), + `#!/usr/bin/env bash +touch "${curlMarkerPath}" +printf '000' +`, + { mode: 0o755 }, + ); + fs.writeFileSync( + scriptPath, + String.raw` +const runner = require(${runnerPath}); +runner.runCapture = () => ""; +const { setupNim } = require(${onboardPath}); + +Object.assign(process.env, { + NEMOCLAW_NON_INTERACTIVE: "1", + NEMOCLAW_PROVIDER: "custom", + NEMOCLAW_ENDPOINT_URL: ${JSON.stringify(endpointUrl)}, + NEMOCLAW_MODEL: "mock-model", + NEMOCLAW_COMPATIBLE_AUTH_MODE: "none", + NEMOCLAW_PREFERRED_API: "chat-completions", +}); + +const originalLog = console.log; +console.log = () => {}; +process.exit = (code) => { + throw Object.assign(new Error("exit"), { code }); +}; + +setupNim(null).then( + () => { + originalLog(JSON.stringify({ resolved: true })); + }, + (error) => { + originalLog(JSON.stringify({ exitCode: error.code })); + }, +); +`, + ); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout.trim()), { exitCode: 1 }); + assert.match(result.stderr, expectedMessage); + // The rejection must not echo the unsafe input back to the terminal, + // including through JSON-style escaping of control characters. + assert.ok(!result.stderr.includes(endpointUrl)); + assert.ok(!result.stderr.includes(JSON.stringify(endpointUrl).slice(1, -1))); + // The QA contract (#9301): rejection fires before any network request + // or persistent state write, so the environment stays unchanged. + assert.ok(!fs.existsSync(curlMarkerPath)); + const writtenStateFiles = ( + fs.readdirSync(tmpDir, { recursive: true }) as string[] + ).filter((entry) => /onboard-session\.json|sandboxes\.json/.test(String(entry))); + assert.deepEqual(writtenStateFiles, []); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, + ); + it("re-prompts after rejecting a query-bearing endpoint URL in interactive mode (#9106)", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-endpoint-url-reprompt-"));