refactor: extract net policy package

This commit is contained in:
Peter Steinberger
2026-05-29 09:44:21 +01:00
parent 03ac6e3171
commit f4c6c0aec4
55 changed files with 1034 additions and 65 deletions

View File

@@ -9,6 +9,7 @@ queries:
paths:
- src
- extensions
- packages/net-policy/src
paths-ignore:
- "**/node_modules"

View File

@@ -15,7 +15,6 @@ query-filters:
paths:
- src/infra/net
- src/shared/net
- src/agents/tools/web-fetch.ts
- src/agents/tools/web-guarded-fetch.ts
- src/agents/tools/web-shared.ts
@@ -23,6 +22,7 @@ paths:
- src/web-fetch
- src/web/provider-runtime-shared.ts
- packages/memory-host-sdk/src/host/ssrf-policy.ts
- packages/net-policy/src
paths-ignore:
- "**/node_modules"

View File

@@ -33,6 +33,7 @@ on:
- "packages/plugin-package-contract/**"
- "packages/plugin-sdk/**"
- "packages/memory-host-sdk/**"
- "packages/net-policy/**"
- "src/*.ts"
- "src/**/*.ts"
- "src/config/**"
@@ -301,7 +302,7 @@ jobs:
esac
case "${file}" in
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts)
src/*.ts|src/**/*.ts|extensions/*.ts|extensions/**/*.ts|packages/net-policy/src/*|packages/net-policy/src/**/*)
network_runtime=true
;;
esac

View File

