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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
{
"name": "@openclaw/net-policy",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./ip": {
"types": "./dist/ip.d.mts",
"import": "./dist/ip.mjs",
"default": "./dist/ip.mjs"
},
"./ipv4": {
"types": "./dist/ipv4.d.mts",
"import": "./dist/ipv4.mjs",
"default": "./dist/ipv4.mjs"
},
"./redact-sensitive-url": {
"types": "./dist/redact-sensitive-url.d.mts",
"import": "./dist/redact-sensitive-url.mjs",
"default": "./dist/redact-sensitive-url.mjs"
},
"./url-protocol": {
"types": "./dist/url-protocol.d.mts",
"import": "./dist/url-protocol.mjs",
"default": "./dist/url-protocol.mjs"
},
"./url-userinfo": {
"types": "./dist/url-userinfo.d.mts",
"import": "./dist/url-userinfo.mjs",
"default": "./dist/url-userinfo.mjs"
}
},
"scripts": {
"build": "tsdown src/index.ts src/ip.ts src/ipv4.ts src/redact-sensitive-url.ts src/url-protocol.ts src/url-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"ipaddr.js": "2.4.0"
}
}

View 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";

View 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;

View 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);
}
});
});

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

View 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)");
});
});

View 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);
}

View 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);
});
});

View 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);
}

View 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);
});
});

View 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:";
}

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