mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(agents): scope custom provider baseUrl SSRF trust by origin (#80751)
* fix(agents): scope provider SSRF trust by origin * fix(provider): preserve explicit private-network deny * docs(provider): document exact-origin SSRF trust * test(provider): cover exact-origin SSRF edges * docs(provider): align local model private-origin guidance * refactor(ssrf): keep policy merging in infra * test(ssrf): cover exact-origin trust through guard * test(ssrf): block sibling private-origin redirects * fix(provider): keep loopback trust origin-scoped * fix(provider): block metadata origin trust * fix(ssrf): keep metadata rebinding blocked * fix(ssrf): block cloud metadata origins * fix(ssrf): block ipv6 metadata origins * fix(ssrf): block embedded metadata origins * test(ssrf): cover embedded link-local metadata * test(provider): cover custom anthropic proxy classification * test(provider): widen transport policy mock * test(plugin-sdk): assert metadata-IP allowedOrigins entries are rejected Plugin authors can construct an SsrFPolicy that lists any well-formed http(s) origin in allowedOrigins. The abuse-resistance lives one layer deeper, in resolvePinnedHostnameWithPolicy's metadata/link-local block. Add an SDK-level smoke test asserting that contract directly: - AWS/Alibaba IMDS IPv4 literals, GCP metadata canonical hostname, IPv6 ULA metadata literal, and non-metadata link-local IPv4 entries build a policy via ssrfPolicyFromHttpBaseUrlAllowedOrigin and are then rejected at resolvePinnedHostnameWithPolicy. - DNS rebinding from a trusted private DNS origin to a metadata IP is rejected even when the request hostname is origin-trusted. This would fail if the SDK helper or resolveSsrFPolicyForUrl ever short-circuited past the metadata block. * chore(docs): regenerate baselines after upstream rebase upstream/main moved between rebases; the merged source state for the PR's `src/config/schema.help.ts` change and the upstream plugin-sdk surface changes both produce different hashes than the committed baselines, so `config:docs:check` and `plugin-sdk:api:check` would fail. Regenerated via `pnpm config:docs:gen` + `pnpm plugin-sdk:api:gen` on Crabbox; both baselines verified with their respective `--check` generators. * test(plugin-sdk): assert SSRF blocked error class * fix(lint): satisfy exact-origin PR lint rules * docs: clarify custom provider origin trust * chore(docs): refresh plugin sdk api baseline --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/providers: trust the exact configured custom/local provider `baseUrl` origin for guarded model HTTP requests, so loopback, LAN, tailnet, and private DNS endpoints work without broad private-network access while different ports and metadata/link-local pivots remain blocked. Fixes #80732. (#80751) Thanks @Kaspre and @msitarzewski.
|
||||
- Bind shell script operands after combined options [AI]. (#81882) Thanks @pgondhi987.
|
||||
- fix(canvas): validate snapshot response formats [AI]. (#81881) Thanks @pgondhi987.
|
||||
- Constrain provider catalog entry paths [AI]. (#81884) Thanks @pgondhi987.
|
||||
|
||||
@@ -689,7 +689,7 @@ Example (OpenAI-compatible):
|
||||
- For OpenAI-compatible Completions proxies that need vendor-specific fields, set `agents.defaults.models["provider/model"].params.extra_body` (or `extraBody`) to merge extra JSON into the outbound request body.
|
||||
- For vLLM chat-template controls, set `agents.defaults.models["provider/model"].params.chat_template_kwargs`. The bundled vLLM plugin automatically sends `enable_thinking: false` and `force_nonempty_content: true` for `vllm/nemotron-3-*` when the session thinking level is off.
|
||||
- For slow local models or remote LAN/tailnet hosts, set `models.providers.<id>.timeoutSeconds`. This extends provider model HTTP request handling, including connect, headers, body streaming, and the total guarded-fetch abort, without increasing the whole agent runtime timeout.
|
||||
- Model provider HTTP calls allow Surge, Clash, and sing-box fake-IP DNS answers in `198.18.0.0/15` and `fc00::/7` only for the configured provider `baseUrl` hostname. Other private, loopback, link-local, and metadata destinations still require an explicit `models.providers.<id>.request.allowPrivateNetwork: true` opt-in.
|
||||
- Model provider HTTP calls allow Surge, Clash, and sing-box fake-IP DNS answers in `198.18.0.0/15` and `fc00::/7` only for the configured provider `baseUrl` hostname. Custom/local provider endpoints also trust that exact configured `scheme://host:port` origin for guarded model requests, including loopback, LAN, and tailnet hosts. This is not a new config option; the `baseUrl` you configure extends the request policy only for that origin. Fake-IP hostname allowance and exact-origin trust are independent mechanisms. Other private, loopback, link-local, metadata destinations, and different ports still require an explicit `models.providers.<id>.request.allowPrivateNetwork: true` opt-in. Set `models.providers.<id>.request.allowPrivateNetwork: false` to opt out of the exact-origin trust.
|
||||
- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
|
||||
- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
|
||||
- For `api: "anthropic-messages"` on non-direct endpoints (any provider other than canonical `anthropic`, or a custom `models.providers.anthropic.baseUrl` whose host is not a public `api.anthropic.com` endpoint), OpenClaw suppresses implicit Anthropic beta headers such as `claude-code-20250219`, `interleaved-thinking-2025-05-14`, and OAuth markers, so custom Anthropic-compatible proxies do not reject unsupported beta flags. Set `models.providers.<id>.headers["anthropic-beta"]` explicitly if your proxy needs specific beta features.
|
||||
|
||||
@@ -412,6 +412,8 @@ Experimental built-in tool flags. Default off unless a strict-agentic GPT-5 auto
|
||||
|
||||
OpenClaw uses the built-in model catalog. Add custom providers via `models.providers` in config or `~/.openclaw/agents/<agentId>/agent/models.json`.
|
||||
|
||||
Configuring a custom/local provider `baseUrl` is also the narrow network trust decision for model HTTP requests: OpenClaw allows that exact `scheme://host:port` origin through the guarded fetch path, without adding a separate config option or trusting other private origins.
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
@@ -487,7 +489,7 @@ OpenClaw uses the built-in model catalog. Add custom providers via `models.provi
|
||||
- `request.auth`: auth strategy override. Modes: `"provider-default"` (use provider's built-in auth), `"authorization-bearer"` (with `token`), `"header"` (with `headerName`, `value`, optional `prefix`).
|
||||
- `request.proxy`: HTTP proxy override. Modes: `"env-proxy"` (use `HTTP_PROXY`/`HTTPS_PROXY` env vars), `"explicit-proxy"` (with `url`). Both modes accept an optional `tls` sub-object.
|
||||
- `request.tls`: TLS override for direct connections. Fields: `ca`, `cert`, `key`, `passphrase` (all accept SecretRef), `serverName`, `insecureSkipVerify`.
|
||||
- `request.allowPrivateNetwork`: when `true`, allow HTTPS to `baseUrl` when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (operator opt-in for trusted self-hosted OpenAI-compatible endpoints). Loopback model-provider stream URLs such as `localhost`, `127.0.0.1`, and `[::1]` are allowed automatically unless this is explicitly set to `false`; LAN, tailnet, and private DNS hosts still require opt-in. WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
- `request.allowPrivateNetwork`: when `true`, allow model-provider HTTP requests to private, CGNAT, or similar ranges through the provider HTTP fetch guard. Custom/local provider base URLs already trust the exact configured origin, except metadata/link-local origins, which remain blocked without explicit opt-in. Set this to `false` to opt out of exact-origin trust. WebSocket uses the same `request` for headers/TLS but not that fetch SSRF gate. Default `false`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Model catalog entries">
|
||||
|
||||
@@ -173,9 +173,11 @@ endpoint and model ID:
|
||||
```
|
||||
|
||||
If `api` is omitted on a custom provider with a `baseUrl`, OpenClaw defaults to
|
||||
`openai-completions`. Loopback endpoints such as `127.0.0.1` are trusted
|
||||
automatically; LAN, tailnet, and private DNS endpoints still need
|
||||
`request.allowPrivateNetwork: true`.
|
||||
`openai-completions`. Custom/local provider entries trust their exact configured
|
||||
`baseUrl` origin for guarded model requests, including loopback, LAN, tailnet,
|
||||
and private DNS hosts. Requests to other private origins still need
|
||||
`request.allowPrivateNetwork: true`; metadata/link-local origins remain blocked
|
||||
without explicit opt-in. Set it to `false` to opt out of exact-origin trust.
|
||||
|
||||
The `models.providers.<id>.models[].id` value is provider-local. Do not
|
||||
include the provider prefix there. For example, an MLX server started with
|
||||
|
||||
@@ -94,7 +94,7 @@ This writes `models.providers.lmstudio` and sets the default model to
|
||||
`lmstudio:default` auth profile.
|
||||
|
||||
Interactive setup can prompt for an optional preferred load context length and applies it across the discovered LM Studio models it saves into config.
|
||||
LM Studio plugin config trusts the configured LM Studio endpoint for model requests, including loopback, LAN, and tailnet hosts. You can opt out by setting `models.providers.lmstudio.request.allowPrivateNetwork: false`.
|
||||
LM Studio plugin config trusts the configured LM Studio endpoint for model requests, including loopback, LAN, and tailnet hosts. Metadata/link-local origins still require explicit opt-in. You can opt out by setting `models.providers.lmstudio.request.allowPrivateNetwork: false`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -215,7 +215,7 @@ Use the LM Studio host's reachable address, keep `/v1`, and make sure LM Studio
|
||||
}
|
||||
```
|
||||
|
||||
Unlike generic OpenAI-compatible providers, `lmstudio` automatically trusts its configured local/private endpoint for guarded model requests. Custom loopback provider IDs such as `localhost` or `127.0.0.1` are also trusted automatically; for LAN, tailnet, or private DNS custom provider IDs, set `models.providers.<id>.request.allowPrivateNetwork: true` explicitly.
|
||||
`lmstudio` automatically trusts its configured local/private endpoint for guarded model requests. Custom/local OpenAI-compatible provider entries also trust their exact configured `baseUrl` origin, except metadata/link-local origins; requests to different private ports or destinations still require `models.providers.<id>.request.allowPrivateNetwork: true`. Set `models.providers.<id>.request.allowPrivateNetwork: false` to opt out of exact-origin trust.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ Use explicit config when:
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
apiKey: "${VLLM_API_KEY}",
|
||||
api: "openai-completions",
|
||||
request: { allowPrivateNetwork: true },
|
||||
timeoutSeconds: 300, // Optional: extend connect/header/body/request timeout for slow local models
|
||||
models: [
|
||||
{
|
||||
@@ -269,7 +268,6 @@ wildcard to the visible model catalog:
|
||||
baseUrl: "http://192.168.1.50:9000/v1",
|
||||
apiKey: "${VLLM_API_KEY}",
|
||||
api: "openai-completions",
|
||||
request: { allowPrivateNetwork: true },
|
||||
timeoutSeconds: 300,
|
||||
models: [
|
||||
{
|
||||
@@ -305,7 +303,6 @@ wildcard to the visible model catalog:
|
||||
baseUrl: "http://192.168.1.50:8000/v1",
|
||||
apiKey: "${VLLM_API_KEY}",
|
||||
api: "openai-completions",
|
||||
request: { allowPrivateNetwork: true },
|
||||
timeoutSeconds: 300,
|
||||
models: [{ id: "your-model-id", name: "Local vLLM Model" }],
|
||||
},
|
||||
@@ -329,10 +326,12 @@ wildcard to the visible model catalog:
|
||||
```
|
||||
|
||||
If you see a connection error, verify the host, port, and that vLLM started with the OpenAI-compatible server mode.
|
||||
For explicit loopback, LAN, or Tailscale endpoints, also set
|
||||
`models.providers.vllm.request.allowPrivateNetwork: true`; provider
|
||||
requests block private-network URLs by default unless the provider is
|
||||
explicitly trusted.
|
||||
For explicit loopback, LAN, or Tailscale endpoints, OpenClaw trusts the
|
||||
exact configured `models.providers.vllm.baseUrl` origin for guarded model
|
||||
requests. Metadata/link-local origins remain blocked without explicit
|
||||
opt-in. Set `models.providers.vllm.request.allowPrivateNetwork: true` only
|
||||
when vLLM requests must reach another private origin, and set it to `false`
|
||||
to opt out of exact-origin trust.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -522,6 +522,7 @@ describe("provider request config", () => {
|
||||
|
||||
expect(resolved.baseUrl).toBe("https://api.openai.com/v1");
|
||||
expect(resolved.allowPrivateNetwork).toBe(false);
|
||||
expect(resolved.privateNetworkExplicitlyDenied).toBe(false);
|
||||
expect(resolved.policy.endpointClass).toBe("openai-public");
|
||||
expect(resolved.capabilities.allowsResponsesStore).toBe(true);
|
||||
expect(resolved.headers?.authorization).toBe("Bearer test-key");
|
||||
@@ -531,7 +532,7 @@ describe("provider request config", () => {
|
||||
expect(resolved.headers?.["X-Custom"]).toBe("1");
|
||||
});
|
||||
|
||||
it("auto-allows loopback model-provider stream requests", () => {
|
||||
it("does not convert implicit loopback model requests into broad private-network trust", () => {
|
||||
const resolved = resolveProviderRequestPolicyConfig({
|
||||
provider: "local-agent-proxy",
|
||||
api: "openai-completions",
|
||||
@@ -540,7 +541,8 @@ describe("provider request config", () => {
|
||||
transport: "stream",
|
||||
});
|
||||
|
||||
expect(resolved.allowPrivateNetwork).toBe(true);
|
||||
expect(resolved.allowPrivateNetwork).toBe(false);
|
||||
expect(resolved.privateNetworkExplicitlyDenied).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps explicit private-network denial for loopback model requests", () => {
|
||||
@@ -554,6 +556,7 @@ describe("provider request config", () => {
|
||||
});
|
||||
|
||||
expect(resolved.allowPrivateNetwork).toBe(false);
|
||||
expect(resolved.privateNetworkExplicitlyDenied).toBe(true);
|
||||
});
|
||||
|
||||
it("does not auto-allow non-loopback private model-provider hosts", () => {
|
||||
@@ -566,5 +569,41 @@ describe("provider request config", () => {
|
||||
});
|
||||
|
||||
expect(resolved.allowPrivateNetwork).toBe(false);
|
||||
expect(resolved.privateNetworkExplicitlyDenied).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
provider: "lmstudio",
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
expectedEndpointClass: "local",
|
||||
},
|
||||
{
|
||||
provider: "vllm",
|
||||
baseUrl: "http://192.168.1.20:8000/v1",
|
||||
expectedEndpointClass: "custom",
|
||||
},
|
||||
{
|
||||
provider: "ollama",
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
expectedEndpointClass: "custom",
|
||||
},
|
||||
{
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "http://anthropic-proxy.lan:8080",
|
||||
expectedEndpointClass: "custom",
|
||||
},
|
||||
])("classifies $provider configured baseUrl as exact-origin trusted endpoint class", (entry) => {
|
||||
const resolved = resolveProviderRequestPolicyConfig({
|
||||
provider: entry.provider,
|
||||
api: entry.api ?? (entry.provider === "ollama" ? "ollama" : "openai-completions"),
|
||||
baseUrl: entry.baseUrl,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
|
||||
expect(resolved.policy.endpointClass).toBe(entry.expectedEndpointClass);
|
||||
expect(resolved.privateNetworkExplicitlyDenied).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
} from "../config/types.provider-request.js";
|
||||
import { assertSecretInputResolved } from "../config/types.secrets.js";
|
||||
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
|
||||
import { isLoopbackIpAddress } from "../shared/net/ip.js";
|
||||
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
||||
import type {
|
||||
ProviderRequestCapabilities,
|
||||
@@ -139,6 +138,7 @@ type ProviderRequestHeaderPrecedence = "caller-wins" | "defaults-win";
|
||||
|
||||
type ResolvedProviderRequestPolicyConfig = ResolvedProviderRequestConfig & {
|
||||
allowPrivateNetwork: boolean;
|
||||
privateNetworkExplicitlyDenied: boolean;
|
||||
capabilities: ProviderRequestCapabilities;
|
||||
};
|
||||
|
||||
@@ -167,28 +167,23 @@ type ResolveProviderRequestPolicyConfigParams = {
|
||||
request?: ModelProviderRequestTransportOverrides;
|
||||
};
|
||||
|
||||
function isLoopbackProviderBaseUrl(baseUrl: string | undefined): boolean {
|
||||
if (!baseUrl) {
|
||||
return false;
|
||||
function resolvePrivateNetworkAccess(params: ResolveProviderRequestPolicyConfigParams): {
|
||||
allowPrivateNetwork: boolean;
|
||||
explicitlyDenied: boolean;
|
||||
} {
|
||||
// Preserve existing precedence: runtime/caller policy overrides model config.
|
||||
const configuredAllowPrivateNetwork =
|
||||
params.allowPrivateNetwork ?? params.request?.allowPrivateNetwork;
|
||||
if (configuredAllowPrivateNetwork !== undefined) {
|
||||
return {
|
||||
allowPrivateNetwork: configuredAllowPrivateNetwork,
|
||||
explicitlyDenied: !configuredAllowPrivateNetwork,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return host === "localhost" || host.endsWith(".localhost") || isLoopbackIpAddress(host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldAutoAllowLoopbackModelRequest(
|
||||
params: ResolveProviderRequestPolicyConfigParams,
|
||||
): boolean {
|
||||
return (
|
||||
params.capability === "llm" &&
|
||||
params.transport === "stream" &&
|
||||
params.allowPrivateNetwork === undefined &&
|
||||
params.request?.allowPrivateNetwork === undefined &&
|
||||
isLoopbackProviderBaseUrl(params.baseUrl)
|
||||
);
|
||||
return {
|
||||
allowPrivateNetwork: false,
|
||||
explicitlyDenied: false,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeConfiguredRequestString(value: unknown, path: string): string | undefined {
|
||||
@@ -670,6 +665,7 @@ export function resolveProviderRequestPolicyConfig(
|
||||
params.precedence === "caller-wins"
|
||||
? mergeProviderRequestHeaders(mergedDefaults, unprotectedCallerHeaders)
|
||||
: mergeProviderRequestHeaders(unprotectedCallerHeaders, mergedDefaults);
|
||||
const privateNetworkAccess = resolvePrivateNetworkAccess(params);
|
||||
|
||||
return {
|
||||
api: params.api,
|
||||
@@ -684,10 +680,8 @@ export function resolveProviderRequestPolicyConfig(
|
||||
tls: resolveTlsOverride(params.request?.tls),
|
||||
policy,
|
||||
capabilities,
|
||||
allowPrivateNetwork:
|
||||
params.allowPrivateNetwork ??
|
||||
params.request?.allowPrivateNetwork ??
|
||||
shouldAutoAllowLoopbackModelRequest(params),
|
||||
allowPrivateNetwork: privateNetworkAccess.allowPrivateNetwork,
|
||||
privateNetworkExplicitlyDenied: privateNetworkAccess.explicitlyDenied,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,14 @@ import { Stream } from "openai/streaming";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGuardedModelFetch } from "./provider-transport-fetch.js";
|
||||
|
||||
type ProviderRequestPolicyConfigMockResult = {
|
||||
allowPrivateNetwork: boolean;
|
||||
privateNetworkExplicitlyDenied?: boolean;
|
||||
policy?: {
|
||||
endpointClass?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const {
|
||||
buildProviderRequestDispatcherPolicyMock,
|
||||
fetchWithSsrFGuardMock,
|
||||
@@ -21,7 +29,11 @@ const {
|
||||
...current,
|
||||
...overrides,
|
||||
})),
|
||||
resolveProviderRequestPolicyConfigMock: vi.fn(() => ({ allowPrivateNetwork: false })),
|
||||
resolveProviderRequestPolicyConfigMock: vi.fn<() => ProviderRequestPolicyConfigMockResult>(
|
||||
() => ({
|
||||
allowPrivateNetwork: false,
|
||||
}),
|
||||
),
|
||||
shouldUseEnvHttpProxyForUrlMock: vi.fn(() => false),
|
||||
withTrustedEnvProxyGuardedFetchModeMock: vi.fn((params: Record<string, unknown>) => ({
|
||||
...params,
|
||||
@@ -239,8 +251,204 @@ describe("buildGuardedModelFetch", () => {
|
||||
expect(policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges explicit private-network opt-in into the provider-host fake-IP policy", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({ allowPrivateNetwork: true });
|
||||
it("trusts exact configured custom provider hosts without broad private-network opt-in", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://10.0.0.5:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("http://10.0.0.5:1234/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toEqual({
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
});
|
||||
expect(policy?.allowPrivateNetwork).toBeUndefined();
|
||||
expect(policy?.dangerouslyAllowPrivateNetwork).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trusts exact configured HTTPS custom provider origins", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "custom-vllm",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://10.0.0.5:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("https://10.0.0.5:1234/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toEqual({
|
||||
allowedOrigins: ["https://10.0.0.5:1234"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit private-network denial ahead of configured custom origin trust", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
privateNetworkExplicitlyDenied: true,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://10.0.0.5:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("http://10.0.0.5:1234/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trusts exact configured local provider origins", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "local" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://127.0.0.1:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("http://127.0.0.1:1234/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toEqual({
|
||||
allowedOrigins: ["http://127.0.0.1:1234"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trust a configured provider host on a different port", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://10.0.0.5:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("http://10.0.0.5:4321/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not add exact-origin trust for non-custom provider endpoints", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "openai-public" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "openai",
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://10.0.0.5:1234/v1",
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher("http://10.0.0.5:1234/v1/chat/completions", { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "link-local metadata IP",
|
||||
baseUrl: "http://169.254.169.254/v1",
|
||||
requestUrl: "http://169.254.169.254/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "legacy link-local metadata IP",
|
||||
baseUrl: "http://2852039166/v1",
|
||||
requestUrl: "http://2852039166/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "embedded IPv6 link-local metadata IP",
|
||||
baseUrl: "http://[64:ff9b::a9fe:a9fe]/v1",
|
||||
requestUrl: "http://[64:ff9b::a9fe:a9fe]/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "non-link-local cloud metadata IP",
|
||||
baseUrl: "http://100.100.100.200/v1",
|
||||
requestUrl: "http://100.100.100.200/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "IPv6 cloud metadata IP",
|
||||
baseUrl: "http://[fd00:ec2::254]/v1",
|
||||
requestUrl: "http://[fd00:ec2::254]/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "embedded IPv6 cloud metadata IP",
|
||||
baseUrl: "http://[64:ff9b::6464:64c8]/v1",
|
||||
requestUrl: "http://[64:ff9b::6464:64c8]/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "metadata hostname",
|
||||
baseUrl: "http://metadata.google.internal/v1",
|
||||
requestUrl: "http://metadata.google.internal/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "metadata short hostname",
|
||||
baseUrl: "http://metadata/v1",
|
||||
requestUrl: "http://metadata/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "metadata compound hostname",
|
||||
baseUrl: "http://metadata-server.example/v1",
|
||||
requestUrl: "http://metadata-server.example/v1/chat/completions",
|
||||
},
|
||||
{
|
||||
label: "cloud instance-data hostname",
|
||||
baseUrl: "http://instance-data.ec2.internal/v1",
|
||||
requestUrl: "http://instance-data.ec2.internal/v1/chat/completions",
|
||||
},
|
||||
])("does not add implicit exact-origin trust for $label", async (entry) => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: false,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "custom-metadata",
|
||||
api: "openai-completions",
|
||||
baseUrl: entry.baseUrl,
|
||||
} as unknown as Model<"openai-completions">;
|
||||
|
||||
const fetcher = buildGuardedModelFetch(model);
|
||||
await fetcher(entry.requestUrl, { method: "POST" });
|
||||
|
||||
const policy = fetchWithSsrFGuardMock.mock.calls[0]?.[0]?.policy;
|
||||
expect(policy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("merges explicit private-network opt-in into the provider-host policies", async () => {
|
||||
resolveProviderRequestPolicyConfigMock.mockReturnValueOnce({
|
||||
allowPrivateNetwork: true,
|
||||
policy: { endpointClass: "custom" },
|
||||
});
|
||||
const model = {
|
||||
id: "qwen3:32b",
|
||||
provider: "ollama",
|
||||
@@ -253,9 +461,7 @@ describe("buildGuardedModelFetch", () => {
|
||||
|
||||
const policy = latestGuardedFetchParams().policy;
|
||||
expect(policy).toEqual({
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
allowIpv6UniqueLocalRange: true,
|
||||
hostnameAllowlist: ["10.0.0.5"],
|
||||
allowedOrigins: ["http://10.0.0.5:11434"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,18 @@ import {
|
||||
} from "../infra/net/fetch-guard.js";
|
||||
import { shouldUseEnvHttpProxyForUrl } from "../infra/net/proxy-env.js";
|
||||
import {
|
||||
mergeSsrFPolicies,
|
||||
ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
type SsrFPolicy,
|
||||
} from "../infra/net/ssrf.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { resolveDebugProxySettings } from "../proxy-capture/env.js";
|
||||
import {
|
||||
isCloudMetadataIpAddress,
|
||||
isLinkLocalIpAddress,
|
||||
parseCanonicalIpAddress,
|
||||
} from "../shared/net/ip.js";
|
||||
import { emitModelTransportDebug } from "./model-transport-debug.js";
|
||||
import { formatModelTransportDebugUrl } from "./model-transport-url.js";
|
||||
import {
|
||||
@@ -25,6 +32,7 @@ import {
|
||||
|
||||
const DEFAULT_MAX_SDK_RETRY_WAIT_SECONDS = 60;
|
||||
const log = createSubsystemLogger("provider-transport-fetch");
|
||||
const BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS = new Set(["instance-data"]);
|
||||
|
||||
function hasReadableSseData(block: string): boolean {
|
||||
const dataLines = block
|
||||
@@ -392,7 +400,7 @@ export function resolveModelRequestTimeoutMs(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveHttpHostname(value: unknown): string | undefined {
|
||||
function resolveHttpOrigin(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -401,33 +409,87 @@ function resolveHttpHostname(value: unknown): string | undefined {
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
return parsed.hostname.toLowerCase();
|
||||
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||
return parsed.origin.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProviderOriginHostname(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = parsed.hostname.trim().toLowerCase().replace(/\.+$/, "");
|
||||
return normalized || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function canImplicitlyTrustConfiguredBaseUrlOrigin(value: unknown): value is string {
|
||||
const hostname = normalizeProviderOriginHostname(value);
|
||||
if (!hostname) {
|
||||
return false;
|
||||
}
|
||||
const labels = hostname.split(".").filter(Boolean);
|
||||
return (
|
||||
!labels.some(
|
||||
(label) =>
|
||||
label.includes("metadata") || BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS.has(label),
|
||||
) &&
|
||||
!isLinkLocalIpAddress(hostname) &&
|
||||
!isCloudMetadataIpAddress(hostname)
|
||||
);
|
||||
}
|
||||
|
||||
function canApplyFakeIpHostnamePolicy(value: unknown): value is string {
|
||||
const hostname = normalizeProviderOriginHostname(value);
|
||||
if (!hostname) {
|
||||
return false;
|
||||
}
|
||||
const labels = hostname.split(".").filter(Boolean);
|
||||
return (
|
||||
!labels.some(
|
||||
(label) =>
|
||||
label.includes("metadata") || BLOCKED_EXACT_ORIGIN_TRUST_HOSTNAME_LABELS.has(label),
|
||||
) && !parseCanonicalIpAddress(hostname)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveModelTransportSsrFPolicy(params: {
|
||||
model: Model<Api>;
|
||||
url: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
trustConfiguredBaseUrlOrigin?: boolean;
|
||||
}): SsrFPolicy | undefined {
|
||||
const baseUrl = (params.model as { baseUrl?: unknown }).baseUrl;
|
||||
const baseHostname = resolveHttpHostname(baseUrl);
|
||||
const requestHostname = resolveHttpHostname(params.url);
|
||||
const baseOrigin = resolveHttpOrigin(baseUrl);
|
||||
const requestOrigin = resolveHttpOrigin(params.url);
|
||||
const requestMatchesBaseOrigin =
|
||||
typeof baseUrl === "string" && Boolean(baseOrigin) && requestOrigin === baseOrigin;
|
||||
const baseUrlOriginPolicy =
|
||||
requestMatchesBaseOrigin &&
|
||||
params.trustConfiguredBaseUrlOrigin &&
|
||||
canImplicitlyTrustConfiguredBaseUrlOrigin(baseUrl)
|
||||
? ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl)
|
||||
: undefined;
|
||||
// Fake-IP trust is hostname-scoped and orthogonal to exact-origin private-IP trust.
|
||||
// It is for DNS hostnames only and does not allow literal private IPs by itself.
|
||||
const fakeIpPolicy =
|
||||
typeof baseUrl === "string" && baseHostname && requestHostname === baseHostname
|
||||
requestMatchesBaseOrigin && canApplyFakeIpHostnamePolicy(baseUrl)
|
||||
? ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist(baseUrl)
|
||||
: undefined;
|
||||
|
||||
if (fakeIpPolicy) {
|
||||
return {
|
||||
...fakeIpPolicy,
|
||||
...(params.allowPrivateNetwork ? { allowPrivateNetwork: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
return mergeSsrFPolicies(
|
||||
baseUrlOriginPolicy,
|
||||
fakeIpPolicy,
|
||||
params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildGuardedModelFetch(
|
||||
@@ -472,6 +534,12 @@ export function buildGuardedModelFetch(
|
||||
model,
|
||||
url,
|
||||
allowPrivateNetwork: requestConfig.allowPrivateNetwork,
|
||||
// Only operator-configured custom/local endpoints get exact-origin trust;
|
||||
// known public/native providers keep the default rebinding checks.
|
||||
trustConfiguredBaseUrlOrigin:
|
||||
!requestConfig.privateNetworkExplicitlyDenied &&
|
||||
(requestConfig.policy?.endpointClass === "custom" ||
|
||||
requestConfig.policy?.endpointClass === "local"),
|
||||
});
|
||||
const requestInit =
|
||||
request &&
|
||||
|
||||
@@ -1023,7 +1023,7 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"models.providers.*.request.tls.insecureSkipVerify":
|
||||
"Skips upstream TLS certificate verification. Use only for controlled development environments.",
|
||||
"models.providers.*.request.allowPrivateNetwork":
|
||||
"When true, allow HTTPS to the model base URL when DNS resolves to private, CGNAT, or similar ranges, via the provider HTTP fetch guard (fetchWithSsrFGuard). OpenAI Responses WebSocket reuses request for headers/TLS but does not use that fetch SSRF path. Use only for operator-controlled self-hosted OpenAI-compatible endpoints (LAN, overlay, split DNS). Default is false.",
|
||||
"When true, allow model-provider HTTP requests to private, CGNAT, or similar ranges through the provider HTTP fetch guard (fetchWithSsrFGuard). Custom/local provider base URLs already trust the exact configured origin, except metadata/link-local origins; set this to false to opt out of that trust. OpenAI Responses WebSocket reuses request for headers/TLS but does not use that fetch SSRF path. Use true only for operator-controlled self-hosted endpoints that must reach private origins outside the configured baseUrl origin.",
|
||||
"models.providers.*.models":
|
||||
"Declared model list for a provider including identifiers, metadata, provider-specific params, and optional compatibility/cost hints. Keep IDs exact to provider catalog values so selection and fallback resolve correctly.",
|
||||
"models.providers.*.models[].agentRuntime":
|
||||
|
||||
@@ -629,6 +629,141 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not carry exact-origin trust across private-host redirects to another port", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:11435/"));
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://127.0.0.1:11434/start",
|
||||
fetchImpl,
|
||||
policy: { allowedOrigins: ["http://127.0.0.1:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not carry exact-origin trust across redirects to a different private host", async () => {
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://10.0.0.6:11434/"));
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://10.0.0.5:11434/start",
|
||||
fetchImpl,
|
||||
policy: { allowedOrigins: ["http://10.0.0.5:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a configured private DNS origin and blocks the same host on another port", async () => {
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "10.0.0.5", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
const result = await fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
await result.release();
|
||||
|
||||
const blockedFetchImpl = vi.fn(async () => okResponse());
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11435/v1/models",
|
||||
fetchImpl: blockedFetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(blockedFetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks exact-origin private DNS when it resolves to link-local metadata IPs", async () => {
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "169.254.169.254", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks exact-origin private DNS when it resolves to embedded IPv6 link-local metadata IPs", async () => {
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "64:ff9b::a9fe:a9fe", family: 6 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks exact-origin private DNS when it resolves to non-link-local metadata IPs", async () => {
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "100.100.100.200", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks exact-origin private DNS when it resolves to IPv6 cloud metadata IPs", async () => {
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "fd00:ec2::254", family: 6 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://model.lan:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["http://model.lan:11434"] },
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a configured IPv6 unique-local exact origin through the guard", async () => {
|
||||
const fetchImpl = vi.fn(async () => okResponse());
|
||||
|
||||
const result = await fetchWithSsrFGuard({
|
||||
url: "http://[fd00::1]:11434/v1/models",
|
||||
fetchImpl,
|
||||
policy: { allowedOrigins: ["http://[fd00::1]:11434"] },
|
||||
});
|
||||
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
await result.release();
|
||||
});
|
||||
|
||||
it("enforces hostname allowlist policies", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
@@ -1460,6 +1595,33 @@ describe("fetchWithSsrFGuard hardening", () => {
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not use target origin trust to allow a private explicit proxy", async () => {
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: agentCtor,
|
||||
EnvHttpProxyAgent: envHttpProxyAgentCtor,
|
||||
ProxyAgent: proxyAgentCtor,
|
||||
fetch: vi.fn(async () => okResponse()),
|
||||
};
|
||||
const lookupFn: LookupFn = vi.fn(async () => [
|
||||
{ address: "10.0.0.5", family: 4 },
|
||||
]) as unknown as LookupFn;
|
||||
const fetchImpl = vi.fn();
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "https://10.0.0.5:11434/v1/models",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { allowedOrigins: ["https://10.0.0.5:11434"] },
|
||||
dispatcherPolicy: {
|
||||
mode: "explicit-proxy",
|
||||
proxyUrl: "http://10.0.0.5:7890",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to DNS pinning in trusted proxy mode when no proxy env var is configured", async () => {
|
||||
clearProxyEnv();
|
||||
const lookupFn = createPublicLookup();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
assertHostnameAllowedWithPolicy,
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolveSsrFPolicyForUrl,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
type PinnedDispatcherPolicy,
|
||||
@@ -395,6 +396,8 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
}
|
||||
|
||||
let dispatcher: Dispatcher | null = null;
|
||||
// Resolve inside the redirect loop so exact-origin trust never carries across origins.
|
||||
const policyForUrl = resolveSsrFPolicyForUrl(parsedUrl, params.policy);
|
||||
try {
|
||||
const usesTrustedExplicitProxyMode =
|
||||
mode === GUARDED_FETCH_MODE.TRUSTED_EXPLICIT_PROXY &&
|
||||
@@ -415,7 +418,7 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
// Trusted env-proxy and pinDns=false can skip local DNS pinning, so keep
|
||||
// the pre-DNS hostname/IP policy checks from the pinned path.
|
||||
if (canUseTrustedEnvProxy || params.pinDns === false) {
|
||||
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
|
||||
assertHostnameAllowedWithPolicy(parsedUrl.hostname, policyForUrl);
|
||||
}
|
||||
|
||||
if (canUseTrustedEnvProxy) {
|
||||
@@ -423,29 +426,29 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
} else if (canUseManagedProxy) {
|
||||
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
policy: policyForUrl,
|
||||
});
|
||||
dispatcher = createHttp1EnvHttpProxyAgent(undefined, timeoutMs);
|
||||
} else if (usesTrustedExplicitProxyMode) {
|
||||
// Explicit proxy targets are still checked against the caller's hostname
|
||||
// policy, but the proxy does the DNS resolution for the final target.
|
||||
assertHostnameAllowedWithPolicy(parsedUrl.hostname, params.policy);
|
||||
assertHostnameAllowedWithPolicy(parsedUrl.hostname, policyForUrl);
|
||||
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
|
||||
} else if (params.pinDns === false) {
|
||||
await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
policy: policyForUrl,
|
||||
});
|
||||
dispatcher = createPolicyDispatcherWithoutPinnedDns(params.dispatcherPolicy, timeoutMs);
|
||||
} else {
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
policy: policyForUrl,
|
||||
});
|
||||
dispatcher = createPinnedDispatcher(
|
||||
pinned,
|
||||
params.dispatcherPolicy,
|
||||
params.policy,
|
||||
policyForUrl,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateIpAddress,
|
||||
isSameSsrFPolicy,
|
||||
resolveSsrFPolicyForUrl,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist,
|
||||
} from "./ssrf.js";
|
||||
|
||||
@@ -100,6 +102,10 @@ const httpBaseUrlPolicyBuilders = [
|
||||
name: "ssrfPolicyFromHttpBaseUrlAllowedHostname",
|
||||
build: ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
},
|
||||
{
|
||||
name: "ssrfPolicyFromHttpBaseUrlAllowedOrigin",
|
||||
build: ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
},
|
||||
{
|
||||
name: "ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist",
|
||||
build: ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist,
|
||||
@@ -142,6 +148,102 @@ describe("ssrfPolicyFromHttpBaseUrlAllowedHostname", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssrfPolicyFromHttpBaseUrlAllowedOrigin", () => {
|
||||
it("builds an allowed-origin policy from HTTP base URLs", () => {
|
||||
expect(ssrfPolicyFromHttpBaseUrlAllowedOrigin(" http://10.0.0.5:1234/v1 ")).toEqual({
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
});
|
||||
expect(
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin("https://api.example.com/v1?token=redacted"),
|
||||
).toEqual({
|
||||
allowedOrigins: ["https://api.example.com"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSsrFPolicyForUrl", () => {
|
||||
it("returns missing and originless policies unchanged", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("https://api.example.com/v1"), undefined),
|
||||
).toBeUndefined();
|
||||
const policy = { allowedOrigins: [], hostnameAllowlist: ["api.example.com"] };
|
||||
expect(resolveSsrFPolicyForUrl(new URL("https://api.example.com/v1"), policy)).toBe(policy);
|
||||
});
|
||||
|
||||
it("converts matching allowed origins into per-request hostname trust", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://10.0.0.5:1234/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
allowedHostnames: ["10.0.0.5"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes allowed origin case, path, query, and default ports before trusting hosts", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("https://api.example.com:443/v1/chat/completions"), {
|
||||
allowedOrigins: ["https://API.EXAMPLE.com:443/base?debug=1"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["https://API.EXAMPLE.com:443/base?debug=1"],
|
||||
allowedHostnames: ["api.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes trailing hostname dots before trusting hosts", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://example.com:11434/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://example.com.:11434/v1"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://example.com.:11434/v1"],
|
||||
allowedHostnames: ["example.com"],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://example.com.:11434/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://example.com:11434/v1"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://example.com:11434/v1"],
|
||||
allowedHostnames: ["example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trust the hostname when the port differs", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://10.0.0.5:4321/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports IPv6 origins when the exact origin matches", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://[fd00::1]:11434/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://[fd00::1]:11434"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://[fd00::1]:11434"],
|
||||
allowedHostnames: ["fd00::1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not trust IPv6 origins when the port differs", () => {
|
||||
expect(
|
||||
resolveSsrFPolicyForUrl(new URL("http://[fd00::1]:11435/v1/chat/completions"), {
|
||||
allowedOrigins: ["http://[fd00::1]:11434"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedOrigins: ["http://[fd00::1]:11434"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist", () => {
|
||||
it("builds a host-scoped fake-IP policy from HTTP base URLs", () => {
|
||||
expect(
|
||||
@@ -225,12 +327,14 @@ describe("isSameSsrFPolicy", () => {
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
allowedOrigins: ["https://A.example.com/v1", "https://b.example.com"],
|
||||
allowedHostnames: ["b.example.com", "A.example.com"],
|
||||
hostnameAllowlist: ["*.example.com", "api.example.com"],
|
||||
},
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
allowedOrigins: ["https://b.example.com", "https://a.example.com/other"],
|
||||
allowedHostnames: ["a.example.com", "B.EXAMPLE.COM"],
|
||||
hostnameAllowlist: ["api.example.com", "*.example.com"],
|
||||
},
|
||||
|
||||
@@ -3,9 +3,11 @@ import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import type { Dispatcher } from "undici";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isCloudMetadataIpAddress,
|
||||
isBlockedSpecialUseIpv4Address,
|
||||
isBlockedSpecialUseIpv6Address,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isLinkLocalIpAddress,
|
||||
type Ipv4SpecialUseBlockOptions,
|
||||
type Ipv6SpecialUseBlockOptions,
|
||||
isIpv4Address,
|
||||
@@ -51,6 +53,11 @@ export type SsrFPolicy = {
|
||||
*/
|
||||
allowIpv6UniqueLocalRange?: boolean;
|
||||
allowedHostnames?: string[];
|
||||
/**
|
||||
* Exact HTTP origins that may promote only the current request hostname into
|
||||
* `allowedHostnames`. Evaluated per URL inside the redirect loop.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
hostnameAllowlist?: string[];
|
||||
};
|
||||
|
||||
@@ -73,6 +80,7 @@ function normalizeSsrFPolicyForComparison(policy?: SsrFPolicy) {
|
||||
allowRfc2544BenchmarkRange: policy.allowRfc2544BenchmarkRange === true,
|
||||
allowIpv6UniqueLocalRange: policy.allowIpv6UniqueLocalRange === true,
|
||||
allowedHostnames: normalizeSsrFPolicyHostnames(policy.allowedHostnames),
|
||||
allowedOrigins: normalizeSsrFPolicyOrigins(policy.allowedOrigins),
|
||||
hostnameAllowlist: [...normalizeHostnameAllowlist(policy.hostnameAllowlist)].toSorted(),
|
||||
};
|
||||
}
|
||||
@@ -84,6 +92,45 @@ export function isSameSsrFPolicy(a?: SsrFPolicy, b?: SsrFPolicy): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeSsrFPolicies(
|
||||
...policies: Array<SsrFPolicy | undefined>
|
||||
): SsrFPolicy | undefined {
|
||||
const merged: SsrFPolicy = {};
|
||||
for (const policy of policies) {
|
||||
if (!policy) {
|
||||
continue;
|
||||
}
|
||||
if (policy.allowPrivateNetwork) {
|
||||
merged.allowPrivateNetwork = true;
|
||||
}
|
||||
if (policy.dangerouslyAllowPrivateNetwork) {
|
||||
merged.dangerouslyAllowPrivateNetwork = true;
|
||||
}
|
||||
if (policy.allowRfc2544BenchmarkRange) {
|
||||
merged.allowRfc2544BenchmarkRange = true;
|
||||
}
|
||||
if (policy.allowIpv6UniqueLocalRange) {
|
||||
merged.allowIpv6UniqueLocalRange = true;
|
||||
}
|
||||
if (policy.allowedHostnames?.length) {
|
||||
merged.allowedHostnames = Array.from(
|
||||
new Set([...(merged.allowedHostnames ?? []), ...policy.allowedHostnames]),
|
||||
);
|
||||
}
|
||||
if (policy.allowedOrigins?.length) {
|
||||
merged.allowedOrigins = Array.from(
|
||||
new Set([...(merged.allowedOrigins ?? []), ...policy.allowedOrigins]),
|
||||
);
|
||||
}
|
||||
if (policy.hostnameAllowlist?.length) {
|
||||
merged.hostnameAllowlist = Array.from(
|
||||
new Set([...(merged.hostnameAllowlist ?? []), ...policy.hostnameAllowlist]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl: string): SsrFPolicy | undefined {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
@@ -100,6 +147,41 @@ export function ssrfPolicyFromHttpBaseUrlAllowedHostname(baseUrl: string): SsrFP
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSsrFPolicyOrigin(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||
return parsed.origin.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSsrFPolicyOrigins(values?: string[]): string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => normalizeSsrFPolicyOrigin(value))
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
),
|
||||
).toSorted();
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl: string): SsrFPolicy | undefined {
|
||||
const origin = normalizeSsrFPolicyOrigin(baseUrl);
|
||||
return origin ? { allowedOrigins: [origin] } : undefined;
|
||||
}
|
||||
|
||||
export function ssrfPolicyFromHttpBaseUrlFakeIpHostnameAllowlist(
|
||||
baseUrl: string,
|
||||
): SsrFPolicy | undefined {
|
||||
@@ -159,6 +241,25 @@ function shouldSkipPrivateNetworkChecks(hostname: string, policy?: SsrFPolicy):
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSsrFPolicyForUrl(url: URL, policy?: SsrFPolicy): SsrFPolicy | undefined {
|
||||
if (!policy?.allowedOrigins?.length) {
|
||||
return policy;
|
||||
}
|
||||
const requestOrigin = normalizeSsrFPolicyOrigin(url.toString());
|
||||
if (
|
||||
!requestOrigin ||
|
||||
!normalizeSsrFPolicyOrigins(policy.allowedOrigins).includes(requestOrigin)
|
||||
) {
|
||||
return policy;
|
||||
}
|
||||
return {
|
||||
...policy,
|
||||
allowedHostnames: Array.from(
|
||||
new Set([...(policy.allowedHostnames ?? []), normalizeHostname(url.hostname)]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveIpv4SpecialUseBlockOptions(policy?: SsrFPolicy): Ipv4SpecialUseBlockOptions {
|
||||
return {
|
||||
allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true,
|
||||
@@ -315,6 +416,16 @@ function assertAllowedResolvedAddressesOrThrow(
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllowedTrustedHostnameResolvedAddressesOrThrow(
|
||||
results: readonly LookupAddress[],
|
||||
): void {
|
||||
for (const entry of results) {
|
||||
if (isLinkLocalIpAddress(entry.address) || isCloudMetadataIpAddress(entry.address)) {
|
||||
throw new SsrFBlockedError(BLOCKED_RESOLVED_IP_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLookupResults(results: LookupResult): readonly LookupAddress[] {
|
||||
if (Array.isArray(results)) {
|
||||
return results;
|
||||
@@ -453,6 +564,10 @@ export async function resolvePinnedHostnameWithPolicy(
|
||||
if (!skipPrivateNetworkChecks) {
|
||||
// Phase 2: re-check DNS answers so public hostnames cannot pivot to private targets.
|
||||
assertAllowedResolvedAddressesOrThrow(results, params.policy);
|
||||
} else if (!isPrivateNetworkAllowedByPolicy(params.policy)) {
|
||||
// Exact-host trust may allow RFC1918/tailnet/private-DNS provider targets, but
|
||||
// it must not turn metadata/link-local DNS rebinding into an implicit allow.
|
||||
assertAllowedTrustedHostnameResolvedAddressesOrThrow(results);
|
||||
}
|
||||
|
||||
// Prefer addresses returned as IPv4 by DNS family metadata before other
|
||||
@@ -507,6 +622,8 @@ function resolvePinnedDispatcherLookup(
|
||||
}));
|
||||
if (!shouldSkipPrivateNetworkChecks(pinned.hostname, policy)) {
|
||||
assertAllowedResolvedAddressesOrThrow(records, policy);
|
||||
} else if (!isPrivateNetworkAllowedByPolicy(policy)) {
|
||||
assertAllowedTrustedHostnameResolvedAddressesOrThrow(records);
|
||||
}
|
||||
return createPinnedLookup({
|
||||
hostname: pinned.hostname,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LookupFn } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
resolveSsrFPolicyForUrl,
|
||||
SsrFBlockedError,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
} from "../infra/net/ssrf.js";
|
||||
import {
|
||||
assertHttpUrlTargetsPrivateNetwork,
|
||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||
@@ -142,6 +148,7 @@ describe("mergeSsrFPolicies", () => {
|
||||
{
|
||||
allowPrivateNetwork: true,
|
||||
allowedHostnames: ["api.example.com"],
|
||||
allowedOrigins: ["http://10.0.0.5:1234"],
|
||||
hostnameAllowlist: ["downloads.example.com"],
|
||||
},
|
||||
{
|
||||
@@ -149,6 +156,7 @@ describe("mergeSsrFPolicies", () => {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
allowIpv6UniqueLocalRange: true,
|
||||
allowedHostnames: ["api.example.com", "cdn.example.com"],
|
||||
allowedOrigins: ["http://10.0.0.5:1234", "http://10.0.0.5:4321"],
|
||||
hostnameAllowlist: ["downloads.example.com", "assets.example.com"],
|
||||
},
|
||||
),
|
||||
@@ -158,6 +166,7 @@ describe("mergeSsrFPolicies", () => {
|
||||
allowRfc2544BenchmarkRange: true,
|
||||
allowIpv6UniqueLocalRange: true,
|
||||
allowedHostnames: ["api.example.com", "cdn.example.com"],
|
||||
allowedOrigins: ["http://10.0.0.5:1234", "http://10.0.0.5:4321"],
|
||||
hostnameAllowlist: ["downloads.example.com", "assets.example.com"],
|
||||
});
|
||||
});
|
||||
@@ -383,3 +392,69 @@ describe("buildHostnameAllowlistPolicyFromSuffixAllowlist", () => {
|
||||
expect(buildHostnameAllowlistPolicyFromSuffixAllowlist(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssrfPolicyFromHttpBaseUrlAllowedOrigin — SDK boundary safety", () => {
|
||||
// The constructor itself is permissive: any well-formed http(s) origin
|
||||
// becomes a single-entry allowedOrigins policy. The metadata/link-local
|
||||
// block lives in the resolver (assertAllowedTrustedHostnameResolvedAddressesOrThrow),
|
||||
// so a plugin author's allowedOrigins entry pointing at a metadata target
|
||||
// must still be rejected when an actual request goes through the guard.
|
||||
it.each([
|
||||
{
|
||||
name: "AWS/EC2 IMDS IPv4 literal",
|
||||
hostname: "169.254.169.254",
|
||||
family: 4,
|
||||
},
|
||||
{
|
||||
name: "Alibaba/100-net metadata IPv4 literal",
|
||||
hostname: "100.100.100.200",
|
||||
family: 4,
|
||||
},
|
||||
{
|
||||
name: "GCP metadata canonical hostname",
|
||||
hostname: "metadata.google.internal",
|
||||
family: 4,
|
||||
resolvedAddress: "169.254.169.254",
|
||||
},
|
||||
{
|
||||
name: "IPv6 ULA metadata literal",
|
||||
hostname: "[fd00:ec2::254]",
|
||||
family: 6,
|
||||
resolvedAddress: "fd00:ec2::254",
|
||||
},
|
||||
{
|
||||
name: "non-metadata link-local IPv4 literal",
|
||||
hostname: "169.254.42.42",
|
||||
family: 4,
|
||||
},
|
||||
])(
|
||||
"rejects plugin-supplied allowedOrigins entry: $name",
|
||||
async ({ hostname, family, resolvedAddress }) => {
|
||||
const baseUrl = `http://${hostname}/v1`;
|
||||
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
|
||||
expect(policy?.allowedOrigins).toEqual([new URL(baseUrl).origin]);
|
||||
|
||||
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
|
||||
const lookupAddress = resolvedAddress ?? hostname.replace(/^\[|\]$/g, "");
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy(hostname, {
|
||||
policy: policyForUrl,
|
||||
lookupFn: createLookupFn([{ address: lookupAddress, family }]),
|
||||
}),
|
||||
).rejects.toThrow(SsrFBlockedError);
|
||||
},
|
||||
);
|
||||
|
||||
it("rebinding a trusted private origin to a metadata IP is still rejected", async () => {
|
||||
const baseUrl = "http://lan-llm.corp.internal:11434/v1";
|
||||
const policy = ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl);
|
||||
const policyForUrl = resolveSsrFPolicyForUrl(new URL(baseUrl), policy);
|
||||
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy("lan-llm.corp.internal", {
|
||||
policy: policyForUrl,
|
||||
lookupFn: createLookupFn([{ address: "169.254.169.254", family: 4 }]),
|
||||
}),
|
||||
).rejects.toThrow(SsrFBlockedError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
isBlockedHostnameOrIp,
|
||||
isPrivateIpAddress,
|
||||
mergeSsrFPolicies,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
@@ -13,7 +14,7 @@ import type {
|
||||
} from "./channel-contract.js";
|
||||
import type { OpenClawConfig } from "./config-runtime.js";
|
||||
|
||||
export { isPrivateIpAddress };
|
||||
export { isPrivateIpAddress, mergeSsrFPolicies };
|
||||
export type { SsrFPolicy };
|
||||
|
||||
export type PrivateNetworkOptInInput =
|
||||
@@ -60,40 +61,6 @@ export function ssrfPolicyFromDangerouslyAllowPrivateNetwork(
|
||||
return ssrfPolicyFromPrivateNetworkOptIn(dangerouslyAllowPrivateNetwork);
|
||||
}
|
||||
|
||||
export function mergeSsrFPolicies(
|
||||
...policies: Array<SsrFPolicy | undefined>
|
||||
): SsrFPolicy | undefined {
|
||||
const merged: SsrFPolicy = {};
|
||||
for (const policy of policies) {
|
||||
if (!policy) {
|
||||
continue;
|
||||
}
|
||||
if (policy.allowPrivateNetwork) {
|
||||
merged.allowPrivateNetwork = true;
|
||||
}
|
||||
if (policy.dangerouslyAllowPrivateNetwork) {
|
||||
merged.dangerouslyAllowPrivateNetwork = true;
|
||||
}
|
||||
if (policy.allowRfc2544BenchmarkRange) {
|
||||
merged.allowRfc2544BenchmarkRange = true;
|
||||
}
|
||||
if (policy.allowIpv6UniqueLocalRange) {
|
||||
merged.allowIpv6UniqueLocalRange = true;
|
||||
}
|
||||
if (policy.allowedHostnames?.length) {
|
||||
merged.allowedHostnames = Array.from(
|
||||
new Set([...(merged.allowedHostnames ?? []), ...policy.allowedHostnames]),
|
||||
);
|
||||
}
|
||||
if (policy.hostnameAllowlist?.length) {
|
||||
merged.hostnameAllowlist = Array.from(
|
||||
new Set([...(merged.hostnameAllowlist ?? []), ...policy.hostnameAllowlist]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}
|
||||
|
||||
export function hasLegacyFlatAllowPrivateNetworkAlias(value: unknown): boolean {
|
||||
const entry = asNullableRecord(value);
|
||||
return Boolean(entry && Object.prototype.hasOwnProperty.call(entry, "allowPrivateNetwork"));
|
||||
|
||||
@@ -9,7 +9,9 @@ export {
|
||||
isPrivateIpAddress,
|
||||
resolvePinnedHostname,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
resolveSsrFPolicyForUrl,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
|
||||
type LookupFn,
|
||||
type SsrFPolicy,
|
||||
} from "../infra/net/ssrf.js";
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
isBlockedSpecialUseIpv6Address,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isCarrierGradeNatIpv4Address,
|
||||
isCloudMetadataIpAddress,
|
||||
isIpInCidr,
|
||||
isIpv4Address,
|
||||
isIpv6Address,
|
||||
isLegacyIpv4Literal,
|
||||
isLinkLocalIpAddress,
|
||||
isLoopbackIpAddress,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
isRfc1918Ipv4Address,
|
||||
@@ -75,6 +77,36 @@ describe("shared ip helpers", () => {
|
||||
expect(isLoopbackIpAddress("198.18.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects link-local addresses without treating normal private ranges as link-local", () => {
|
||||
expect(isLinkLocalIpAddress("169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("::ffff:169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("2852039166")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("0xa9fea9fe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("0xa9.0xfe.0xa9.0xfe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("64:ff9b::169.254.169.254")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("64:ff9b:1::a9fe:a9fe")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("2002:a9fe:a9fe::")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("fe80::1%lo0")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("[fe80::1]")).toBe(true);
|
||||
expect(isLinkLocalIpAddress("10.0.0.5")).toBe(false);
|
||||
expect(isLinkLocalIpAddress("127.0.0.1")).toBe(false);
|
||||
expect(isLinkLocalIpAddress("fd00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("detects known non-link-local cloud metadata IPs", () => {
|
||||
expect(isCloudMetadataIpAddress("100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("::ffff:100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("64:ff9b::100.100.100.200")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("64:ff9b:1::6464:64c8")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("2002:6464:64c8::")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("1684301000")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("fd00:ec2::254")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("[fd00:ec2::254]")).toBe(true);
|
||||
expect(isCloudMetadataIpAddress("100.100.100.201")).toBe(false);
|
||||
expect(isCloudMetadataIpAddress("169.254.169.254")).toBe(false);
|
||||
expect(isCloudMetadataIpAddress("fd00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses loose legacy IPv4 literals that canonical parsing rejects", () => {
|
||||
expect(parseCanonicalIpAddress("0177.0.0.1")).toBeUndefined();
|
||||
expect(parseLooseIpAddress("0177.0.0.1")?.toString()).toBe("127.0.0.1");
|
||||
|
||||
@@ -36,6 +36,7 @@ const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set<BlockedIpv6Range>([
|
||||
"orchid2",
|
||||
]);
|
||||
const RFC2544_BENCHMARK_PREFIX: [ipaddr.IPv4, number] = [ipaddr.IPv4.parse("198.18.0.0"), 15];
|
||||
const CLOUD_METADATA_IP_ADDRESSES = new Set(["100.100.100.200", "fd00:ec2::254"]);
|
||||
export type Ipv4SpecialUseBlockOptions = {
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
};
|
||||
@@ -243,6 +244,37 @@ export function isLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
return normalized.range() === "loopback";
|
||||
}
|
||||
|
||||
export function isLinkLocalIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseLooseIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv4Address(normalized)) {
|
||||
return normalized.range() === "linkLocal";
|
||||
}
|
||||
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
|
||||
if (embeddedIpv4?.range() === "linkLocal") {
|
||||
return true;
|
||||
}
|
||||
return normalized.range() === "linkLocal";
|
||||
}
|
||||
|
||||
export function isCloudMetadataIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseLooseIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv6Address(normalized)) {
|
||||
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
|
||||
if (embeddedIpv4 && CLOUD_METADATA_IP_ADDRESSES.has(embeddedIpv4.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString());
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
|
||||
Reference in New Issue
Block a user