Skip to content

Commit acb324e

Browse files
Dongni-Yangapurvvkumariagithub-actions[bot]
authored
fix(onboard): reject an unsafe custom endpoint URL before any mutation (#9320)
<!-- markdownlint-disable MD041 --> ## Summary Custom endpoint intake could accept unsafe characters until later processing, after network or state work had begun. Endpoint URLs are now classified before mutation across onboarding, `inference set`, and rebuild recovery; surrounding ASCII spaces are normalized, while boundary controls, Unicode separators, encoded controls, shell metacharacters, and other unsupported input are rejected without echoing the supplied value. ## Related Issue Fixes #9301 ## Changes - Add one shared custom endpoint classifier for userinfo, query, fragment, literal and encoded controls, unsupported characters, URL shape, and protocol. - Preserve the original onboarding, inference, and rebuild value until validation so boundary controls and Unicode separators cannot disappear before classification. - Normalize only surrounding ASCII spaces before the documented ASCII allowlist is applied. - Reject unsafe onboarding input before an endpoint request, provider registration, registry write, or sandbox and image mutation. - Apply the same contract before inference DNS validation and when rebuild recovery reads recorded endpoint metadata. - Add shared, focused, and process-level regression coverage for shell metacharacters, encoded controls, tabs, newlines, and Unicode separators, including no-network and no-state-mutation assertions. - Document the accepted character set, ASCII-space normalization, and reject-before-mutation behavior. ## Type of Change - [ ] Code change (feature, bug fix, or refactor) - [x] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Docs updated for user-facing behavior changes - [ ] Docs not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: [Maintainer security review — PASS](#9320 (comment)) - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: None accepted; fresh checks are running for the current revision. ## Documentation Writer Review - [x] Documentation writer subagent reviewed the completed changes - Result: `docs-updated` - Evidence: `docs/inference/custom-endpoint-security.mdx` and `docs/reference/commands.mdx`; the independent review covered all 11 changed files, confirmed the prior Unicode-separator accuracy finding is resolved, and found no remaining issue. The docs build validated 68 routes with 0 errors and 2 existing warnings; all generated OpenClaw, Hermes, and Deep Agents Code variants contain the updated text. - Agent: Codex Desktop <!-- docs-review-head-sha: 70ceb7b --> <!-- docs-review-agents-blob-sha: b9fb6a9 --> ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result or justification: 154 focused CLI tests passed; 10 process-level onboarding rejection tests passed; `npm run typecheck:cli` passed; `npm run docs` validated 68 routes with 0 errors. - [ ] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: Not applicable; this is a focused input-validation change and does not alter the runtime harness or repository-wide coverage configuration. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) — not a documentation-only change; the build passed with 2 existing warnings. - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) — not a documentation-only change; the independent documentation review found no issue. - [ ] New doc pages include SPDX header and frontmatter (new pages only) — no new page. --- Signed-off-by: Dongni Yang <dongniy@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Security Enhancements** - Strengthened custom endpoint validation to reject unsafe characters, encoded controls, whitespace, shell metacharacters, invalid URL formats, and unsupported protocols. - Validation occurs before network requests or configuration changes, preventing unsafe endpoint values from being used or saved. - Sandbox rebuilds safely handle invalid previously recorded endpoint metadata. - **Documentation** - Updated endpoint security and onboarding documentation with expanded validation requirements and accepted URL formats. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Dongni Yang <dongniy@nvidia.com> Signed-off-by: Apurv Kumaria <akumaria@nvidia.com> Co-authored-by: Apurv Kumaria <akumaria@nvidia.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 29feb03 commit acb324e

11 files changed

Lines changed: 392 additions & 44 deletions

ci/source-architecture-budget.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"src/lib/core/json-types.ts": 36,
1818
"src/lib/core/ports.ts": 89,
1919
"src/lib/core/shell-quote.ts": 28,
20-
"src/lib/core/url-utils.ts": 29,
20+
"src/lib/core/url-utils.ts": 30,
2121
"src/lib/core/wait.ts": 36,
2222
"src/lib/credentials/store.ts": 46,
2323
"src/lib/inference/config.ts": 30,

docs/inference/custom-endpoint-security.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ Custom endpoint onboarding rejects endpoint URLs that contain userinfo, query, o
3636
NemoClaw does not forward those components to the endpoint.
3737
Configure the provider credential separately instead of putting it in the endpoint URL.
3838

39+
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.
40+
The URL-safe ASCII set is ASCII letters, digits, and the characters `_ . / : = , @ % + - [ ] ~`.
41+
NemoClaw trims ASCII spaces at the start and end of the URL before it applies these checks.
42+
It also rejects an input that is not an absolute HTTP or HTTPS URL.
43+
This rejection happens before any network request, provider registration, registry write, or sandbox and image mutation, so a rejected input changes no NemoClaw state.
44+
The `inference set` command applies the same rejection classes to `--endpoint-url` before DNS resolution.
45+
Sandbox rebuild applies the same rejection classes to recorded custom endpoint metadata and treats a violating value as unknown.
46+
3947
Managed provider defaults that do not provide an explicit custom endpoint through these paths are unaffected.
4048

4149
Custom endpoint onboarding has one narrower operator-controlled exception for corporate inference gateways.

docs/reference/commands.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4741,7 +4741,7 @@ Set them before running `$$nemoclaw onboard`.
47414741
| `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. |
47424742
| `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. |
47434743
| `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. |
4744-
| `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. |
4744+
| `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. |
47454745
| `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. |
47464746
| `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. |
47474747
| `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`. |

src/lib/actions/inference-set-endpoint-security.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,44 @@ describe("custom inference endpoint DNS pinning", () => {
3838
).rejects.toThrow(/endpoint-url is not allowed:.*private\/internal address/i);
3939
});
4040

41+
it.each([
42+
[
43+
"shell metacharacters",
44+
"http://public.example/v1$(id)",
45+
/endpoint-url must contain only URL-safe ASCII characters\./,
46+
],
47+
[
48+
"percent-encoded control characters",
49+
"http://public.example/v1%0ainjected",
50+
/endpoint-url must not contain percent-encoded control characters\./,
51+
],
52+
[
53+
"a leading tab",
54+
"\thttp://public.example/v1",
55+
/endpoint-url must not contain control characters\./,
56+
],
57+
[
58+
"a trailing newline",
59+
"http://public.example/v1\n",
60+
/endpoint-url must not contain control characters\./,
61+
],
62+
[
63+
"a leading no-break space",
64+
"\u00a0http://public.example/v1",
65+
/endpoint-url must contain only URL-safe ASCII characters\./,
66+
],
67+
] as const)(
68+
"rejects an endpoint URL with %s before DNS validation or any mutation (#9301)",
69+
async (_label, endpointUrl, message) => {
70+
const rewriteUrl = vi.fn(async () => {
71+
throw new Error("unsafe endpoint unexpectedly reached DNS validation");
72+
});
73+
74+
await expect(normalizeCustomEndpointUrl(endpointUrl, rewriteUrl)).rejects.toThrow(message);
75+
expect(rewriteUrl).not.toHaveBeenCalled();
76+
},
77+
);
78+
4179
it("pins validated public HTTP endpoints before they become durable metadata", async () => {
4280
const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
4381

src/lib/actions/inference-set-route-containment.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
type HttpsPinCredentialProviderType,
1212
isHttpsPinRuntimeEligible,
1313
} from "../inference/https-pin-runtime";
14+
import { unsafeEndpointUrlViolation } from "../core/url-utils";
1415
import { resolveSandboxGatewayName } from "../onboard/gateway-binding";
1516
import { isAllowedOpenShellSandboxBridgeUrl } from "../private-networks";
1617
import { ConfigUrlValidationError } from "../sandbox/config";
@@ -133,17 +134,29 @@ function normalizeEndpointUrlShape(value: string): { url: URL; normalized: strin
133134
}
134135

135136
function normalizeCustomEndpointUrlWithoutDns(value: string | null | undefined): string {
136-
const raw = typeof value === "string" ? value.trim() : "";
137+
const input = typeof value === "string" ? value : "";
138+
const raw = input.trim();
137139
if (!raw)
138140
throw new InferenceSetError("endpoint-url is required for custom-compatible metadata.", 2);
141+
let normalized: string;
139142
try {
140-
return normalizeEndpointUrlShape(raw).normalized;
143+
normalized = normalizeEndpointUrlShape(raw).normalized;
141144
} catch {
142145
throw new InferenceSetError(
143146
"endpoint-url must be a valid http(s) URL without userinfo, query, or fragment components.",
144147
2,
145148
);
146149
}
150+
// #9301: reject control characters, percent-encoded control characters,
151+
// spaces, and shell metacharacters before any provider, registry, or
152+
// sandbox mutation, matching onboarding intake. The shape check above owns
153+
// the userinfo, query, fragment, scheme, and parse classes and their
154+
// established message.
155+
const violation = unsafeEndpointUrlViolation(input);
156+
if (violation) {
157+
throw new InferenceSetError(`endpoint-url ${violation.reason}`, 2);
158+
}
159+
return normalized;
147160
}
148161

149162
export async function normalizeCustomEndpointUrl(

src/lib/actions/sandbox/rebuild-resume-config.test.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ describe("getRebuildEndpointFromRegistry", () => {
133133
expect(
134134
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1?x=1"),
135135
).toEqual({ known: false });
136+
expect(
137+
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1;id"),
138+
).toEqual({ known: false });
139+
expect(
140+
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1%0ax"),
141+
).toEqual({ known: false });
142+
expect(
143+
getRebuildEndpointFromRegistry("compatible-endpoint", "\thttps://example.test/v1"),
144+
).toEqual({ known: false });
145+
expect(
146+
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1\n"),
147+
).toEqual({ known: false });
148+
expect(
149+
getRebuildEndpointFromRegistry("compatible-endpoint", "\u00a0https://example.test/v1"),
150+
).toEqual({ known: false });
151+
expect(
152+
getRebuildEndpointFromRegistry("compatible-endpoint", "https://example.test/v1\u2029"),
153+
).toEqual({ known: false });
136154
expect(
137155
getRebuildEndpointFromRegistry("compatible-endpoint", "http://@example.test/v1"),
138156
).toEqual({ known: false });
@@ -342,23 +360,30 @@ describe("prepareRebuildResumeConfig", () => {
342360
).toThrow("Cannot validate recreate endpoint");
343361
});
344362

345-
it("fails closed for a matching custom-endpoint session with an invalid endpoint", () => {
346-
vi.spyOn(onboardSession, "loadSession").mockReturnValue({
347-
sandboxName: "alpha",
348-
provider: "compatible-endpoint",
349-
model: "m",
350-
endpointUrl: "https://user:pass@example.test/v1",
351-
});
352-
expect(() =>
353-
prepareRebuildResumeConfig(
354-
"alpha",
355-
entry({ provider: "compatible-endpoint", model: "m" }),
356-
null,
357-
noopLog,
358-
throwingBail,
359-
),
360-
).toThrow("Cannot validate recreate endpoint");
361-
});
363+
it.each([
364+
["userinfo", "https://user:pass@example.test/v1"],
365+
["a percent-encoded control character", "https://example.test/v1%0ainjected"],
366+
["a shell metacharacter", "https://example.test/v1;id"],
367+
])(
368+
"fails closed for a matching custom-endpoint session with %s before rebuild deletion",
369+
(_label, endpointUrl) => {
370+
vi.spyOn(onboardSession, "loadSession").mockReturnValue({
371+
sandboxName: "alpha",
372+
provider: "compatible-endpoint",
373+
model: "m",
374+
endpointUrl,
375+
});
376+
expect(() =>
377+
prepareRebuildResumeConfig(
378+
"alpha",
379+
entry({ provider: "compatible-endpoint", model: "m" }),
380+
null,
381+
noopLog,
382+
throwingBail,
383+
),
384+
).toThrow("Cannot validate recreate endpoint");
385+
},
386+
);
362387

363388
it("does not borrow a custom endpoint from a conflicting same-sandbox selection", () => {
364389
vi.spyOn(onboardSession, "loadSession").mockReturnValue({

src/lib/actions/sandbox/rebuild-resume-preflight.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
import { D, R } from "../../cli/terminal-style";
5-
import { endpointUrlHasUserinfoQueryOrFragment } from "../../core/url-utils";
5+
import { unsafeEndpointUrlViolation } from "../../core/url-utils";
66
import type { InferenceSelection } from "../../inference/selection";
77
import type { RegistryInferenceRoute } from "../../onboard/rebuild-route-handoff";
88
import { isRecoveredProviderCredentialReuseSelectionKey } from "../../onboard/recovered-provider-reuse";
@@ -98,9 +98,9 @@ const SESSION_ONLY_ENDPOINT_PROVIDER_NAMES = new Set(
9898

9999
export function canonicalCustomEndpointUrl(value: string | null | undefined): string | null {
100100
const raw = typeof value === "string" ? value.trim() : "";
101-
// #9106: reject userinfo, query, and fragment components instead of
102-
// stripping them, matching onboarding intake.
103-
if (endpointUrlHasUserinfoQueryOrFragment(raw)) return null;
101+
// #9106/#9301: reject unsafe endpoint metadata instead of stripping or
102+
// forwarding it, matching onboarding intake.
103+
if (unsafeEndpointUrlViolation(value)) return null;
104104
try {
105105
const url = new URL(raw);
106106
const supportedProtocol = url.protocol === "http:" || url.protocol === "https:";

src/lib/core/url-utils.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
normalizeProviderBaseUrl,
1313
parsePolicyPresetEnv,
1414
stripEndpointSuffix,
15+
unsafeEndpointUrlViolation,
1516
} from "./url-utils";
1617

1718
describe("compactText", () => {
@@ -145,6 +146,59 @@ describe("endpointUrlHasUserinfoQueryOrFragment", () => {
145146
});
146147
});
147148

149+
describe("unsafeEndpointUrlViolation", () => {
150+
it.each([
151+
["backtick command substitution", "http://127.0.0.1:8000/v1`whoami`", "unsupported-characters"],
152+
["dollar command substitution", "http://127.0.0.1:8000/v1$(id)", "unsupported-characters"],
153+
["semicolon in the path", "https://example.test/v1;id", "unsupported-characters"],
154+
["pipe in the path", "https://example.test/v1|cat", "unsupported-characters"],
155+
["ampersand in the path", "https://example.test/v1&x", "unsupported-characters"],
156+
["double quote", 'https://example.test/v1"q"', "unsupported-characters"],
157+
["single quote", "https://example.test/v1'q'", "unsupported-characters"],
158+
["interior space", "https://example.test/v 1", "unsupported-characters"],
159+
["encoded newline", "https://example.test/v1%0ainjected", "encoded-control-characters"],
160+
["encoded carriage return uppercase", "https://example.test/v1%0Dx", "encoded-control-characters"],
161+
["encoded NUL", "https://example.test/v1%00x", "encoded-control-characters"],
162+
["encoded UTF-8 C1 control", "https://example.test/v1%C2%80x", "encoded-control-characters"],
163+
[
164+
"encoded UTF-8 zero-width space",
165+
"https://example.test/v1%E2%80%8Bx",
166+
"encoded-control-characters",
167+
],
168+
["raw tab", "https://example.test/v\t1", "control-characters"],
169+
["raw newline", "https://example.test/v\n1", "control-characters"],
170+
["leading tab", "\thttps://example.test/v1", "control-characters"],
171+
["trailing tab", "https://example.test/v1\t", "control-characters"],
172+
["leading newline", "\nhttps://example.test/v1", "control-characters"],
173+
["trailing newline", "https://example.test/v1\n", "control-characters"],
174+
["leading no-break space", "\u00a0https://example.test/v1", "unsupported-characters"],
175+
["trailing ogham space mark", "https://example.test/v1\u1680", "unsupported-characters"],
176+
["leading en quad", "\u2000https://example.test/v1", "unsupported-characters"],
177+
["trailing line separator", "https://example.test/v1\u2028", "unsupported-characters"],
178+
["leading paragraph separator", "\u2029https://example.test/v1", "unsupported-characters"],
179+
["query string", "http://127.0.0.1:8000/v1?param=value", "userinfo-query-fragment"],
180+
["userinfo", "https://user:password@example.test/v1", "userinfo-query-fragment"],
181+
["non-HTTP scheme", "ftp://example.test/v1", "unsupported-protocol"],
182+
["scheme-less host and port", "localhost:8000/v1", "unsupported-protocol"],
183+
["scheme-less host path", "example.test/v1", "invalid-url"],
184+
["non-ASCII host", "https://exämple.test/v1", "unsupported-characters"],
185+
] as const)("rejects %s (#9301)", (_label, input, kind) => {
186+
expect(unsafeEndpointUrlViolation(input)?.kind).toBe(kind);
187+
});
188+
189+
it.each([
190+
["IPv6 loopback with port", "http://[::1]:8000/v1"],
191+
["host with port and deep path", "https://example.test:8443/deep/path-v1"],
192+
["path with URL-legal punctuation", "http://example.test/v1_x.y~z"],
193+
["percent-encoded space in the path", "https://example.test/v1/a%20b"],
194+
["clean origin", "https://proxy.example.com"],
195+
["empty input", ""],
196+
["whitespace input", " "],
197+
] as const)("accepts %s (#9301)", (_label, input) => {
198+
expect(unsafeEndpointUrlViolation(input)).toBeNull();
199+
});
200+
});
201+
148202
describe("isLoopbackHostname", () => {
149203
it.each([
150204
["localhost", true],

src/lib/core/url-utils.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,90 @@ export function endpointUrlHasUserinfoQueryOrFragment(value: string | null | und
7373
}
7474
}
7575

76+
// Endpoint URL inputs feed provider registration, registry writes, Dockerfile
77+
// ARGs, and container startup commands, so intake accepts only characters that
78+
// stay inert across every downstream consumer. The set matches the
79+
// startup-command token allowlist in onboard/docker-startup-command-env.ts
80+
// plus "~"; the two sets stay separate because command tokens and endpoint
81+
// URLs are distinct contracts.
82+
const ENDPOINT_URL_ALLOWED_CHARACTERS = /^[A-Za-z0-9_./:=,@%+\-[\]~]+$/u;
83+
const CONTROL_OR_FORMAT_CHARACTER = /[\p{Cc}\p{Cf}]/u;
84+
85+
function trimEndpointUrlAsciiSpaces(value: string): string {
86+
return value.replace(/^ +/u, "").replace(/ +$/u, "");
87+
}
88+
89+
export type EndpointUrlViolation = {
90+
kind:
91+
| "userinfo-query-fragment"
92+
| "control-characters"
93+
| "encoded-control-characters"
94+
| "unsupported-characters"
95+
| "invalid-url"
96+
| "unsupported-protocol";
97+
reason: string;
98+
};
99+
100+
/**
101+
* Classify an endpoint URL input that onboarding must reject before any
102+
* network request, provider registration, registry write, or sandbox and
103+
* image mutation (#9301). Returns null for an empty input (emptiness is a
104+
* separate required-input error) and for a safe absolute HTTP(S) URL. The
105+
* reason completes the sentence "Endpoint URL ..." and never echoes the
106+
* input value.
107+
*/
108+
export function unsafeEndpointUrlViolation(
109+
value: string | null | undefined,
110+
): EndpointUrlViolation | null {
111+
const input = String(value || "");
112+
const raw = trimEndpointUrlAsciiSpaces(input);
113+
if (!raw) return null;
114+
// Inspect the original input before surrounding ASCII spaces are
115+
// normalized. The WHATWG parser and downstream consumers can discard
116+
// boundary controls, but intake promises to reject them before mutation.
117+
if (CONTROL_OR_FORMAT_CHARACTER.test(input)) {
118+
return { kind: "control-characters", reason: "must not contain control characters." };
119+
}
120+
if (endpointUrlHasUserinfoQueryOrFragment(raw)) {
121+
return {
122+
kind: "userinfo-query-fragment",
123+
reason: "must not contain userinfo, query, or fragment components.",
124+
};
125+
}
126+
// Decode once and reclassify so a percent-encoded control or format
127+
// character (ASCII %0A as well as UTF-8 forms such as %C2%80 and %E2%80%8B)
128+
// cannot pass while its literal form is rejected. Downstream consumers
129+
// decode at most once, so a double-encoded sequence stays inert text.
130+
let decoded = raw;
131+
try {
132+
decoded = decodeURIComponent(raw);
133+
} catch {
134+
// Malformed percent-encoding carries no decoded controls; the remaining
135+
// checks classify the raw input.
136+
}
137+
if (CONTROL_OR_FORMAT_CHARACTER.test(decoded)) {
138+
return {
139+
kind: "encoded-control-characters",
140+
reason: "must not contain percent-encoded control characters.",
141+
};
142+
}
143+
if (!ENDPOINT_URL_ALLOWED_CHARACTERS.test(raw)) {
144+
return {
145+
kind: "unsupported-characters",
146+
reason: "must contain only URL-safe ASCII characters.",
147+
};
148+
}
149+
try {
150+
const url = new URL(raw);
151+
if (url.protocol !== "http:" && url.protocol !== "https:") {
152+
return { kind: "unsupported-protocol", reason: "must use HTTP or HTTPS." };
153+
}
154+
} catch {
155+
return { kind: "invalid-url", reason: "must be a valid HTTP or HTTPS URL." };
156+
}
157+
return null;
158+
}
159+
76160
/** Return the bounded canonical form of a credential-free HTTP(S) provider endpoint. */
77161
export function canonicalEndpoint(
78162
value: string | null | undefined,

0 commit comments

Comments
 (0)