Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
7
packages/net-policy/src/index.ts
Normal file
7
packages/net-policy/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Public network policy package surface for IP parsing, redaction, and URL
|
||||
// userinfo stripping helpers.
|
||||
export * from "./ip.js";
|
||||
export * from "./ipv4.js";
|
||||
export * from "./redact-sensitive-url.js";
|
||||
export * from "./url-protocol.js";
|
||||
export * from "./url-userinfo.js";
|
||||
2
packages/net-policy/src/ip-test-fixtures.ts
Normal file
2
packages/net-policy/src/ip-test-fixtures.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Network Policy module implements ip test fixtures behavior.
|
||||
export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const;
|
||||
188
packages/net-policy/src/ip.test.ts
Normal file
188
packages/net-policy/src/ip.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Network Policy tests cover ip behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { blockedIpv6MulticastLiterals } from "./ip-test-fixtures.js";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isBlockedSpecialUseIpv4Address,
|
||||
isBlockedSpecialUseIpv6Address,
|
||||
isCanonicalDottedDecimalIPv4,
|
||||
isCarrierGradeNatIpv4Address,
|
||||
isCloudMetadataIpAddress,
|
||||
isIpInCidr,
|
||||
isIpv4Address,
|
||||
isIpv6Address,
|
||||
isLegacyIpv4Literal,
|
||||
isLinkLocalIpAddress,
|
||||
isLoopbackIpAddress,
|
||||
isPrivateOrLoopbackIpAddress,
|
||||
isRfc1918Ipv4Address,
|
||||
normalizeIpAddress,
|
||||
parseCanonicalIpAddress,
|
||||
parseLooseIpAddress,
|
||||
} from "./ip.js";
|
||||
|
||||
describe("shared ip helpers", () => {
|
||||
it("distinguishes canonical dotted IPv4 from legacy forms", () => {
|
||||
expect(isCanonicalDottedDecimalIPv4("127.0.0.1")).toBe(true);
|
||||
expect(isCanonicalDottedDecimalIPv4("0177.0.0.1")).toBe(false);
|
||||
expect(isLegacyIpv4Literal("0177.0.0.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("127.1")).toBe(true);
|
||||
expect(isLegacyIpv4Literal("example.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches both IPv4 and IPv6 CIDRs", () => {
|
||||
expect(isIpInCidr("10.42.0.59", "10.42.0.0/24")).toBe(true);
|
||||
expect(isIpInCidr("10.43.0.59", "10.42.0.0/24")).toBe(false);
|
||||
expect(isIpInCidr("2001:db8::1234", "2001:db8::/32")).toBe(true);
|
||||
expect(isIpInCidr("2001:db9::1234", "2001:db8::/32")).toBe(false);
|
||||
expect(isIpInCidr("::ffff:127.0.0.1", "127.0.0.1")).toBe(true);
|
||||
expect(isIpInCidr("127.0.0.1", "::ffff:127.0.0.2")).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts embedded IPv4 for transition prefixes", () => {
|
||||
const cases = [
|
||||
["::ffff:127.0.0.1", "127.0.0.1"],
|
||||
["::127.0.0.1", "127.0.0.1"],
|
||||
["64:ff9b::8.8.8.8", "8.8.8.8"],
|
||||
["64:ff9b:1::10.0.0.1", "10.0.0.1"],
|
||||
["2002:0808:0808::", "8.8.8.8"],
|
||||
["2001::f7f7:f7f7", "8.8.8.8"],
|
||||
["2001:4860:1::5efe:7f00:1", "127.0.0.1"],
|
||||
] as const;
|
||||
for (const [ipv6Literal, expectedIpv4] of cases) {
|
||||
const parsed = parseCanonicalIpAddress(ipv6Literal);
|
||||
expect(parsed?.kind(), ipv6Literal).toBe("ipv6");
|
||||
if (!parsed || !isIpv6Address(parsed)) {
|
||||
continue;
|
||||
}
|
||||
expect(extractEmbeddedIpv4FromIpv6(parsed)?.toString(), ipv6Literal).toBe(expectedIpv4);
|
||||
}
|
||||
});
|
||||
|
||||
it("treats blocked IPv6 classes as private/internal", () => {
|
||||
expect(isPrivateOrLoopbackIpAddress("fec0::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:db8::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:2::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("100::1")).toBe(true);
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:20::1")).toBe(true);
|
||||
for (const literal of blockedIpv6MulticastLiterals) {
|
||||
expect(isPrivateOrLoopbackIpAddress(literal)).toBe(true);
|
||||
}
|
||||
expect(isPrivateOrLoopbackIpAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes canonical IP strings and loopback detection", () => {
|
||||
expect(normalizeIpAddress("[::FFFF:127.0.0.1]")).toBe("127.0.0.1");
|
||||
expect(normalizeIpAddress(" [2001:DB8::1] ")).toBe("2001:db8::1");
|
||||
expect(isLoopbackIpAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
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");
|
||||
expect(parseLooseIpAddress("[::1]")?.toString()).toBe("::1");
|
||||
});
|
||||
|
||||
it("classifies RFC1918 and carrier-grade-nat IPv4 ranges", () => {
|
||||
expect(isRfc1918Ipv4Address("10.42.0.59")).toBe(true);
|
||||
expect(isRfc1918Ipv4Address("100.64.0.1")).toBe(false);
|
||||
expect(isCarrierGradeNatIpv4Address("100.64.0.1")).toBe(true);
|
||||
expect(isCarrierGradeNatIpv4Address("10.42.0.59")).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks special-use IPv4 ranges while allowing optional RFC2544 benchmark addresses", () => {
|
||||
const loopback = parseCanonicalIpAddress("127.0.0.1");
|
||||
const benchmark = parseCanonicalIpAddress("198.18.0.1");
|
||||
|
||||
expect(loopback?.kind()).toBe("ipv4");
|
||||
expect(benchmark?.kind()).toBe("ipv4");
|
||||
if (!loopback || !isIpv4Address(loopback) || !benchmark || !isIpv4Address(benchmark)) {
|
||||
throw new Error("expected ipv4 fixtures");
|
||||
}
|
||||
|
||||
expect(isBlockedSpecialUseIpv4Address(loopback)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv4Address(benchmark)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv4Address(benchmark, { allowRfc2544BenchmarkRange: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks IPv6 unique-local addresses by default and exempts them on opt-in (#74351)", () => {
|
||||
// fc00::/7 is the IPv6 ULA range. Sing-box / Clash / Surge fake-ip
|
||||
// proxies resolve foreign domains here, alongside the IPv4 198.18.0.0/15
|
||||
// benchmark range. Operators using those proxies need both ranges
|
||||
// exempted to keep web_fetch working.
|
||||
const ula = parseCanonicalIpAddress("fc00::1");
|
||||
expect(ula?.kind()).toBe("ipv6");
|
||||
if (!ula || !isIpv6Address(ula)) {
|
||||
throw new Error("expected ipv6 fixture");
|
||||
}
|
||||
|
||||
// Default policy (no options) must continue to block the ULA range.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, {})).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: false })).toBe(true);
|
||||
|
||||
// Opt-in flag — the only path the SSRF policy uses to thread fake-ip
|
||||
// proxy intent through to the address classifier.
|
||||
expect(isBlockedSpecialUseIpv6Address(ula, { allowUniqueLocalRange: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("opt-in unique-local exemption does NOT bleed into other special-use IPv6 ranges (#74351)", () => {
|
||||
// The exemption must be scoped: loopback (::1), unspecified (::), and
|
||||
// multicast (ff00::/8) all stay blocked even when `allowUniqueLocalRange`
|
||||
// is set, otherwise the flag silently widens the SSRF escape hatch
|
||||
// beyond what operators opted into.
|
||||
const loopback = parseCanonicalIpAddress("::1");
|
||||
const multicast = parseCanonicalIpAddress("ff02::1");
|
||||
const siteLocal = parseCanonicalIpAddress("fec0::1"); // deprecated fec0::/10
|
||||
|
||||
if (
|
||||
!loopback ||
|
||||
!isIpv6Address(loopback) ||
|
||||
!multicast ||
|
||||
!isIpv6Address(multicast) ||
|
||||
!siteLocal ||
|
||||
!isIpv6Address(siteLocal)
|
||||
) {
|
||||
throw new Error("expected ipv6 fixtures");
|
||||
}
|
||||
|
||||
for (const options of [{}, { allowUniqueLocalRange: true }] as const) {
|
||||
expect(isBlockedSpecialUseIpv6Address(loopback, options)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(multicast, options)).toBe(true);
|
||||
expect(isBlockedSpecialUseIpv6Address(siteLocal, options)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
443
packages/net-policy/src/ip.ts
Normal file
443
packages/net-policy/src/ip.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
// Network Policy module implements ip behavior.
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Parsed IP address value returned by the net-policy parsing helpers. */
|
||||
export type ParsedIpAddress = ipaddr.IPv4 | ipaddr.IPv6;
|
||||
type Ipv4Range = ReturnType<ipaddr.IPv4["range"]>;
|
||||
type Ipv6Range = ReturnType<ipaddr.IPv6["range"]>;
|
||||
type BlockedIpv6Range = Ipv6Range | "discard";
|
||||
|
||||
const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set<Ipv4Range>([
|
||||
"unspecified",
|
||||
"broadcast",
|
||||
"multicast",
|
||||
"linkLocal",
|
||||
"loopback",
|
||||
"carrierGradeNat",
|
||||
"private",
|
||||
"reserved",
|
||||
]);
|
||||
|
||||
const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set<Ipv4Range>([
|
||||
"loopback",
|
||||
"private",
|
||||
"linkLocal",
|
||||
"carrierGradeNat",
|
||||
]);
|
||||
|
||||
const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set<BlockedIpv6Range>([
|
||||
"unspecified",
|
||||
"loopback",
|
||||
"linkLocal",
|
||||
"uniqueLocal",
|
||||
"multicast",
|
||||
"reserved",
|
||||
"benchmarking",
|
||||
"discard",
|
||||
"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"]);
|
||||
/** Per-call exemptions for `isBlockedSpecialUseIpv4Address`. */
|
||||
export type Ipv4SpecialUseBlockOptions = {
|
||||
allowRfc2544BenchmarkRange?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-call exemptions for `isBlockedSpecialUseIpv6Address`. Mirror of
|
||||
* {@link Ipv4SpecialUseBlockOptions} for the IPv6 side. Currently only
|
||||
* `allowUniqueLocalRange` is exposed (#74351); other reserved IPv6 ranges stay
|
||||
* unconditionally blocked because they have no documented fake-ip / proxy
|
||||
* use case.
|
||||
*/
|
||||
export type Ipv6SpecialUseBlockOptions = {
|
||||
/**
|
||||
* When true, exempt addresses in `fc00::/7` (the IPv6 Unique Local Address
|
||||
* block, RFC 4193) from the SSRF private-IP block. Sing-box / Clash / Surge
|
||||
* fake-ip implementations resolve foreign domains to ULA addresses
|
||||
* alongside RFC 2544 benchmark IPv4 addresses, and operators using those
|
||||
* proxy stacks need both ranges exempted to keep `web_fetch` working.
|
||||
*/
|
||||
allowUniqueLocalRange?: boolean;
|
||||
};
|
||||
|
||||
const EMBEDDED_IPV4_SENTINEL_RULES: Array<{
|
||||
matches: (parts: number[]) => boolean;
|
||||
toHextets: (parts: number[]) => [high: number, low: number];
|
||||
}> = [
|
||||
{
|
||||
// IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases).
|
||||
matches: (parts) =>
|
||||
parts[0] === 0 &&
|
||||
parts[1] === 0 &&
|
||||
parts[2] === 0 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// NAT64 local-use prefix: 64:ff9b:1::/48.
|
||||
matches: (parts) =>
|
||||
parts[0] === 0x0064 &&
|
||||
parts[1] === 0xff9b &&
|
||||
parts[2] === 0x0001 &&
|
||||
parts[3] === 0 &&
|
||||
parts[4] === 0 &&
|
||||
parts[5] === 0,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
{
|
||||
// 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2).
|
||||
matches: (parts) => parts[0] === 0x2002,
|
||||
toHextets: (parts) => [parts[1], parts[2]],
|
||||
},
|
||||
{
|
||||
// Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7).
|
||||
matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000,
|
||||
toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff],
|
||||
},
|
||||
{
|
||||
// ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4.
|
||||
matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe,
|
||||
toHextets: (parts) => [parts[6], parts[7]],
|
||||
},
|
||||
];
|
||||
|
||||
function stripIpv6Brackets(value: string): string {
|
||||
if (value.startsWith("[") && value.endsWith("]")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isNumericIpv4LiteralPart(value: string): boolean {
|
||||
return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
|
||||
}
|
||||
|
||||
function parseIpv6WithEmbeddedIpv4(raw: string): ipaddr.IPv6 | undefined {
|
||||
if (!raw.includes(":") || !raw.includes(".")) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
|
||||
if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) {
|
||||
return undefined;
|
||||
}
|
||||
const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
|
||||
const high = ((octets[0] << 8) | octets[1]).toString(16);
|
||||
const low = ((octets[2] << 8) | octets[3]).toString(16);
|
||||
const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`;
|
||||
if (!ipaddr.IPv6.isValid(normalizedIpv6)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.IPv6.parse(normalizedIpv6);
|
||||
}
|
||||
|
||||
/** Type guard for parsed IPv4 addresses. */
|
||||
export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 {
|
||||
return address.kind() === "ipv4";
|
||||
}
|
||||
|
||||
/** Type guard for parsed IPv6 addresses. */
|
||||
export function isIpv6Address(address: ParsedIpAddress): address is ipaddr.IPv6 {
|
||||
return address.kind() === "ipv6";
|
||||
}
|
||||
|
||||
function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress {
|
||||
if (!isIpv6Address(address)) {
|
||||
return address;
|
||||
}
|
||||
if (!address.isIPv4MappedAddress()) {
|
||||
return address;
|
||||
}
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
|
||||
function normalizeIpParseInput(raw: string | undefined): string | undefined {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return stripIpv6Brackets(trimmed);
|
||||
}
|
||||
|
||||
/** Parses canonical IPv4/IPv6 literals, rejecting legacy IPv4 shorthand forms. */
|
||||
export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const normalized = normalizeIpParseInput(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (ipaddr.IPv4.isValid(normalized)) {
|
||||
if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return ipaddr.IPv4.parse(normalized);
|
||||
}
|
||||
if (ipaddr.IPv6.isValid(normalized)) {
|
||||
return ipaddr.IPv6.parse(normalized);
|
||||
}
|
||||
return parseIpv6WithEmbeddedIpv4(normalized);
|
||||
}
|
||||
|
||||
/** Parses canonical IP literals plus legacy IPv4 forms needed for SSRF checks. */
|
||||
export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined {
|
||||
const normalized = normalizeIpParseInput(raw);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (ipaddr.isValid(normalized)) {
|
||||
return ipaddr.parse(normalized);
|
||||
}
|
||||
return parseIpv6WithEmbeddedIpv4(normalized);
|
||||
}
|
||||
|
||||
/** Normalizes canonical IP literals and maps IPv4-mapped IPv6 addresses to IPv4 text. */
|
||||
export function normalizeIpAddress(raw: string | undefined): string | undefined {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalizeLowercaseStringOrEmpty(normalized.toString());
|
||||
}
|
||||
|
||||
/** True only for canonical four-part dotted-decimal IPv4 literals. */
|
||||
export function isCanonicalDottedDecimalIPv4(raw: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return ipaddr.IPv4.isValidFourPartDecimal(normalized);
|
||||
}
|
||||
|
||||
/** Detects legacy numeric IPv4 forms that canonical parsing deliberately rejects. */
|
||||
export function isLegacyIpv4Literal(raw: string | undefined): boolean {
|
||||
const trimmed = normalizeOptionalString(raw);
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = stripIpv6Brackets(trimmed);
|
||||
if (!normalized || normalized.includes(":")) {
|
||||
return false;
|
||||
}
|
||||
if (isCanonicalDottedDecimalIPv4(normalized)) {
|
||||
return false;
|
||||
}
|
||||
const parts = normalized.split(".");
|
||||
if (parts.length === 0 || parts.length > 4) {
|
||||
return false;
|
||||
}
|
||||
if (parts.some((part) => part.length === 0)) {
|
||||
return false;
|
||||
}
|
||||
if (!parts.every((part) => isNumericIpv4LiteralPart(part))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** True when a canonical IP literal is loopback, including IPv4-mapped IPv6. */
|
||||
export function isLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
return normalized.range() === "loopback";
|
||||
}
|
||||
|
||||
/** True for link-local IPs, including legacy and embedded-IPv4 forms. */
|
||||
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";
|
||||
}
|
||||
|
||||
/** True for cloud metadata IP literals, including mapped and embedded forms. */
|
||||
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());
|
||||
}
|
||||
|
||||
/** True for canonical private, loopback, link-local, or blocked special-use IPs. */
|
||||
export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIpv4MappedAddress(parsed);
|
||||
if (isIpv4Address(normalized)) {
|
||||
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
|
||||
}
|
||||
return isBlockedSpecialUseIpv6Address(normalized);
|
||||
}
|
||||
|
||||
/** Applies the SSRF block policy for parsed IPv6 special-use ranges. */
|
||||
export function isBlockedSpecialUseIpv6Address(
|
||||
address: ipaddr.IPv6,
|
||||
options: Ipv6SpecialUseBlockOptions = {},
|
||||
): boolean {
|
||||
// ipaddr.js returns "discard" at runtime for 100::/64, but its published
|
||||
// TypeScript IPv6Range union omits that literal.
|
||||
const range = address.range() as BlockedIpv6Range;
|
||||
if (range === "uniqueLocal" && options.allowUniqueLocalRange === true) {
|
||||
// Operators running fake-ip proxy stacks (sing-box, Clash, Surge) opt in
|
||||
// to fc00::/7 reaching the network — same intent as
|
||||
// `allowRfc2544BenchmarkRange` for the IPv4 side (#74351).
|
||||
return false;
|
||||
}
|
||||
if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(range)) {
|
||||
return true;
|
||||
}
|
||||
// ipaddr.js does not classify deprecated site-local fec0::/10 as private.
|
||||
return (address.parts[0] & 0xffc0) === 0xfec0;
|
||||
}
|
||||
|
||||
/** True for canonical IPv4 literals in RFC 1918 private ranges. */
|
||||
export function isRfc1918Ipv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "private";
|
||||
}
|
||||
|
||||
/** True for canonical IPv4 literals in the carrier-grade NAT range. */
|
||||
export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean {
|
||||
const parsed = parseCanonicalIpAddress(raw);
|
||||
if (!parsed || !isIpv4Address(parsed)) {
|
||||
return false;
|
||||
}
|
||||
return parsed.range() === "carrierGradeNat";
|
||||
}
|
||||
|
||||
/** Applies the SSRF block policy for parsed IPv4 special-use ranges. */
|
||||
export function isBlockedSpecialUseIpv4Address(
|
||||
address: ipaddr.IPv4,
|
||||
options: Ipv4SpecialUseBlockOptions = {},
|
||||
): boolean {
|
||||
const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX);
|
||||
if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) {
|
||||
return false;
|
||||
}
|
||||
return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange;
|
||||
}
|
||||
|
||||
function decodeIpv4FromHextets(high: number, low: number): ipaddr.IPv4 {
|
||||
const octets: [number, number, number, number] = [
|
||||
(high >>> 8) & 0xff,
|
||||
high & 0xff,
|
||||
(low >>> 8) & 0xff,
|
||||
low & 0xff,
|
||||
];
|
||||
return ipaddr.IPv4.parse(octets.join("."));
|
||||
}
|
||||
|
||||
/** Extracts embedded IPv4 addresses from mapped and transition IPv6 prefixes. */
|
||||
export function extractEmbeddedIpv4FromIpv6(address: ipaddr.IPv6): ipaddr.IPv4 | undefined {
|
||||
if (address.isIPv4MappedAddress()) {
|
||||
return address.toIPv4Address();
|
||||
}
|
||||
if (address.range() === "rfc6145") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
if (address.range() === "rfc6052") {
|
||||
return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
|
||||
}
|
||||
for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
|
||||
if (!rule.matches(address.parts)) {
|
||||
continue;
|
||||
}
|
||||
const [high, low] = rule.toHextets(address.parts);
|
||||
return decodeIpv4FromHextets(high, low);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Checks an IP literal against an exact IP or CIDR range, normalizing mapped IPv4. */
|
||||
export function isIpInCidr(ip: string, cidr: string): boolean {
|
||||
const normalizedIp = parseCanonicalIpAddress(ip);
|
||||
if (!normalizedIp) {
|
||||
return false;
|
||||
}
|
||||
const candidate = cidr.trim();
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const comparableIp = normalizeIpv4MappedAddress(normalizedIp);
|
||||
if (!candidate.includes("/")) {
|
||||
const exact = parseCanonicalIpAddress(candidate);
|
||||
if (!exact) {
|
||||
return false;
|
||||
}
|
||||
const comparableExact = normalizeIpv4MappedAddress(exact);
|
||||
return (
|
||||
comparableIp.kind() === comparableExact.kind() &&
|
||||
comparableIp.toString() === comparableExact.toString()
|
||||
);
|
||||
}
|
||||
|
||||
let parsedCidr: [ParsedIpAddress, number];
|
||||
try {
|
||||
parsedCidr = ipaddr.parseCIDR(candidate);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [baseAddress, prefixLength] = parsedCidr;
|
||||
const comparableBase = normalizeIpv4MappedAddress(baseAddress);
|
||||
if (comparableIp.kind() !== comparableBase.kind()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) {
|
||||
return comparableIp.match([comparableBase, prefixLength]);
|
||||
}
|
||||
if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) {
|
||||
return comparableIp.match([comparableBase, prefixLength]);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
36
packages/net-policy/src/ipv4.test.ts
Normal file
36
packages/net-policy/src/ipv4.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Network Policy tests cover ipv4 behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateDottedDecimalIPv4Input, validateIPv4AddressInput } from "./ipv4.js";
|
||||
|
||||
describe("net-policy/ipv4", () => {
|
||||
it("requires a value for custom bind mode", () => {
|
||||
expect(validateDottedDecimalIPv4Input(undefined)).toBe(
|
||||
"IP address is required for custom bind mode",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("")).toBe("IP address is required for custom bind mode");
|
||||
expect(validateDottedDecimalIPv4Input(" ")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts canonical dotted-decimal ipv4 only", () => {
|
||||
expect(validateDottedDecimalIPv4Input("0.0.0.0")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("192.168.1.100")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input(" 192.168.1.100 ")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("0177.0.0.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("[192.168.1.100]")).toBeUndefined();
|
||||
expect(validateDottedDecimalIPv4Input("127.1")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
expect(validateDottedDecimalIPv4Input("example.com")).toBe(
|
||||
"Invalid IPv4 address (e.g., 192.168.1.100)",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the backward-compatible alias wired to the same validation", () => {
|
||||
expect(validateIPv4AddressInput("192.168.1.100")).toBeUndefined();
|
||||
expect(validateIPv4AddressInput("bad-ip")).toBe("Invalid IPv4 address (e.g., 192.168.1.100)");
|
||||
});
|
||||
});
|
||||
18
packages/net-policy/src/ipv4.ts
Normal file
18
packages/net-policy/src/ipv4.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Network Policy module implements ipv4 behavior.
|
||||
import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
|
||||
|
||||
/** Validates the custom-bind IPv4 input and returns the user-facing error text. */
|
||||
export function validateDottedDecimalIPv4Input(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return "IP address is required for custom bind mode";
|
||||
}
|
||||
if (isCanonicalDottedDecimalIPv4(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return "Invalid IPv4 address (e.g., 192.168.1.100)";
|
||||
}
|
||||
|
||||
/** @deprecated Use validateDottedDecimalIPv4Input. */
|
||||
export function validateIPv4AddressInput(value: string | undefined): string | undefined {
|
||||
return validateDottedDecimalIPv4Input(value);
|
||||
}
|
||||
156
packages/net-policy/src/redact-sensitive-url.test.ts
Normal file
156
packages/net-policy/src/redact-sensitive-url.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// Network Policy tests cover redact sensitive url behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isSensitiveUrlQueryParamName,
|
||||
isSensitiveUrlConfigPath,
|
||||
SENSITIVE_URL_HINT_TAG,
|
||||
hasSensitiveUrlHintTag,
|
||||
redactSensitiveUrl,
|
||||
redactSensitiveUrlLikeString,
|
||||
} from "./redact-sensitive-url.js";
|
||||
|
||||
describe("redactSensitiveUrl", () => {
|
||||
it("redacts userinfo and sensitive query params from valid URLs", () => {
|
||||
expect(redactSensitiveUrl("https://user:pass@example.com/mcp?token=secret&safe=value")).toBe(
|
||||
"https://***:***@example.com/mcp?token=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats query param names case-insensitively", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?Access_Token=secret")).toBe(
|
||||
"https://example.com/mcp?Access_Token=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts encoded and invisible-spliced sensitive query param names", () => {
|
||||
expect(
|
||||
redactSensitiveUrl("https://example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
|
||||
).toBe("https://example.com/mcp?client_se%E2%80%8Bcret=***&safe=value");
|
||||
});
|
||||
|
||||
it("redacts encoded sensitive query names with decoded whitespace and control separators", () => {
|
||||
expect(
|
||||
redactSensitiveUrl("https://example.com/mcp?client%5Fse%20cret=space&client%5Fse%00cret=nul"),
|
||||
).toBe("https://example.com/mcp?client_se+cret=***&client_se%00cret=***");
|
||||
});
|
||||
|
||||
it("redacts query names with plus-encoded separators", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?client_se+cret=secret&safe=value")).toBe(
|
||||
"https://example.com/mcp?client_se+cret=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-sensitive URLs unchanged", () => {
|
||||
expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe(
|
||||
"https://example.com/mcp?safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts Telegram bot tokens from URL paths", () => {
|
||||
expect(
|
||||
redactSensitiveUrl(
|
||||
"https://telegram.internal/bot123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/getMe",
|
||||
),
|
||||
).toBe("https://telegram.internal/bot***/getMe");
|
||||
expect(
|
||||
redactSensitiveUrl(
|
||||
"https://api.telegram.org/bot123456%3AABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/getMe",
|
||||
),
|
||||
).toBe("https://api.telegram.org/bot***/getMe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactSensitiveUrlLikeString", () => {
|
||||
it("redacts invalid URL-like strings", () => {
|
||||
expect(redactSensitiveUrlLikeString("//user:pass@example.com/mcp?client_secret=secret")).toBe(
|
||||
"//***:***@example.com/mcp?client_secret=***",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts encoded and invisible-spliced query names in invalid URL-like strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString("//example.com/mcp?client%5Fse%E2%80%8Bcret=secret&safe=value"),
|
||||
).toBe("//example.com/mcp?client%5Fse%E2%80%8Bcret=***&safe=value");
|
||||
});
|
||||
|
||||
it("redacts encoded query names with decoded whitespace and control separators in invalid URL-like strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"//example.com/mcp?client%5Fse%20cret=space&client%5Fse%00cret=nul",
|
||||
),
|
||||
).toBe("//example.com/mcp?client%5Fse%20cret=***&client%5Fse%00cret=***");
|
||||
});
|
||||
|
||||
it("redacts plus-spliced query names in invalid URL-like strings", () => {
|
||||
expect(redactSensitiveUrlLikeString("//example.com/mcp?client_se+cret=secret&safe=value")).toBe(
|
||||
"//example.com/mcp?client_se+cret=***&safe=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts every URL-like userinfo occurrence in arbitrary text", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"fatal https://a:b@github.com/one.git and https://c:d@github.com/two.git",
|
||||
),
|
||||
).toBe("fatal https://***:***@github.com/one.git and https://***:***@github.com/two.git");
|
||||
});
|
||||
|
||||
it("redacts protocol URLs that are too malformed to parse", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"wss://fallback-user:fallback-pass@[bad-host/socket?token=fallback-secret&keep=visible)",
|
||||
),
|
||||
).toBe("wss://***:***@[bad-host/socket?token=***&keep=visible)");
|
||||
});
|
||||
|
||||
it("redacts Telegram bot tokens from URL-like fallback strings", () => {
|
||||
expect(
|
||||
redactSensitiveUrlLikeString(
|
||||
"timeout /bot123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcd/sendMessage and keep /bot/settings",
|
||||
),
|
||||
).toBe("timeout /bot***/sendMessage and keep /bot/settings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSensitiveUrlQueryParamName", () => {
|
||||
it("matches the auth-oriented query params used by MCP SSE config redaction", () => {
|
||||
expect(isSensitiveUrlQueryParamName("token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("refresh_token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("access-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("hook-token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("passwd")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("signature")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("code")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("x-amz-signature")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("X-Amz-Security-Token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("id_token")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("app_secret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse\u200Bcret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse%20cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client%5Fse%00cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client_se+cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("client_se\u3164cret")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("credential")).toBe(true);
|
||||
expect(isSensitiveUrlQueryParamName("safe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sensitive URL config metadata", () => {
|
||||
it("recognizes config paths that may embed URL secrets", () => {
|
||||
expect(isSensitiveUrlConfigPath("models.providers.*.baseUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("mcp.servers.remote.url")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("gateway.remote.url")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes cdpUrl config paths as sensitive (browser CDP URLs can embed credentials)", () => {
|
||||
expect(isSensitiveUrlConfigPath("browser.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.remote.cdpUrl")).toBe(true);
|
||||
expect(isSensitiveUrlConfigPath("browser.profiles.staging.cdpUrl")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses an explicit url-secret hint tag", () => {
|
||||
expect(SENSITIVE_URL_HINT_TAG).toBe("url-secret");
|
||||
expect(hasSensitiveUrlHintTag({ tags: [SENSITIVE_URL_HINT_TAG] })).toBe(true);
|
||||
expect(hasSensitiveUrlHintTag({ tags: ["security"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
127
packages/net-policy/src/redact-sensitive-url.ts
Normal file
127
packages/net-policy/src/redact-sensitive-url.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// Network Policy module implements redact sensitive url behavior.
|
||||
type ConfigUiHintTags = {
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
/** Config UI hint tag for URL-like values that may embed credentials or tokens. */
|
||||
export const SENSITIVE_URL_HINT_TAG = "url-secret";
|
||||
|
||||
const SENSITIVE_URL_QUERY_PARAM_NAMES = new Set([
|
||||
"token",
|
||||
"key",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"secret",
|
||||
"access_token",
|
||||
"auth_token",
|
||||
"password",
|
||||
"pass",
|
||||
"passwd",
|
||||
"auth",
|
||||
"jwt",
|
||||
"session",
|
||||
"id_token",
|
||||
"code",
|
||||
"client_secret",
|
||||
"app_secret",
|
||||
"hook_token",
|
||||
"refresh_token",
|
||||
"signature",
|
||||
"x_amz_signature",
|
||||
"x_amz_security_token",
|
||||
"private_key",
|
||||
"credential",
|
||||
"authorization",
|
||||
]);
|
||||
// Keep in sync with FORM_BODY_KEY_SEPARATOR_RE in src/logging/redact.ts: Hangul fillers are
|
||||
// category Lo, so \p{C}\p{Z} alone would let them splice sensitive key names.
|
||||
const URL_QUERY_NAME_SEPARATOR_RE = /[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu;
|
||||
|
||||
// Telegram Bot API credentials live in `/bot<token>/...` path segments rather
|
||||
// than userinfo or query params. Keep this shape aligned with logging/redact.ts.
|
||||
const TELEGRAM_BOT_TOKEN_PATH_RE = /\/bot\d{6,}(?::|%3[aA])[A-Za-z0-9_-]{20,}(?=\/|$)/giu;
|
||||
|
||||
function redactSensitiveUrlPath(value: string): string {
|
||||
return value.replace(TELEGRAM_BOT_TOKEN_PATH_RE, "/bot***");
|
||||
}
|
||||
|
||||
function normalizeUrlQueryParamName(name: string): string {
|
||||
const stripped = name.replace(URL_QUERY_NAME_SEPARATOR_RE, "");
|
||||
try {
|
||||
return normalizeLowercaseStringOrEmpty(
|
||||
decodeURIComponent(stripped).replace(URL_QUERY_NAME_SEPARATOR_RE, ""),
|
||||
).replaceAll("-", "_");
|
||||
} catch {
|
||||
return normalizeLowercaseStringOrEmpty(stripped).replaceAll("-", "_");
|
||||
}
|
||||
}
|
||||
|
||||
/** True for auth-like URL query parameter names that should be redacted. */
|
||||
export function isSensitiveUrlQueryParamName(name: string): boolean {
|
||||
const normalized = normalizeUrlQueryParamName(name);
|
||||
return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized);
|
||||
}
|
||||
|
||||
/** True for config paths whose URL values may contain credentials or secret query params. */
|
||||
export function isSensitiveUrlConfigPath(path: string): boolean {
|
||||
if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) {
|
||||
return true;
|
||||
}
|
||||
if (path.endsWith(".cdpUrl")) {
|
||||
return true;
|
||||
}
|
||||
if (path.endsWith(".request.proxy.url")) {
|
||||
return true;
|
||||
}
|
||||
return /^mcp\.servers\.(?:\*|[^.]+)\.url$/.test(path);
|
||||
}
|
||||
|
||||
/** True when a config UI hint explicitly marks a URL-like value as secret-bearing. */
|
||||
export function hasSensitiveUrlHintTag(hint: ConfigUiHintTags | undefined): boolean {
|
||||
return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true;
|
||||
}
|
||||
|
||||
/** Redacts credentials and sensitive query params from parseable URLs. */
|
||||
export function redactSensitiveUrl(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
let mutated = false;
|
||||
const redactedPath = redactSensitiveUrlPath(parsed.pathname);
|
||||
if (redactedPath !== parsed.pathname) {
|
||||
parsed.pathname = redactedPath;
|
||||
mutated = true;
|
||||
}
|
||||
if (parsed.username || parsed.password) {
|
||||
parsed.username = parsed.username ? "***" : "";
|
||||
parsed.password = parsed.password ? "***" : "";
|
||||
mutated = true;
|
||||
}
|
||||
for (const key of Array.from(parsed.searchParams.keys())) {
|
||||
if (isSensitiveUrlQueryParamName(key)) {
|
||||
parsed.searchParams.set(key, "***");
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
return mutated ? parsed.toString() : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Redacts sensitive URL-looking substrings even when the full value is not a valid URL. */
|
||||
export function redactSensitiveUrlLikeString(value: string): string {
|
||||
const redactedUrl = redactSensitiveUrl(value);
|
||||
if (redactedUrl !== value) {
|
||||
return redactedUrl;
|
||||
}
|
||||
const redactedFallback = value
|
||||
.replace(/\/\/([^@/?#\s]+)@/g, "//***:***@")
|
||||
.replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix: string, key: string) =>
|
||||
isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match,
|
||||
);
|
||||
return redactSensitiveUrlPath(redactedFallback);
|
||||
}
|
||||
43
packages/net-policy/src/url-protocol.test.ts
Normal file
43
packages/net-policy/src/url-protocol.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasHttpUrlPrefix, isHttpsUrl, isHttpUrl, isWebSocketUrl } from "./url-protocol.js";
|
||||
|
||||
describe("hasHttpUrlPrefix", () => {
|
||||
it.each([
|
||||
["http://example.com", true],
|
||||
["HTTPS://user:pass@example.com:8443/path", true],
|
||||
["https://", true],
|
||||
[" https://example.com", false],
|
||||
["//example.com", false],
|
||||
["example.com", false],
|
||||
["wss://example.com", false],
|
||||
])("classifies %j", (value, expected) => {
|
||||
expect(hasHttpUrlPrefix(value)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsed URL protocol predicates", () => {
|
||||
it.each([
|
||||
["http://example.com", true, false, false],
|
||||
["HTTPS://user:pass@example.com:8443/path", true, true, false],
|
||||
["ws://example.com", false, false, true],
|
||||
["WSS://example.com:9443/socket", false, false, true],
|
||||
["file:///tmp/example", false, false, false],
|
||||
])("classifies %s", (value, http, https, websocket) => {
|
||||
const url = new URL(value);
|
||||
expect(isHttpUrl(url)).toBe(http);
|
||||
expect(isHttpsUrl(url)).toBe(https);
|
||||
expect(isWebSocketUrl(url)).toBe(websocket);
|
||||
});
|
||||
|
||||
it("returns false for malformed and relative strings", () => {
|
||||
expect(isHttpUrl("https://")).toBe(false);
|
||||
expect(isHttpsUrl("/relative")).toBe(false);
|
||||
expect(isWebSocketUrl("not a URL")).toBe(false);
|
||||
});
|
||||
|
||||
it("parses string inputs without changing URL constructor behavior", () => {
|
||||
expect(isHttpUrl(" HTTP://user:pass@example.com:8080/path ")).toBe(true);
|
||||
expect(isHttpsUrl(" HTTPS://example.com ")).toBe(true);
|
||||
expect(isWebSocketUrl(" WSS://example.com:9443/socket ")).toBe(true);
|
||||
});
|
||||
});
|
||||
30
packages/net-policy/src/url-protocol.ts
Normal file
30
packages/net-policy/src/url-protocol.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
const HTTP_URL_PREFIX_RE = /^https?:\/\//i;
|
||||
|
||||
function parseUrl(value: string | URL): URL | null {
|
||||
if (value instanceof URL) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasHttpUrlPrefix(value: string): boolean {
|
||||
return HTTP_URL_PREFIX_RE.test(value);
|
||||
}
|
||||
|
||||
export function isHttpUrl(value: string | URL): boolean {
|
||||
const url = parseUrl(value);
|
||||
return url?.protocol === "http:" || url?.protocol === "https:";
|
||||
}
|
||||
|
||||
export function isHttpsUrl(value: string | URL): boolean {
|
||||
return parseUrl(value)?.protocol === "https:";
|
||||
}
|
||||
|
||||
export function isWebSocketUrl(value: string | URL): boolean {
|
||||
const url = parseUrl(value);
|
||||
return url?.protocol === "ws:" || url?.protocol === "wss:";
|
||||
}
|
||||
14
packages/net-policy/src/url-userinfo.ts
Normal file
14
packages/net-policy/src/url-userinfo.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Strip username/password credentials from a URL string when it parses. */
|
||||
export function stripUrlUserInfo(value: string): string {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!parsed.username && !parsed.password) {
|
||||
return value;
|
||||
}
|
||||
parsed.username = "";
|
||||
parsed.password = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user