@@ -134,7 +134,7 @@ Configure the proxy to:
Use this denylist as the starting point for any forward proxy, firewall, or egress policy.
OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `src/shared/net/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy.
OpenClaw application-level classifier logic lives in `src/infra/net/ssrf.ts` and `packages/net-policy/src/ip.ts`. The relevant parity hooks are `BLOCKED_HOSTNAMES`, `BLOCKED_IPV4_SPECIAL_USE_RANGES`, `BLOCKED_IPV6_SPECIAL_USE_RANGES`, `RFC2544_BENCHMARK_PREFIX`, and the embedded IPv4 sentinel handling for NAT64, 6to4, Teredo, ISATAP, and IPv4-mapped forms. Those files are useful references when maintaining an external proxy policy, but OpenClaw does not automatically export or enforce those rules in your proxy.
| Range or host | Why to block |
| ------------------------------------------------------------------------------------ | ---------------------------------------------------- |

View File

@@ -0,0 +1,44 @@
{
"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-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-userinfo.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"ipaddr.js": "2.4.0"
}
}

View File

@@ -0,0 +1,4 @@
export * from "./ip.js";
export * from "./ipv4.js";
export * from "./redact-sensitive-url.js";
export * from "./url-userinfo.js";

View File

@@ -0,0 +1 @@
export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"] as const;

View File

@@ -0,0 +1,187 @@
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,423 @@
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() : "";
}
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"]);
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);
}
export function isIpv4Address(address: ParsedIpAddress): address is ipaddr.IPv4 {
return address.kind() === "ipv4";
}
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);
}
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);
}
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);
}
export function normalizeIpAddress(raw: string | undefined): string | undefined {
const parsed = parseCanonicalIpAddress(raw);
if (!parsed) {
return undefined;
}
const normalized = normalizeIpv4MappedAddress(parsed);
return normalizeLowercaseStringOrEmpty(normalized.toString());
}
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);
}
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;
}
export function isLoopbackIpAddress(raw: string | undefined): boolean {
const parsed = parseCanonicalIpAddress(raw);
if (!parsed) {
return false;
}
const normalized = normalizeIpv4MappedAddress(parsed);
return normalized.range() === "loopback";
}
export function isLinkLocalIpAddress(raw: string | undefined): boolean {
const parsed = parseLooseIpAddress(raw);
if (!parsed) {
return false;
}
const normalized = normalizeIpv4MappedAddress(parsed);
if (isIpv4Address(normalized)) {
return normalized.range() === "linkLocal";
}
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
if (embeddedIpv4?.range() === "linkLocal") {
return true;
}
return normalized.range() === "linkLocal";
}
export function isCloudMetadataIpAddress(raw: string | undefined): boolean {
const parsed = parseLooseIpAddress(raw);
if (!parsed) {
return false;
}
const normalized = normalizeIpv4MappedAddress(parsed);
if (isIpv6Address(normalized)) {
const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(normalized);
if (embeddedIpv4 && CLOUD_METADATA_IP_ADDRESSES.has(embeddedIpv4.toString())) {
return true;
}
}
return CLOUD_METADATA_IP_ADDRESSES.has(normalized.toString());
}
export function isPrivateOrLoopbackIpAddress(raw: string | undefined): boolean {
const parsed = parseCanonicalIpAddress(raw);
if (!parsed) {
return false;
}
const normalized = normalizeIpv4MappedAddress(parsed);
if (isIpv4Address(normalized)) {
return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
}
return isBlockedSpecialUseIpv6Address(normalized);
}
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;
}
export function isRfc1918Ipv4Address(raw: string | undefined): boolean {
const parsed = parseCanonicalIpAddress(raw);
if (!parsed || !isIpv4Address(parsed)) {
return false;
}
return parsed.range() === "private";
}
export function isCarrierGradeNatIpv4Address(raw: string | undefined): boolean {
const parsed = parseCanonicalIpAddress(raw);
if (!parsed || !isIpv4Address(parsed)) {
return false;
}
return parsed.range() === "carrierGradeNat";
}
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("."));
}
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;
}
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,35 @@
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,16 @@
import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
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,85 @@
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("keeps non-sensitive URLs unchanged", () => {
expect(redactSensitiveUrl("https://example.com/mcp?safe=value")).toBe(
"https://example.com/mcp?safe=value",
);
});
});
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 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)");
});
});
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("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,82 @@
type ConfigUiHintTags = {
tags?: string[];
};
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
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",
"client_secret",
"hook_token",
"refresh_token",
"signature",
]);
export function isSensitiveUrlQueryParamName(name: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(name).replaceAll("-", "_");
return SENSITIVE_URL_QUERY_PARAM_NAMES.has(normalized);
}
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);
}
export function hasSensitiveUrlHintTag(hint: ConfigUiHintTags | undefined): boolean {
return hint?.tags?.includes(SENSITIVE_URL_HINT_TAG) === true;
}
export function redactSensitiveUrl(value: string): string {
try {
const parsed = new URL(value);
let mutated = false;
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;
}
}
export function redactSensitiveUrlLikeString(value: string): string {
const redactedUrl = redactSensitiveUrl(value);
if (redactedUrl !== value) {
return redactedUrl;
}
return value
.replace(/\/\/([^@/?#\s]+)@/g, "//***:***@")
.replace(/([?&])([^=&]+)=([^&]*)/g, (match, prefix: string, key: string) =>
isSensitiveUrlQueryParamName(key) ? `${prefix}${key}=***` : match,
);
}

View File

@@ -0,0 +1,13 @@
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;
}
}

View File

@@ -17,5 +17,8 @@
},
"scripts": {
"build": "tsdown src/index.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"@openclaw/gateway-client": "workspace:*"
}
}

View File

@@ -1,4 +1,4 @@
import { GatewayClient } from "../../../src/gateway/client.js";
import { GatewayClient } from "@openclaw/gateway-client";
import { EventHub } from "./event-hub.js";
import type {
ConnectableOpenClawTransport,

12
pnpm-lock.yaml generated
View File

@@ -1788,11 +1788,21 @@ importers:
packages/memory-host-sdk: {}
packages/net-policy:
dependencies:
ipaddr.js:
specifier: 2.4.0
version: 2.4.0
packages/plugin-package-contract: {}
packages/plugin-sdk: {}
packages/sdk: {}
packages/sdk:
dependencies:
'@openclaw/gateway-client':
specifier: workspace:*
version: link:../gateway-client
packages/speech-core:
dependencies:

View File

@@ -5,10 +5,12 @@ import {
} from "./lib/bundled-plugin-paths.mjs";
const RUN_NODE_PACKAGE_SOURCE_ROOTS = [
// Gateway runtime code now lives in package sources, but pnpm dev/watch still
// runs the root dist entrypoint. Treat these package roots like src/.
// Root runtime code imports these package sources through tsconfig aliases,
// while pnpm dev/watch still runs the root dist entrypoint. Treat them like
// src/ so edits restart the same process that consumes them.
"packages/gateway-client/src",
"packages/gateway-protocol/src",
"packages/net-policy/src",
];
export const runNodeSourceRoots = [

View File

@@ -9,6 +9,7 @@ import type {
JsonSchemaValidator,
jsonSchemaValidator,
} from "@modelcontextprotocol/sdk/validation/types.js";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { Compile } from "typebox/compile";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { logWarn } from "../logger.js";
@@ -17,7 +18,6 @@ import {
findJsonSchemaShapeError,
normalizeJsonSchemaForTypeBox,
} from "../shared/json-schema-defaults.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
import type {

View File

@@ -1,7 +1,7 @@
import {
redactSensitiveUrl,
redactSensitiveUrlLikeString,
} from "../shared/net/redact-sensitive-url.js";
} from "@openclaw/net-policy/redact-sensitive-url";
import { isMcpConfigRecord, toMcpStringRecord } from "./mcp-config-shared.js";
export type HttpMcpTransportType = "sse" | "streamable-http";

View File

@@ -1,3 +1,8 @@
import {
isCloudMetadataIpAddress,
isLinkLocalIpAddress,
parseCanonicalIpAddress,
} from "@openclaw/net-policy/ip";
import {
fetchWithSsrFGuard,
withTrustedEnvProxyGuardedFetchMode,
@@ -12,11 +17,6 @@ import {
import type { Model } from "../llm/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveDebugProxySettings } from "../proxy-capture/env.js";
import {
isCloudMetadataIpAddress,
isLinkLocalIpAddress,
parseCanonicalIpAddress,
} from "../shared/net/ip.js";
import { emitModelTransportDebug } from "./model-transport-debug.js";
import { formatModelTransportDebugUrl } from "./model-transport-url.js";
import {

View File

@@ -1,11 +1,11 @@
import fs from "node:fs/promises";
import path from "node:path";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { sanitizeHostExecEnv } from "../infra/host-env-security.js";
import { withTempDir } from "../infra/install-source-utils.js";
import { writeJson } from "../infra/json-files.js";
import { parseGitPluginSpec } from "../plugins/git-install.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveUserPath } from "../utils.js";

View File

@@ -1,4 +1,4 @@
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
import { stripUrlUserInfo } from "@openclaw/net-policy/url-userinfo";
import { asFiniteNumber } from "../shared/number-coercion.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";

View File

@@ -1,3 +1,4 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { normalizeChannelId } from "../../channels/plugins/index.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { formatCliCommand } from "../../cli/command-format.js";
@@ -11,7 +12,6 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { redactSensitiveUrlLikeString } from "../../shared/net/redact-sensitive-url.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import {

View File

@@ -1,3 +1,4 @@
import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4";
import { formatPortRangeHint } from "../cli/error-format.js";
import { parsePort } from "../cli/shared/parse-port.js";
import { resolveGatewayPort } from "../config/config.js";
@@ -12,7 +13,6 @@ import {
import { findTailscaleBinary } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { normalizeOptionalString, readStringValue } from "../shared/string-coerce.js";
import { normalizeStringEntries } from "../shared/string-normalization.js";
import { note } from "../terminal/note.js";

View File

@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -11,7 +12,6 @@ import type { GatewayProbeResult, probeGateway as probeGatewayFn } from "../gate
import type { MemoryProviderStatus } from "../memory-host-sdk/engine-storage.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,

View File

@@ -1,10 +1,10 @@
import { createSubsystemLogger } from "../logging/subsystem.js";
import { type ConfigUiHints } from "../shared/config-ui-hints-types.js";
import {
hasSensitiveUrlHintTag,
isSensitiveUrlConfigPath,
redactSensitiveUrlLikeString,
} from "../shared/net/redact-sensitive-url.js";
} from "@openclaw/net-policy/redact-sensitive-url";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { type ConfigUiHints } from "../shared/config-ui-hints-types.js";
import { isRecord as isObjectRecord } from "../shared/record-coerce.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import {

View File

@@ -1,4 +1,4 @@
import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js";
import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url";
import { VERSION } from "../version.js";
import { FIELD_HELP } from "./schema.help.js";
import type { ConfigUiHints } from "./schema.hints.js";

View File

@@ -1,5 +1,5 @@
import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-url";
import { describe, expect, it } from "vitest";
import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js";
import { computeBaseConfigSchemaResponse } from "./schema-base.js";
type TestJsonSchema = {

View File

@@ -1,7 +1,7 @@
import { isSensitiveUrlConfigPath } from "@openclaw/net-policy/redact-sensitive-url";
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js";
import { isSensitiveUrlConfigPath } from "../shared/net/redact-sensitive-url.js";
import { FIELD_HELP } from "./schema.help.js";
import { testApi, isPluginOwnedChannelHintPath, isSensitiveConfigPath } from "./schema.hints.js";
import { FIELD_LABELS } from "./schema.labels.js";

View File

@@ -1,10 +1,10 @@
import { z } from "zod";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ConfigUiHints } from "../shared/config-ui-hints-types.js";
import {
isSensitiveUrlConfigPath,
SENSITIVE_URL_HINT_TAG,
} from "../shared/net/redact-sensitive-url.js";
} from "@openclaw/net-policy/redact-sensitive-url";
import { z } from "zod";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ConfigUiHints } from "../shared/config-ui-hints-types.js";
import { FIELD_HELP } from "./schema.help.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { applyDerivedTags } from "./schema.tags.js";

View File

@@ -1,5 +1,5 @@
import { SENSITIVE_URL_HINT_TAG } from "@openclaw/net-policy/redact-sensitive-url";
import { beforeAll, describe, expect, it } from "vitest";
import { SENSITIVE_URL_HINT_TAG } from "../shared/net/redact-sensitive-url.js";
import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";

View File

@@ -1,4 +1,5 @@
import path from "node:path";
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isPathInside } from "../infra/path-guards.js";
import { planManifestModelCatalogSuppressions } from "../model-catalog/index.js";
@@ -34,7 +35,6 @@ import {
formatUnsafeGatewayTailscaleNoAuthMessage,
isUnsafeGatewayTailscaleNoAuth,
} from "../shared/gateway-tailscale-auth-policy.js";
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { isRecord, resolveUserPath } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";

View File

@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
@@ -13,8 +15,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
import { loadDeviceAuthToken } from "../infra/device-auth-store.js";
import { loadOrCreateDeviceIdentity, type DeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
GATEWAY_CLIENT_MODES,

View File

@@ -1,6 +1,6 @@
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { resolveConfigPath, resolveGatewayPort } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { isSecureWebSocketUrl } from "./net.js";

View File

@@ -1,6 +1,6 @@
import { isIpv6Address, parseCanonicalIpAddress } from "@openclaw/net-policy/ip";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getTailnetHostname } from "../infra/tailscale.js";
import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
export const TAILSCALE_EXPOSURE_OPTIONS = [

View File

@@ -1,5 +1,12 @@
import type { IncomingMessage } from "node:http";
import net from "node:net";
import {
isCanonicalDottedDecimalIPv4,
isIpInCidr,
isLoopbackIpAddress,
isPrivateOrLoopbackIpAddress,
normalizeIpAddress,
} from "@openclaw/net-policy/ip";
import type { GatewayBindMode } from "../config/types.gateway.js";
import {
resetContainerEnvironmentCacheForTest,
@@ -12,13 +19,6 @@ import {
type NetworkInterfacesSnapshot,
} from "../infra/network-interfaces.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import {
isCanonicalDottedDecimalIPv4,
isIpInCidr,
isLoopbackIpAddress,
isPrivateOrLoopbackIpAddress,
normalizeIpAddress,
} from "../shared/net/ip.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
/**

View File

@@ -1,9 +1,9 @@
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isLoopbackIpAddress } from "../shared/net/ip.js";
import { resolveGatewayClientBootstrap } from "./client-bootstrap.js";
import { startGatewayClientWhenEventLoopReady } from "./client-start-readiness.js";
import { GatewayClient, type GatewayClientOptions } from "./client.js";

View File

@@ -1,5 +1,5 @@
import net from "node:net";
import { isPrivateOrLoopbackIpAddress } from "../shared/net/ip.js";
import { isPrivateOrLoopbackIpAddress } from "@openclaw/net-policy/ip";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,

View File

@@ -1,4 +1,4 @@
import { isLoopbackIpAddress } from "../../shared/net/ip.js";
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import { getActiveManagedProxyLoopbackMode } from "./proxy/active-proxy-state.js";
import { SsrFBlockedError } from "./ssrf.js";

View File

@@ -15,8 +15,8 @@ import {
import type { ProxyConfig } from "../../../config/zod-schema.proxy.js";
export type ProxyLoopbackMode = NonNullable<NonNullable<ProxyConfig>["loopbackMode"]>;
import { isLoopbackIpAddress } from "@openclaw/net-policy/ip";
import { logInfo, logWarn } from "../../../logger.js";
import { isLoopbackIpAddress } from "../../../shared/net/ip.js";
import { forceResetGlobalDispatcher } from "../undici-global-dispatcher.js";
import {
getActiveManagedProxyLoopbackMode,

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { blockedIpv6MulticastLiterals } from "../../shared/net/ip-test-fixtures.js";
import { blockedIpv6MulticastLiterals } from "../../../packages/net-policy/src/ip-test-fixtures.js";
import {
assertHostnameAllowedWithPolicy,
isBlockedHostnameOrIp,

View File

@@ -1,6 +1,5 @@
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
import { lookup as dnsLookup } from "node:dns/promises";
import type { Dispatcher } from "undici";
import {
extractEmbeddedIpv4FromIpv6,
isCloudMetadataIpAddress,
@@ -14,7 +13,8 @@ import {
isLegacyIpv4Literal,
parseCanonicalIpAddress,
parseLooseIpAddress,
} from "../../shared/net/ip.js";
} from "@openclaw/net-policy/ip";
import type { Dispatcher } from "undici";
import { normalizeUniqueStringEntries } from "../../shared/string-normalization.js";
import { normalizeHostname } from "./hostname.js";
import {

View File

@@ -1,4 +1,4 @@
import { isIpInCidr } from "../shared/net/ip.js";
import { isIpInCidr } from "@openclaw/net-policy/ip";
import { uniqueStrings } from "../shared/string-normalization.js";
import { listExternalInterfaceAddresses, readNetworkInterfaces } from "./network-interfaces.js";

View File

@@ -132,6 +132,7 @@ describe("watch-node script", () => {
expect(watchPaths).toContain("extensions");
expect(watchPaths).toContain("packages/gateway-client/src");
expect(watchPaths).toContain("packages/gateway-protocol/src");
expect(watchPaths).toContain("packages/net-policy/src");
expect(watchPaths).toContain("tsdown.config.ts");
expect(watchOptions.ignoreInitial).toBe(true);
expect(watchOptions.ignored("src")).toBe(false);
@@ -139,6 +140,8 @@ describe("watch-node script", () => {
expect(watchOptions.ignored("packages/gateway-client/src/client.ts")).toBe(false);
expect(watchOptions.ignored("packages/gateway-client/src/client.test.ts")).toBe(true);
expect(watchOptions.ignored("packages/gateway-protocol/src/schema/cron.ts")).toBe(false);
expect(watchOptions.ignored("packages/net-policy/src/ip.ts")).toBe(false);
expect(watchOptions.ignored("packages/net-policy/src/ip.test.ts")).toBe(true);
expect(watchOptions.ignored("extensions")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/dist")).toBe(true);

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import { isSensitiveUrlQueryParamName } from "@openclaw/net-policy/redact-sensitive-url";
import { isSecretRefShape } from "../config/redact-snapshot.secret-ref.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { isSensitiveUrlQueryParamName } from "../shared/net/redact-sensitive-url.js";
import { asOptionalRecord } from "../shared/record-coerce.js";
import { redactSensitiveText } from "./redact.js";

View File

@@ -1,6 +1,5 @@
// Shared helpers for parsing MEDIA tokens from command/stdout text.
import { parseFenceSpans } from "../markdown/fences.js";
import {
extractEmbeddedIpv4FromIpv6,
isBlockedSpecialUseIpv4Address,
@@ -10,7 +9,8 @@ import {
isLegacyIpv4Literal,
parseCanonicalIpAddress,
parseLooseIpAddress,
} from "../shared/net/ip.js";
} from "@openclaw/net-policy/ip";
import { parseFenceSpans } from "../markdown/fences.js";
import { parseAudioTag } from "./audio-tags.js";
// Allow optional wrapping backticks and punctuation after the token; capture the core token.

View File

@@ -1,4 +1,12 @@
import os from "node:os";
import {
isCarrierGradeNatIpv4Address,
isIpv4Address,
isIpv6Address,
isLoopbackIpAddress,
isRfc1918Ipv4Address,
parseCanonicalIpAddress,
} from "@openclaw/net-policy/ip";
import { resolveGatewayPort } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
@@ -11,14 +19,6 @@ import {
} from "../infra/network-interfaces.js";
import { PAIRING_SETUP_BOOTSTRAP_PROFILE } from "../shared/device-bootstrap-profile.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import {
isCarrierGradeNatIpv4Address,
isIpv4Address,
isIpv6Address,
isLoopbackIpAddress,
isRfc1918Ipv4Address,
parseCanonicalIpAddress,
} from "../shared/net/ip.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,

View File

@@ -2,8 +2,8 @@ import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
const runCommandWithTimeoutMock = vi.fn();
const installPluginFromInstalledPackageDirMock = vi.fn();

View File

@@ -1,6 +1,7 @@
import "../infra/fs-safe-defaults.js";
import { createHash } from "node:crypto";
import path from "node:path";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { withTempDir } from "../infra/install-source-utils.js";
import { replaceDirectoryAtomic } from "../infra/replace-file.js";
import {
@@ -8,7 +9,6 @@ import {
createSafeNpmInstallEnv,
} from "../infra/safe-package-install.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveUserPath } from "../utils.js";

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { resolveArchiveKind } from "../infra/archive.js";
import { formatErrorMessage } from "../infra/errors.js";
import { pathExists } from "../infra/fs-safe.js";
@@ -9,7 +10,6 @@ import { tryReadJson } from "../infra/json-files.js";
import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js";
import { isPathInside } from "../infra/path-guards.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { redactSensitiveUrlLikeString } from "../shared/net/redact-sensitive-url.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveUserPath } from "../utils.js";

View File

@@ -1,3 +1,4 @@
import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4";
import { formatPortRangeHint } from "../cli/error-format.js";
import {
normalizeGatewayTokenInput,
@@ -21,7 +22,6 @@ import { findTailscaleBinary } from "../infra/tailscale.js";
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { maskApiKey } from "../utils/mask-api-key.js";
import { t } from "./i18n/index.js";
import type { WizardPrompter } from "./prompts.js";

View File

@@ -209,6 +209,32 @@ export const sharedVitestConfig = {
find: "@openclaw/gateway-protocol",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "index.ts"),
},
{
find: "@openclaw/net-policy/ip",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "ip.ts"),
},
{
find: "@openclaw/net-policy/ipv4",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "ipv4.ts"),
},
{
find: "@openclaw/net-policy/redact-sensitive-url",
replacement: path.join(
repoRoot,
"packages",
"net-policy",
"src",
"redact-sensitive-url.ts",
),
},
{
find: "@openclaw/net-policy/url-userinfo",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "url-userinfo.ts"),
},
{
find: "@openclaw/net-policy",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "index.ts"),
},
...sourcePluginSdkSubpaths.map((subpath) => ({
find: `openclaw/plugin-sdk/${subpath}`,
replacement: path.join(repoRoot, "src", "plugin-sdk", `${subpath}.ts`),

View File

@@ -44,6 +44,14 @@
],
"@openclaw/gateway-protocol/version": ["./packages/gateway-protocol/src/version.ts"],
"@openclaw/gateway-protocol/*": ["./packages/gateway-protocol/src/*"],
"@openclaw/net-policy": ["./packages/net-policy/src/index.ts"],
"@openclaw/net-policy/ip": ["./packages/net-policy/src/ip.ts"],
"@openclaw/net-policy/ipv4": ["./packages/net-policy/src/ipv4.ts"],
"@openclaw/net-policy/redact-sensitive-url": [
"./packages/net-policy/src/redact-sensitive-url.ts"
],
"@openclaw/net-policy/url-userinfo": ["./packages/net-policy/src/url-userinfo.ts"],
"@openclaw/net-policy/*": ["./packages/net-policy/src/*"],
"@openclaw/speech-core": ["./packages/speech-core/runtime-api.ts"],
"@openclaw/speech-core/api": ["./packages/speech-core/api.ts"],
"@openclaw/speech-core/runtime-api": ["./packages/speech-core/runtime-api.ts"],

View File

@@ -371,6 +371,18 @@ function buildGatewayClientDistEntries(): Record<string, string> {
};
}
function buildNetPolicyDistEntries(): Record<string, string> {
return {
// These subpaths are imported by root runtime code and exported by the
// package. Keep the build list adjacent to package.json exports.
index: "packages/net-policy/src/index.ts",
ip: "packages/net-policy/src/ip.ts",
ipv4: "packages/net-policy/src/ipv4.ts",
"redact-sensitive-url": "packages/net-policy/src/redact-sensitive-url.ts",
"url-userinfo": "packages/net-policy/src/url-userinfo.ts",
};
}
function buildSpeechCoreDistEntries(): Record<string, string> {
return {
api: "packages/speech-core/api.ts",
@@ -405,6 +417,10 @@ function shouldExternalizeGatewayClientDependency(id: string): boolean {
);
}
function shouldExternalizeNetPolicyDependency(id: string): boolean {
return id === "ipaddr.js" || id.startsWith("ipaddr.js/");
}
function shouldExternalizeSpeechCoreDependency(id: string): boolean {
return id === "openclaw" || id.startsWith("openclaw/");
}
@@ -469,6 +485,15 @@ export default defineConfig([
neverBundle: shouldExternalizeGatewayClientDependency,
},
}),
nodeWorkspacePackageBuildConfig({
clean: true,
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,
entry: buildNetPolicyDistEntries(),
outDir: "packages/net-policy/dist",
deps: {
neverBundle: shouldExternalizeNetPolicyDependency,
},
}),
nodeWorkspacePackageBuildConfig({
clean: true,
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,