Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
270 changes: 270 additions & 0 deletions src/lib/onboard/inference-providers/hermes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// 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";

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,
};
}

function makeArgs(endpointUrl: string | null) {
return {
sandboxName: "alpha",
model: "m",
provider: "p",
endpointUrl,
credentialEnv: null,
hermesAuthMethod: null,
hermesToolGateways: [] as string[],
};
}

function publicLookup() {
return vi.fn(async () => [{ address: "8.8.8.8", family: 4 }]);
}

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 without leaking the raw value", async () => {
await expect(
setupHermesProviderInference(
{
sandboxName: "alpha",
model: "m",
provider: "p",
endpointUrl: "not-a-url",
credentialEnv: null,
hermesAuthMethod: null,
hermesToolGateways: [],
},
makeDeps() as never,
),
).rejects.toThrow(/valid URL/);
});

it("rejects unsupported scheme", async () => {
await expect(
setupHermesProviderInference(
{
sandboxName: "alpha",
model: "m",
provider: "p",
endpointUrl: "ftp://example.com/v1",
credentialEnv: null,
hermesAuthMethod: null,
hermesToolGateways: [],
},
makeDeps() as never,
),
).rejects.toThrow(/unsupported scheme/);
});

it("rejects URL with embedded credentials", async () => {
await expect(
setupHermesProviderInference(
{
sandboxName: "alpha",
model: "m",
provider: "p",
endpointUrl: "https://user:secret@example.com/v1",
credentialEnv: null,
hermesAuthMethod: null,
hermesToolGateways: [],
},
makeDeps() as never,
),
).rejects.toThrow(/credentials/);
});

it("does not call runOpenshell when endpoint is rejected", async () => {
const deps = makeDeps();
await expect(
setupHermesProviderInference(
{
sandboxName: "alpha",
model: "m",
provider: "p",
endpointUrl: "ftp://example.com/v1",
credentialEnv: null,
hermesAuthMethod: null,
hermesToolGateways: [],
},
deps as never,
),
).rejects.toThrow();
expect(deps.runOpenshell).not.toHaveBeenCalled();
});

it("accepts a public HTTPS endpoint (#6072)", async () => {
const deps = makeDeps({ lookup: publicLookup() });
await expect(
setupHermesProviderInference(makeArgs("https://integrate.api.nvidia.com/v1"), deps as never),
).resolves.toEqual({ ok: true });
expect(deps.runOpenshell).toHaveBeenCalled();
});

it("accepts a public hostname that resolves to a public IP (#6073)", async () => {
const deps = makeDeps({
lookup: vi.fn(async () => [{ address: "8.8.8.8", family: 4 }]),
});
await expect(
setupHermesProviderInference(makeArgs("https://api.public.example.test/v1"), deps as never),
).resolves.toEqual({ ok: true });
expect(deps.runOpenshell).toHaveBeenCalled();
});

it("rejects a public hostname that resolves to a private IP (DNS rebinding) (#6073)", async () => {
const deps = makeDeps({
lookup: vi.fn(async () => [{ address: "10.0.0.5", family: 4 }]),
});
await expect(
setupHermesProviderInference(
makeArgs("https://public-looking.example.test/v1"),
deps as never,
),
).rejects.toThrow(/private or internal/);
expect(deps.runOpenshell).not.toHaveBeenCalled();
});

it("skips SSRF check when endpointUrl is null (#6072)", async () => {
const deps = makeDeps();
await expect(setupHermesProviderInference(makeArgs(null), deps as never)).resolves.toEqual({
ok: true,
});
expect(deps.runOpenshell).toHaveBeenCalled();
});

it.each([
"http://127.0.0.1/v1",
"http://169.254.169.254/latest/meta-data/",
"http://10.0.0.1/v1",
"http://192.168.1.1/v1",
"http://172.16.0.1/v1",
"http://[::1]/v1",
"http://[fc00::1]/v1",
"http://localhost/v1",
"http://foo.internal/v1",
"http://foo.local/v1",
])("rejects private/reserved endpoint %s (#6073)", async (endpointUrl) => {
const deps = makeDeps();
await expect(
setupHermesProviderInference(makeArgs(endpointUrl), deps as never),
).rejects.toThrow(/private or internal/);
expect(deps.runOpenshell).not.toHaveBeenCalled();
});
});
43 changes: 40 additions & 3 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 { rewriteConfigUrlsWithDnsPinning } from "../../sandbox/config";
import type { HermesDeps, SetupInferenceResult } from "./types";

