Skip to content
2 changes: 1 addition & 1 deletion ci/source-architecture-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions docs/inference/custom-endpoint-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
38 changes: 38 additions & 0 deletions src/lib/actions/inference-set-endpoint-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }]);

Expand Down
17 changes: 15 additions & 2 deletions src/lib/actions/inference-set-route-containment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
59 changes: 42 additions & 17 deletions src/lib/actions/sandbox/rebuild-resume-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 4 additions & 4 deletions src/lib/actions/sandbox/rebuild-resume-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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:";
Expand Down
54 changes: 54 additions & 0 deletions src/lib/core/url-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
normalizeProviderBaseUrl,
parsePolicyPresetEnv,
stripEndpointSuffix,
unsafeEndpointUrlViolation,
} from "./url-utils";

describe("compactText", () => {
Expand Down Expand Up @@ -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],
Expand Down
84 changes: 84 additions & 0 deletions src/lib/core/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading