Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions src/lib/onboard/inference-providers/hermes.test.ts
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 thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}));

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();
});
});
14 changes: 14 additions & 0 deletions src/lib/onboard/inference-providers/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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.`,
);
}
}
const {
runOpenshell,
upsertProvider: _upsertProvider, // intentionally unused; matches inline branch
Expand Down
14 changes: 14 additions & 0 deletions src/lib/policy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
const builtin = listPresets().map((p) => p.name);
if (builtin.includes(presetName)) {
console.error(
Expand Down
106 changes: 106 additions & 0 deletions src/lib/policy/preset-allowed-ips.test.ts
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" });
});
});
Loading