export async function setupHermesProviderInference(
Expand All @@ -28,6 +29,42 @@ export async function setupHermesProviderInference(
hermesAuthMethod,
hermesToolGateways,
} = args;
// A null/absent endpointUrl is intentionally accepted: the Hermes
// managed/OAuth path supplies the endpoint later (using the default managed
// inference route), so SSRF validation only applies to an explicitly-supplied
// custom endpoint.
let resolvedEndpointUrl = endpointUrl;
if (endpointUrl) {
let parsedEndpoint: URL;
try {
parsedEndpoint = new URL(endpointUrl);
} catch {
throw new Error(`Inference endpoint URL is not a valid URL.`);
}
if (parsedEndpoint.protocol !== "http:" && parsedEndpoint.protocol !== "https:") {
throw new Error(
`Inference endpoint URL uses an unsupported scheme. Only http and https are allowed.`,
);
}
if (parsedEndpoint.username || parsedEndpoint.password) {
throw new Error(
`Inference endpoint URL must not contain credentials. Remove the username and password from the URL.`,
);
}
// DNS-resolving + pinning validation closes the DNS-rebinding gap a
// string-only hostname check leaves open. For http this returns the
// pinned-IP URL; for https it returns the original hostname (preserving TLS
// SNI/cert validation).
try {
const validated = await rewriteConfigUrlsWithDnsPinning(endpointUrl, deps.lookup);
resolvedEndpointUrl = typeof validated === "string" ? validated : endpointUrl;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Inference endpoint URL points to a private or internal address, or could not be resolved: ${message}`,
);
}
}
const {
runOpenshell,
upsertProvider: _upsertProvider, // intentionally unused; matches inline branch
Expand Down Expand Up @@ -88,12 +125,12 @@ export async function setupHermesProviderInference(
? await hermesProviderAuth.ensureHermesProviderApiKeyCredentials(targetSandbox, {
apiKey: resolveHermesNousApiKey(),
runOpenshell,
baseUrl: endpointUrl || undefined,
baseUrl: resolvedEndpointUrl || undefined,
})
: await hermesProviderAuth.ensureHermesProviderOAuthCredentials(targetSandbox, {
allowInteractiveLogin: !isNonInteractive(),
runOpenshell,
baseUrl: endpointUrl || undefined,
baseUrl: resolvedEndpointUrl || undefined,
toolGatewayPresets: hermesToolGateways,
});
if (!state) {
Expand Down Expand Up @@ -129,7 +166,7 @@ export async function setupHermesProviderInference(
}

verifyInferenceRoute(provider, model);
verifyOnboardInferenceSmoke({ provider, model, endpointUrl, credentialEnv });
verifyOnboardInferenceSmoke({ provider, model, endpointUrl: resolvedEndpointUrl, credentialEnv });
if (sandboxName) {
registry.updateSandbox(sandboxName, { model, provider });
}
Expand Down
10 changes: 10 additions & 0 deletions src/lib/onboard/inference-providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,17 @@ export type RemoteProviderDeps = CommonDeps & {
};
};

// DNS lookup signature compatible with `dnsPromises.lookup(host, { all: true })`
// and with the injectable `lookup` accepted by
// `rewriteConfigUrlsWithDnsPinning` in `../../sandbox/config`. Real callers omit
// it (defaulting to real DNS); tests inject a mock.
export type LookupFn = (
hostname: string,
options: { all: true },
) => Promise<Array<{ address: string; family?: number }>>;

export type HermesDeps = CommonDeps & {
lookup?: LookupFn;
hermesProviderAuth: {
HERMES_PROVIDER_NAME: string;
isHermesProviderRegistered(runOpenshell: any): boolean;
Expand Down
Loading
Loading