mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 20:30:23 +00:00
fix(security): harden browser SSRF defaults and migrate legacy key
This commit is contained in:
47
src/agents/tools/web-search.redirect.test.ts
Normal file
47
src/agents/tools/web-search.redirect.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/net/fetch-guard.js", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
describe("web_search redirect resolution hardening", () => {
|
||||
const { resolveRedirectUrl } = __testing;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("resolves redirects via SSRF-guarded HEAD requests", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(null, { status: 200 }),
|
||||
finalUrl: "https://example.com/final",
|
||||
release,
|
||||
});
|
||||
|
||||
const resolved = await resolveRedirectUrl("https://example.com/start");
|
||||
expect(resolved).toBe("https://example.com/final");
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/start",
|
||||
timeoutMs: 5000,
|
||||
init: { method: "HEAD" },
|
||||
policy: { dangerouslyAllowPrivateNetwork: true },
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the original URL when guarded resolution fails", async () => {
|
||||
fetchWithSsrFGuardMock.mockRejectedValue(new Error("blocked"));
|
||||
await expect(resolveRedirectUrl("https://example.com/start")).resolves.toBe(
|
||||
"https://example.com/start",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { wrapWebContent } from "../../security/external-content.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
@@ -42,6 +43,7 @@ const KIMI_WEB_SEARCH_TOOL = {
|
||||
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||
const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const;
|
||||
|
||||
const WebSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
@@ -681,12 +683,17 @@ const REDIRECT_TIMEOUT_MS = 5000;
|
||||
*/
|
||||
async function resolveRedirectUrl(url: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "HEAD",
|
||||
redirect: "follow",
|
||||
signal: withTimeout(undefined, REDIRECT_TIMEOUT_MS),
|
||||
const { finalUrl, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: { method: "HEAD" },
|
||||
timeoutMs: REDIRECT_TIMEOUT_MS,
|
||||
policy: TRUSTED_NETWORK_SSRF_POLICY,
|
||||
});
|
||||
return res.url || url;
|
||||
try {
|
||||
return finalUrl || url;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
@@ -1345,4 +1352,5 @@ export const __testing = {
|
||||
resolveKimiModel,
|
||||
resolveKimiBaseUrl,
|
||||
extractKimiCitations,
|
||||
resolveRedirectUrl,
|
||||
} as const;
|
||||
|
||||
@@ -177,14 +177,25 @@ describe("browser config", () => {
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy).toEqual({
|
||||
allowPrivateNetwork: true,
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
allowedHostnames: ["localhost"],
|
||||
hostnameAllowlist: ["*.trusted.example"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps browser SSRF policy undefined when not configured", () => {
|
||||
it("defaults browser SSRF policy to trusted-network mode", () => {
|
||||
const resolved = resolveBrowserConfig({});
|
||||
expect(resolved.ssrfPolicy).toBeUndefined();
|
||||
expect(resolved.ssrfPolicy).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports explicit strict mode by disabling private network access", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,19 +75,28 @@ function normalizeStringList(raw: string[] | undefined): string[] | undefined {
|
||||
|
||||
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
||||
const allowPrivateNetwork = cfg?.ssrfPolicy?.allowPrivateNetwork;
|
||||
const dangerouslyAllowPrivateNetwork = cfg?.ssrfPolicy?.dangerouslyAllowPrivateNetwork;
|
||||
const allowedHostnames = normalizeStringList(cfg?.ssrfPolicy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeStringList(cfg?.ssrfPolicy?.hostnameAllowlist);
|
||||
const hasExplicitPrivateSetting =
|
||||
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
||||
// Browser defaults to trusted-network mode unless explicitly disabled by policy.
|
||||
const resolvedAllowPrivateNetwork =
|
||||
dangerouslyAllowPrivateNetwork === true ||
|
||||
allowPrivateNetwork === true ||
|
||||
!hasExplicitPrivateSetting;
|
||||
|
||||
if (
|
||||
allowPrivateNetwork === undefined &&
|
||||
allowedHostnames === undefined &&
|
||||
hostnameAllowlist === undefined
|
||||
!resolvedAllowPrivateNetwork &&
|
||||
!hasExplicitPrivateSetting &&
|
||||
!allowedHostnames &&
|
||||
!hostnameAllowlist
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(allowPrivateNetwork === true ? { allowPrivateNetwork: true } : {}),
|
||||
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
} from "./navigation-guard.js";
|
||||
|
||||
@@ -101,4 +102,22 @@ describe("browser navigation guard", () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(InvalidBrowserNavigationUrlError);
|
||||
});
|
||||
|
||||
it("validates final network URLs after navigation", async () => {
|
||||
const lookupFn = createLookupFn("127.0.0.1");
|
||||
await expect(
|
||||
assertBrowserNavigationResultAllowed({
|
||||
url: "http://private.test",
|
||||
lookupFn,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
});
|
||||
|
||||
it("ignores non-network browser-internal final URLs", async () => {
|
||||
await expect(
|
||||
assertBrowserNavigationResultAllowed({
|
||||
url: "chrome-error://chromewebdata/",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,3 +61,32 @@ export async function assertBrowserNavigationAllowed(
|
||||
policy: opts.ssrfPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort post-navigation guard for final page URLs.
|
||||
* Only validates network URLs (http/https) and about:blank to avoid false
|
||||
* positives on browser-internal error pages (e.g. chrome-error://).
|
||||
*/
|
||||
export async function assertBrowserNavigationResultAllowed(
|
||||
opts: {
|
||||
url: string;
|
||||
lookupFn?: LookupFn;
|
||||
} & BrowserNavigationPolicyOptions,
|
||||
): Promise<void> {
|
||||
const rawUrl = String(opts.url ?? "").trim();
|
||||
if (!rawUrl) {
|
||||
return;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) ||
|
||||
isAllowedNonNetworkNavigationUrl(parsed)
|
||||
) {
|
||||
await assertBrowserNavigationAllowed(opts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { appendCdpPath, fetchJson, getHeadersWithAuth, withCdpSocket } from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
|
||||
export type BrowserConsoleMessage = {
|
||||
type: string;
|
||||
@@ -738,13 +742,18 @@ export async function createPageViaPlaywright(opts: {
|
||||
// Navigate to the URL
|
||||
const targetUrl = opts.url.trim() || "about:blank";
|
||||
if (targetUrl !== "about:blank") {
|
||||
const navigationPolicy = withBrowserNavigationPolicy(opts.ssrfPolicy);
|
||||
await assertBrowserNavigationAllowed({
|
||||
url: targetUrl,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
...navigationPolicy,
|
||||
});
|
||||
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||
// Navigation might fail for some URLs, but page is still created
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: page.url(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the targetId for this page
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import {
|
||||
buildRoleSnapshotFromAiSnapshot,
|
||||
buildRoleSnapshotFromAriaSnapshot,
|
||||
@@ -175,7 +179,12 @@ export async function navigateViaPlaywright(opts: {
|
||||
await page.goto(url, {
|
||||
timeout: Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000)),
|
||||
});
|
||||
return { url: page.url() };
|
||||
const finalUrl = page.url();
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: finalUrl,
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
return { url: finalUrl };
|
||||
}
|
||||
|
||||
export async function resizeViewportViaPlaywright(opts: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./extension-relay.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
@@ -176,6 +177,7 @@ function createProfileContext(
|
||||
const tabs = await listTabs().catch(() => [] as BrowserTab[]);
|
||||
const found = tabs.find((t) => t.targetId === createdViaCdp);
|
||||
if (found) {
|
||||
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
|
||||
return found;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
@@ -214,10 +216,12 @@ function createProfileContext(
|
||||
}
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = created.id;
|
||||
const resolvedUrl = created.url ?? url;
|
||||
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
|
||||
return {
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: created.url ?? url,
|
||||
url: resolvedUrl,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: created.type,
|
||||
};
|
||||
|
||||
@@ -222,4 +222,39 @@ describe("normalizeLegacyConfigValues", () => {
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates browser ssrfPolicy allowPrivateNetwork to dangerouslyAllowPrivateNetwork", () => {
|
||||
const res = normalizeLegacyConfigValues({
|
||||
browser: {
|
||||
ssrfPolicy: {
|
||||
allowPrivateNetwork: true,
|
||||
allowedHostnames: ["localhost"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config.browser?.ssrfPolicy?.allowPrivateNetwork).toBeUndefined();
|
||||
expect(res.config.browser?.ssrfPolicy?.dangerouslyAllowPrivateNetwork).toBe(true);
|
||||
expect(res.config.browser?.ssrfPolicy?.allowedHostnames).toEqual(["localhost"]);
|
||||
expect(res.changes).toContain(
|
||||
"Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes conflicting browser SSRF alias keys without changing effective behavior", () => {
|
||||
const res = normalizeLegacyConfigValues({
|
||||
browser: {
|
||||
ssrfPolicy: {
|
||||
allowPrivateNetwork: true,
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config.browser?.ssrfPolicy?.allowPrivateNetwork).toBeUndefined();
|
||||
expect(res.config.browser?.ssrfPolicy?.dangerouslyAllowPrivateNetwork).toBe(true);
|
||||
expect(res.changes).toContain(
|
||||
"Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (true).",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,6 +293,51 @@ export function normalizeLegacyConfigValues(cfg: OpenClawConfig): {
|
||||
normalizeProvider("slack");
|
||||
normalizeProvider("discord");
|
||||
|
||||
const normalizeBrowserSsrFPolicyAlias = () => {
|
||||
const rawBrowser = next.browser;
|
||||
if (!isRecord(rawBrowser)) {
|
||||
return;
|
||||
}
|
||||
const rawSsrFPolicy = rawBrowser.ssrfPolicy;
|
||||
if (!isRecord(rawSsrFPolicy) || !("allowPrivateNetwork" in rawSsrFPolicy)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyAllowPrivateNetwork = rawSsrFPolicy.allowPrivateNetwork;
|
||||
const currentDangerousAllowPrivateNetwork = rawSsrFPolicy.dangerouslyAllowPrivateNetwork;
|
||||
|
||||
let resolvedDangerousAllowPrivateNetwork: unknown = currentDangerousAllowPrivateNetwork;
|
||||
if (
|
||||
typeof legacyAllowPrivateNetwork === "boolean" ||
|
||||
typeof currentDangerousAllowPrivateNetwork === "boolean"
|
||||
) {
|
||||
// Preserve runtime behavior while collapsing to the canonical key.
|
||||
resolvedDangerousAllowPrivateNetwork =
|
||||
legacyAllowPrivateNetwork === true || currentDangerousAllowPrivateNetwork === true;
|
||||
} else if (currentDangerousAllowPrivateNetwork === undefined) {
|
||||
resolvedDangerousAllowPrivateNetwork = legacyAllowPrivateNetwork;
|
||||
}
|
||||
|
||||
const nextSsrFPolicy: Record<string, unknown> = { ...rawSsrFPolicy };
|
||||
delete nextSsrFPolicy.allowPrivateNetwork;
|
||||
if (resolvedDangerousAllowPrivateNetwork !== undefined) {
|
||||
nextSsrFPolicy.dangerouslyAllowPrivateNetwork = resolvedDangerousAllowPrivateNetwork;
|
||||
}
|
||||
|
||||
const migratedBrowser = { ...next.browser } as Record<string, unknown>;
|
||||
migratedBrowser.ssrfPolicy = nextSsrFPolicy;
|
||||
|
||||
next = {
|
||||
...next,
|
||||
browser: migratedBrowser as OpenClawConfig["browser"],
|
||||
};
|
||||
changes.push(
|
||||
`Moved browser.ssrfPolicy.allowPrivateNetwork → browser.ssrfPolicy.dangerouslyAllowPrivateNetwork (${String(resolvedDangerousAllowPrivateNetwork)}).`,
|
||||
);
|
||||
};
|
||||
|
||||
normalizeBrowserSsrFPolicyAlias();
|
||||
|
||||
const legacyAckReaction = cfg.messages?.ackReaction?.trim();
|
||||
const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined;
|
||||
if (legacyAckReaction && hasWhatsAppConfig) {
|
||||
|
||||
@@ -519,6 +519,7 @@ const FINAL_BACKLOG_TARGET_KEYS = [
|
||||
"browser.snapshotDefaults.mode",
|
||||
"browser.ssrfPolicy",
|
||||
"browser.ssrfPolicy.allowPrivateNetwork",
|
||||
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork",
|
||||
"browser.ssrfPolicy.allowedHostnames",
|
||||
"browser.ssrfPolicy.hostnameAllowlist",
|
||||
"diagnostics.enabled",
|
||||
|
||||
@@ -186,7 +186,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"browser.ssrfPolicy":
|
||||
"Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.",
|
||||
"browser.ssrfPolicy.allowPrivateNetwork":
|
||||
"Allows access to private-network address ranges from browser/network tooling when SSRF protections are active. Keep disabled unless internal-network access is required and separately controlled.",
|
||||
"Legacy alias for browser.ssrfPolicy.dangerouslyAllowPrivateNetwork. Prefer the dangerously-named key so risk intent is explicit.",
|
||||
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork":
|
||||
"Allows access to private-network address ranges from browser tooling. Default is enabled for trusted-network operator setups; disable to enforce strict public-only resolution checks.",
|
||||
"browser.ssrfPolicy.allowedHostnames":
|
||||
"Explicit hostname allowlist exceptions for SSRF policy checks on browser/network requests. Keep this list minimal and review entries regularly to avoid stale broad access.",
|
||||
"browser.ssrfPolicy.hostnameAllowlist":
|
||||
|
||||
@@ -427,6 +427,7 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"browser.snapshotDefaults.mode": "Browser Snapshot Mode",
|
||||
"browser.ssrfPolicy": "Browser SSRF Policy",
|
||||
"browser.ssrfPolicy.allowPrivateNetwork": "Browser Allow Private Network",
|
||||
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork": "Browser Dangerously Allow Private Network",
|
||||
"browser.ssrfPolicy.allowedHostnames": "Browser Allowed Hostnames",
|
||||
"browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
|
||||
@@ -13,8 +13,10 @@ export type BrowserSnapshotDefaults = {
|
||||
mode?: "efficient";
|
||||
};
|
||||
export type BrowserSsrFPolicyConfig = {
|
||||
/** If true, permit browser navigation to private/internal networks. Default: false */
|
||||
/** Legacy alias for private-network access. Prefer dangerouslyAllowPrivateNetwork. */
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** If true, permit browser navigation to private/internal networks. Default: true */
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
/**
|
||||
* Explicitly allowed hostnames (exact-match), including blocked names like localhost.
|
||||
* Example: ["localhost", "metadata.internal"]
|
||||
|
||||
@@ -235,6 +235,7 @@ export const OpenClawSchema = z
|
||||
ssrfPolicy: z
|
||||
.object({
|
||||
allowPrivateNetwork: z.boolean().optional(),
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
allowedHostnames: z.array(z.string()).optional(),
|
||||
hostnameAllowlist: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
@@ -154,4 +154,19 @@ describe("ssrf pinning", () => {
|
||||
});
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts dangerouslyAllowPrivateNetwork as an allowPrivateNetwork alias", async () => {
|
||||
const lookup = vi.fn(async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn;
|
||||
|
||||
await expect(
|
||||
resolvePinnedHostnameWithPolicy("localhost", {
|
||||
lookupFn: lookup,
|
||||
policy: { dangerouslyAllowPrivateNetwork: true },
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
hostname: "localhost",
|
||||
addresses: ["127.0.0.1"],
|
||||
});
|
||||
expect(lookup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ export type LookupFn = typeof dnsLookup;
|
||||
|
||||
export type SsrFPolicy = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
dangerouslyAllowPrivateNetwork?: boolean;
|
||||
allowedHostnames?: string[];
|
||||
hostnameAllowlist?: string[];
|
||||
};
|
||||
@@ -60,6 +61,10 @@ function normalizeHostnameAllowlist(values?: string[]): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAllowPrivateNetwork(policy?: SsrFPolicy): boolean {
|
||||
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
||||
}
|
||||
|
||||
function isHostnameAllowedByPattern(hostname: string, pattern: string): boolean {
|
||||
if (pattern.startsWith("*.")) {
|
||||
const suffix = pattern.slice(2);
|
||||
@@ -247,7 +252,7 @@ export async function resolvePinnedHostnameWithPolicy(
|
||||
throw new Error("Invalid hostname");
|
||||
}
|
||||
|
||||
const allowPrivateNetwork = Boolean(params.policy?.allowPrivateNetwork);
|
||||
const allowPrivateNetwork = resolveAllowPrivateNetwork(params.policy);
|
||||
const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
|
||||
const isExplicitAllowed = allowedHostnames.has(normalized);
|
||||
|
||||
Reference in New Issue
Block a user