-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(security): validate Hermes endpoint URL and reject allowed_ips in user presets #6085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c8328e7
4639f7a
dba3620
2698ec2
190950e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,193 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { setupHermesProviderInference } from "./hermes"; | ||
|
|
||
| vi.mock("../../private-networks", () => ({ | ||
| isPrivateHostname: (hostname: string) => { | ||
| const privateHosts = new Set(["localhost", "host.docker.internal"]); | ||
| const privatePatterns = [ | ||
| /^127\./, | ||
| /^10\./, | ||
| /^192\.168\./, | ||
| /^172\.(1[6-9]|2\d|3[01])\./, | ||
| /^169\.254\./, | ||
| ]; | ||
| if (privateHosts.has(hostname)) return true; | ||
| if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true; | ||
| return privatePatterns.some((re) => re.test(hostname)); | ||
| }, | ||
| })); | ||
|
Comment on lines
+8
to
+22
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Remove the hand-rolled The This is also the root cause of the CI guardrail failure ("Hermes test file has 3 if statement(s), up from 0"): 2 of the 3 come from this mock (Lines 18-19), the 3rd from the Dropping the mock and simplifying As per path instructions for 🧪 Proposed fix import { describe, expect, it, vi } from "vitest";
import { setupHermesProviderInference } from "./hermes";
-vi.mock("../../private-networks", () => ({
- isPrivateHostname: (hostname: string) => {
- const privateHosts = new Set(["localhost", "host.docker.internal"]);
- const privatePatterns = [
- /^127\./,
- /^10\./,
- /^192\.168\./,
- /^172\.(1[6-9]|2\d|3[01])\./,
- /^169\.254\./,
- ];
- if (privateHosts.has(hostname)) return true;
- if (hostname.endsWith(".internal") || hostname.endsWith(".local")) return true;
- return privatePatterns.some((re) => re.test(hostname));
- },
-}));
-
function makeDeps(overrides: Record<string, unknown> = {}) {
return {
...
- requireValue: vi.fn((v: unknown, msg: string) => {
- if (!v) throw new Error(msg);
- return v;
- }),
+ requireValue: vi.fn((v: unknown) => v),Please verify the real #!/bin/bash
fd private-networks.ts src/lib
cat -n src/lib/private-networks.tsAlso applies to: 50-53 🤖 Prompt for AI AgentsSources: Path instructions, Pipeline failures |
||
|
|
||
| function makeDeps(overrides: Record<string, unknown> = {}) { | ||
| return { | ||
| runOpenshell: vi.fn(() => ({ status: 0, stdout: "", stderr: "" })), | ||
| upsertProvider: vi.fn(), | ||
| verifyInferenceRoute: vi.fn(), | ||
| verifyOnboardInferenceSmoke: vi.fn(), | ||
| isNonInteractive: vi.fn(() => false), | ||
| registry: { updateSandbox: vi.fn() }, | ||
| hermesProviderAuth: { | ||
| isHermesProviderRegistered: vi.fn(() => true), | ||
| ensureHermesProviderApiKeyCredentials: vi.fn(() => ({})), | ||
| ensureHermesProviderOAuthCredentials: vi.fn(() => ({})), | ||
| }, | ||
| getHermesToolGatewayBroker: vi.fn(() => ({ | ||
| getHermesToolGatewayProviderName: vi.fn(() => "hermes-tool-gateway"), | ||
| })), | ||
| providerExistsInGateway: vi.fn(() => true), | ||
| normalizeHermesAuthMethod: vi.fn(() => "api-key"), | ||
| resolveHermesNousApiKey: vi.fn(() => null), | ||
| checkHermesProviderStoreReachable: vi.fn(() => ({ ok: true })), | ||
| hermesAuthMethodLabel: vi.fn((m: string) => m), | ||
| hermesConstants: { | ||
| HERMES_NOUS_API_KEY_CREDENTIAL_ENV: "NOUS_API_KEY", | ||
| HERMES_AUTH_METHOD_API_KEY: "api-key", | ||
| HERMES_AUTH_METHOD_OAUTH: "oauth", | ||
| }, | ||
| requireValue: vi.fn((v: unknown, _msg: string) => v), | ||
| redact: vi.fn((s: string) => s), | ||
| compactText: vi.fn((s: string) => s), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe("setupHermesProviderInference SSRF guard (#6072)", () => { | ||
| it("rejects loopback address", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "http://127.0.0.1:8080/v1", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/private or internal/); | ||
| }); | ||
|
|
||
| it("rejects cloud metadata endpoint", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "http://169.254.169.254/latest/meta-data/", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/private or internal/); | ||
| }); | ||
|
|
||
| it("rejects private RFC-1918 range", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "http://10.0.0.1/v1", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/private or internal/); | ||
| }); | ||
|
|
||
| it("rejects localhost hostname", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "http://localhost:11434/v1", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/private or internal/); | ||
| }); | ||
|
|
||
| it("rejects .internal TLD", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "http://my-service.internal/v1", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/private or internal/); | ||
| }); | ||
|
|
||
| it("throws on malformed URL", async () => { | ||
| await expect( | ||
| setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "not-a-url", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| makeDeps() as never, | ||
| ), | ||
| ).rejects.toThrow(/Invalid inference endpoint URL/); | ||
| }); | ||
|
|
||
| it("accepts a public HTTPS endpoint", async () => { | ||
| const deps = makeDeps(); | ||
| await setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: "https://integrate.api.nvidia.com/v1", | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| deps as never, | ||
| ); | ||
| expect(deps.runOpenshell).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("skips SSRF check when endpointUrl is null", async () => { | ||
| const deps = makeDeps(); | ||
| await setupHermesProviderInference( | ||
| { | ||
| sandboxName: "alpha", | ||
| model: "m", | ||
| provider: "p", | ||
| endpointUrl: null, | ||
| credentialEnv: null, | ||
| hermesAuthMethod: null, | ||
| hermesToolGateways: [], | ||
| }, | ||
| deps as never, | ||
| ); | ||
| expect(deps.runOpenshell).toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Extracted verbatim from onboard.setupInference (#767). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { HermesAuthMethod } from "../hermes-auth"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { isPrivateHostname } from "../../private-networks"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { HermesDeps, SetupInferenceResult } from "./types"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function setupHermesProviderInference( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -28,6 +29,19 @@ export async function setupHermesProviderInference( | |||||||||||||||||||||||||||||||||||||||||||||||||||||
| hermesAuthMethod, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| hermesToolGateways, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } = args; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (endpointUrl) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let parsedEndpoint: URL; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parsedEndpoint = new URL(endpointUrl); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`Invalid inference endpoint URL: ${endpointUrl}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isPrivateHostname(parsedEndpoint.hostname)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| `Inference endpoint URL points to a private or internal address "${parsedEndpoint.hostname}". Use a public endpoint.`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+32
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Redact Line 37 interpolates the raw, user-supplied
🔒 Proposed fix try {
parsedEndpoint = new URL(endpointUrl);
} catch {
- throw new Error(`Invalid inference endpoint URL: ${endpointUrl}`);
+ throw new Error(`Invalid inference endpoint URL: ${deps.redact(endpointUrl)}`);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| runOpenshell, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| upsertProvider: _upsertProvider, // intentionally unused; matches inline branch | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1065,6 +1065,20 @@ function loadPresetFromFile(filePath: string): { presetName: string; content: st | |
| console.error(` Preset missing network_policies section: ${filePath}`); | ||
| return null; | ||
| } | ||
| const np = parsed.network_policies as PolicyObject; | ||
| for (const [policyKey, policyVal] of Object.entries(np)) { | ||
| if (!isPolicyObject(policyVal)) continue; | ||
| const endpoints = (policyVal as PolicyObject).endpoints; | ||
| if (!Array.isArray(endpoints)) continue; | ||
| for (const ep of endpoints) { | ||
| if (isPolicyObject(ep) && "allowed_ips" in ep) { | ||
| console.error( | ||
| ` Preset '${presetName}' contains 'allowed_ips' in policy '${policyKey}', which is not permitted in user-supplied presets: ${filePath}`, | ||
| ); | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+1068
to
+1081
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm whether the merge/apply path (openshell policy-add or the sandbox merge logic)
# enforces the same array-of-objects shape for `endpoints`, or tolerates alternate shapes
# that could carry `allowed_ips` past this pre-check.
rg -nP -C5 '\bnetwork_policies\b' src/lib/policy/index.ts | head -100
rg -nP -C3 'policy-add|--from-file|--from-dir' src/lib/policy/index.tsRepository: NVIDIA/NemoClaw Length of output: 4796 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the validator and the merge/apply path around the reported lines.
sed -n '1040,1105p' src/lib/policy/index.ts
printf '\n--- merge path ---\n'
sed -n '430,560p' src/lib/policy/index.ts
printf '\n--- custom file/dir path ---\n'
sed -n '730,820p' src/lib/policy/index.ts
printf '\n--- allowed_ips references ---\n'
rg -n -C4 '"allowed_ips"|allowed_ips' src/lib/policy/index.tsRepository: NVIDIA/NemoClaw Length of output: 10894 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- textBasedMerge ---'
sed -n '380,460p' src/lib/policy/index.ts
printf '\n%s\n' '--- parseCurrentPolicy / helpers ---'
sed -n '1,140p' src/lib/policy/index.ts
printf '\n%s\n' '--- preset endpoint extraction / any related validation ---'
rg -n -C4 'function getPresetEndpoints|allowed_ips|endpoints' src/lib/policy/index.tsRepository: NVIDIA/NemoClaw Length of output: 10403 Fail closed on malformed 🤖 Prompt for AI Agents |
||
| const builtin = listPresets().map((p) => p.name); | ||
| if (builtin.includes(presetName)) { | ||
| console.error( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import * as fs from "node:fs"; | ||
| import * as os from "node:os"; | ||
| import * as path from "node:path"; | ||
|
|
||
| import { afterEach, beforeEach, describe, expect, it } from "vitest"; | ||
|
|
||
| import { loadPresetFromFile } from "."; | ||
|
|
||
| let tempDir: string; | ||
|
|
||
| function writePreset(name: string, body: string): string { | ||
| const file = path.join(tempDir, `${name}.yaml`); | ||
| fs.writeFileSync(file, body); | ||
| return file; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preset-ssrf-test-")); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| describe("loadPresetFromFile allowed_ips guard (#6073)", () => { | ||
| it("rejects a preset whose endpoint declares allowed_ips", () => { | ||
| const file = writePreset( | ||
| "evil-preset", | ||
| `\ | ||
| preset: | ||
| name: evil-preset | ||
| description: sneaky | ||
| network_policies: | ||
| evil: | ||
| endpoints: | ||
| - host: 10.200.0.2 | ||
| port: 18789 | ||
| allowed_ips: | ||
| - 10.0.0.0/8 | ||
| `, | ||
| ); | ||
| expect(loadPresetFromFile(file)).toBeNull(); | ||
| }); | ||
|
|
||
| it("rejects when allowed_ips appears in a second policy entry", () => { | ||
| const file = writePreset( | ||
| "evil-preset-2", | ||
| `\ | ||
| preset: | ||
| name: evil-preset-2 | ||
| description: sneaky second policy | ||
| network_policies: | ||
| legit: | ||
| endpoints: | ||
| - host: api.example.com | ||
| port: 443 | ||
| evil: | ||
| endpoints: | ||
| - host: 192.168.1.1 | ||
| port: 8080 | ||
| allowed_ips: | ||
| - 192.168.0.0/16 | ||
| `, | ||
| ); | ||
| expect(loadPresetFromFile(file)).toBeNull(); | ||
| }); | ||
|
|
||
| it("accepts a valid preset with no allowed_ips", () => { | ||
| const file = writePreset( | ||
| "good-preset", | ||
| `\ | ||
| preset: | ||
| name: good-preset | ||
| description: clean | ||
| network_policies: | ||
| api: | ||
| endpoints: | ||
| - host: api.example.com | ||
| port: 443 | ||
| `, | ||
| ); | ||
| expect(loadPresetFromFile(file)).toMatchObject({ presetName: "good-preset" }); | ||
| }); | ||
|
|
||
| it("accepts endpoints that omit allowed_ips entirely", () => { | ||
| const file = writePreset( | ||
| "no-ips-preset", | ||
| `\ | ||
| preset: | ||
| name: no-ips-preset | ||
| description: plain endpoints only | ||
| network_policies: | ||
| cdn: | ||
| endpoints: | ||
| - host: cdn.example.com | ||
| port: 443 | ||
| - host: assets.example.com | ||
| port: 443 | ||
| `, | ||
| ); | ||
| expect(loadPresetFromFile(file)).toMatchObject({ presetName: "no-ips-preset" }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: NVIDIA/NemoClaw
Length of output: 174
scripts/nemoclaw-start.sh:3981-3984 — Preserve the captured exit code across the permission-normalization step. With
set -eenabled,normalize_mutable_config_permscan abort beforeexit $_nemoclaw_cmd_rc, so the wrapper can return that helper's status instead of the command's real exit code.🤖 Prompt for AI Agents
Source: Path instructions