mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 17:41:22 +00:00
fix: harden OpenResponses URL input fetching
This commit is contained in:
63
src/infra/net/fetch-guard.ssrf.test.ts
Normal file
63
src/infra/net/fetch-guard.ssrf.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fetchWithSsrFGuard } from "./fetch-guard.js";
|
||||
|
||||
function redirectResponse(location: string): Response {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location },
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetchWithSsrFGuard hardening", () => {
|
||||
it("blocks private IP literal URLs before fetch", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "http://127.0.0.1:8080/internal",
|
||||
fetchImpl,
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks redirect chains that hop to private hosts", async () => {
|
||||
const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
|
||||
const fetchImpl = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1:6379/"));
|
||||
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "https://public.example/start",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
}),
|
||||
).rejects.toThrow(/private|internal|blocked/i);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("enforces hostname allowlist policies", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
fetchWithSsrFGuard({
|
||||
url: "https://evil.example.org/file.txt",
|
||||
fetchImpl,
|
||||
policy: { hostnameAllowlist: ["cdn.example.com", "*.assets.example.com"] },
|
||||
}),
|
||||
).rejects.toThrow(/allowlist/i);
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows wildcard allowlisted hosts", async () => {
|
||||
const lookupFn = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
|
||||
const fetchImpl = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
const result = await fetchWithSsrFGuard({
|
||||
url: "https://img.assets.example.com/pic.png",
|
||||
fetchImpl,
|
||||
lookupFn,
|
||||
policy: { hostnameAllowlist: ["*.assets.example.com"] },
|
||||
});
|
||||
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
await result.release();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { logWarn } from "../../logger.js";
|
||||
import {
|
||||
closeDispatcher,
|
||||
createPinnedDispatcher,
|
||||
resolvePinnedHostname,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
type LookupFn,
|
||||
SsrFBlockedError,
|
||||
type SsrFPolicy,
|
||||
} from "./ssrf.js";
|
||||
|
||||
@@ -20,6 +21,7 @@ export type GuardedFetchOptions = {
|
||||
policy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
pinDns?: boolean;
|
||||
auditContext?: string;
|
||||
};
|
||||
|
||||
export type GuardedFetchResult = {
|
||||
@@ -113,15 +115,10 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
|
||||
let dispatcher: Dispatcher | null = null;
|
||||
try {
|
||||
const usePolicy = Boolean(
|
||||
params.policy?.allowPrivateNetwork || params.policy?.allowedHostnames?.length,
|
||||
);
|
||||
const pinned = usePolicy
|
||||
? await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
})
|
||||
: await resolvePinnedHostname(parsedUrl.hostname, params.lookupFn);
|
||||
const pinned = await resolvePinnedHostnameWithPolicy(parsedUrl.hostname, {
|
||||
lookupFn: params.lookupFn,
|
||||
policy: params.policy,
|
||||
});
|
||||
if (params.pinDns !== false) {
|
||||
dispatcher = createPinnedDispatcher(pinned);
|
||||
}
|
||||
@@ -164,6 +161,12 @@ export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<G
|
||||
release: async () => release(dispatcher),
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof SsrFBlockedError) {
|
||||
const context = params.auditContext ?? "url-fetch";
|
||||
logWarn(
|
||||
`security: blocked URL fetch (${context}) target=${parsedUrl.origin}${parsedUrl.pathname} reason=${err.message}`,
|
||||
);
|
||||
}
|
||||
await release(dispatcher);
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js";
|
||||
import {
|
||||
createPinnedLookup,
|
||||
resolvePinnedHostname,
|
||||
resolvePinnedHostnameWithPolicy,
|
||||
} from "./ssrf.js";
|
||||
|
||||
describe("ssrf pinning", () => {
|
||||
it("pins resolved addresses for the target hostname", async () => {
|
||||
@@ -68,4 +72,34 @@ describe("ssrf pinning", () => {
|
||||
expect(fallback).toHaveBeenCalledTimes(1);
|
||||
expect(result.address).toBe("1.2.3.4");
|
||||
});
|
||||
|
||||
it("enforces hostname allowlist when configured", async () => {
|
||||
const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
|
||||
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy("api.example.com", {
|
||||
lookupFn: lookup,
|
||||
policy: { hostnameAllowlist: ["cdn.example.com", "*.trusted.example"] },
|
||||
}),
|
||||
).rejects.toThrow(/allowlist/i);
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports wildcard hostname allowlist patterns", async () => {
|
||||
const lookup = vi.fn(async () => [{ address: "93.184.216.34", family: 4 }]);
|
||||
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy("assets.example.com", {
|
||||
lookupFn: lookup,
|
||||
policy: { hostnameAllowlist: ["*.example.com"] },
|
||||
}),
|
||||
).resolves.toMatchObject({ hostname: "assets.example.com" });
|
||||
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy("example.com", {
|
||||
lookupFn: lookup,
|
||||
policy: { hostnameAllowlist: ["*.example.com"] },
|
||||
}),
|
||||
).rejects.toThrow(/allowlist/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export type LookupFn = typeof dnsLookup;
|
||||
export type SsrFPolicy = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
allowedHostnames?: string[];
|
||||
hostnameAllowlist?: string[];
|
||||
};
|
||||
|
||||
const PRIVATE_IPV6_PREFIXES = ["fe80:", "fec0:", "fc", "fd"];
|
||||
@@ -40,6 +41,37 @@ function normalizeHostnameSet(values?: string[]): Set<string> {
|
||||
return new Set(values.map((value) => normalizeHostname(value)).filter(Boolean));
|
||||
}
|
||||
|
||||
function normalizeHostnameAllowlist(values?: string[]): string[] {
|
||||
if (!values || values.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((value) => normalizeHostname(value))
|
||||
.filter((value) => value !== "*" && value !== "*." && value.length > 0),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(2);
|
||||
if (!suffix || hostname === suffix) {
|
||||
return false;
|
||||
}
|
||||
return hostname.endsWith(`.${suffix}`);
|
||||
}
|
||||
return hostname === pattern;
|
||||
}
|
||||
|
||||
function matchesHostnameAllowlist(hostname: string, allowlist: string[]): boolean {
|
||||
if (allowlist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
|
||||
}
|
||||
|
||||
function parseIpv4(address: string): number[] | null {
|
||||
const parts = address.split(".");
|
||||
if (parts.length !== 4) {
|
||||
@@ -229,8 +261,13 @@ export async function resolvePinnedHostnameWithPolicy(
|
||||
|
||||
const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork);
|
||||
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
|
||||
const isExplicitAllowed = allowedHostnames.has(normalized);
|
||||
|
||||
if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
|
||||
throw new SsrFBlockedError(`Blocked hostname (not in allowlist): ${hostname}`);
|
||||
}
|
||||
|
||||
if (!allowPrivateNetwork && !isExplicitAllowed) {
|
||||
if (isBlockedHostname(normalized)) {
|
||||
throw new SsrFBlockedError(`Blocked hostname: ${hostname}`);
|
||||
|
||||
Reference in New Issue
Block a user