@apifuse/provider-sdk 2.2.0-beta.17 → 2.2.0-beta.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ import { domainToASCII } from "node:url";
2
+
3
+ const STRICT_IPV4_PATTERN =
4
+ /^(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})\.(0|[1-9]\d{0,2})$/;
5
+ const DECIMAL_COMPONENT_PATTERN = /^\d+$/;
6
+ const HEX_COMPONENT_PATTERN = /^0[xX][\da-fA-F]+$/;
7
+ const IPV6_GROUP_PATTERN = /^[\da-fA-F]{1,4}$/;
8
+ const FORMAT_CONTROL_PATTERN = /\p{Cf}/u;
9
+ const RESERVED_EGRESS_HOST_DELIMITERS = ["/", "\\", "?", "#", "@", "[", "]", " ", "%"];
10
+
11
+ const IPV4_MAPPED_PREFIX = Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff);
12
+ const IPV4_COMPATIBLE_MIN = Uint8Array.of(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2);
13
+ const IPV4_COMPATIBLE_MAX = Uint8Array.of(
14
+ 0,
15
+ 0,
16
+ 0,
17
+ 0,
18
+ 0,
19
+ 0,
20
+ 0,
21
+ 0,
22
+ 0,
23
+ 0,
24
+ 0,
25
+ 0,
26
+ 0xff,
27
+ 0xff,
28
+ 0xff,
29
+ 0xff,
30
+ );
31
+
32
+ export type EgressHostKind = "ipv4" | "ipv6" | "ipv4-mapped-ipv6" | "numeric-ambiguous" | "dns";
33
+
34
+ export type EgressHostCanonicalizationFailure =
35
+ | "not-string"
36
+ | "reserved-delimiter"
37
+ | "control-character"
38
+ | "whitespace"
39
+ | "canonicalization-empty";
40
+
41
+ export type EgressHostCanonicalizationResult =
42
+ | { readonly ok: true; readonly host: string }
43
+ | { readonly ok: false; readonly reason: EgressHostCanonicalizationFailure };
44
+
45
+ export type Ipv6CidrOverlap = "ipv4-mapped" | "ipv4-compatible";
46
+
47
+ export type Ipv6CidrParseResult =
48
+ | { readonly ok: true; readonly network: Uint8Array; readonly prefix: number }
49
+ | {
50
+ readonly ok: false;
51
+ readonly reason: "malformed";
52
+ readonly overlap?: Ipv6CidrOverlap;
53
+ }
54
+ | { readonly ok: false; readonly reason: "non-canonical-network" };
55
+
56
+ export function parseStrictIpv4(value: string): number | undefined {
57
+ const match = STRICT_IPV4_PATTERN.exec(value);
58
+ if (!match || match[0] !== value) return undefined;
59
+ const [, first, second, third, fourth] = match;
60
+ if (first === undefined || second === undefined || third === undefined || fourth === undefined)
61
+ return undefined;
62
+ const octets = [Number(first), Number(second), Number(third), Number(fourth)];
63
+ if (octets.some((octet) => octet > 255)) return undefined;
64
+ const [a, b, c, d] = octets;
65
+ if (a === undefined || b === undefined || c === undefined || d === undefined) return undefined;
66
+ return (a * 0x1000000 + b * 0x10000 + c * 0x100 + d) >>> 0;
67
+ }
68
+
69
+ function ipv4HexGroups(value: string): readonly [string, string] | undefined {
70
+ const address = parseStrictIpv4(value);
71
+ if (address === undefined) return undefined;
72
+ return [((address >>> 16) & 0xffff).toString(16), (address & 0xffff).toString(16)];
73
+ }
74
+
75
+ /** Parse the RFC 4291 IPv6 text forms accepted at both policy and runtime boundaries. */
76
+ export function parseIpv6(value: string): Uint8Array | undefined {
77
+ if (!value || value.includes("%") || value.includes("[") || value.includes("]")) return undefined;
78
+ if (value.indexOf("::") !== value.lastIndexOf("::")) return undefined;
79
+
80
+ let text = value;
81
+ if (text.includes(".")) {
82
+ const finalColon = text.lastIndexOf(":");
83
+ if (finalColon < 0) return undefined;
84
+ const groups = ipv4HexGroups(text.slice(finalColon + 1));
85
+ if (!groups) return undefined;
86
+ text = `${text.slice(0, finalColon + 1)}${groups[0]}:${groups[1]}`;
87
+ }
88
+
89
+ const compression = text.indexOf("::");
90
+ let groups: string[];
91
+ if (compression >= 0) {
92
+ const leftText = text.slice(0, compression);
93
+ const rightText = text.slice(compression + 2);
94
+ const left = leftText ? leftText.split(":") : [];
95
+ const right = rightText ? rightText.split(":") : [];
96
+ if (
97
+ left.some((group) => !IPV6_GROUP_PATTERN.test(group)) ||
98
+ right.some((group) => !IPV6_GROUP_PATTERN.test(group)) ||
99
+ left.length + right.length >= 8
100
+ )
101
+ return undefined;
102
+ groups = [...left, ...Array<string>(8 - left.length - right.length).fill("0"), ...right];
103
+ } else {
104
+ groups = text.split(":");
105
+ if (groups.length !== 8 || groups.some((group) => !IPV6_GROUP_PATTERN.test(group)))
106
+ return undefined;
107
+ }
108
+
109
+ const bytes = new Uint8Array(16);
110
+ for (let index = 0; index < groups.length; index += 1) {
111
+ const group = groups[index];
112
+ if (group === undefined) return undefined;
113
+ const parsed = Number.parseInt(group, 16);
114
+ bytes[index * 2] = parsed >>> 8;
115
+ bytes[index * 2 + 1] = parsed & 0xff;
116
+ }
117
+ return bytes;
118
+ }
119
+
120
+ function prefixMatches(address: Uint8Array, network: Uint8Array, prefix: number): boolean {
121
+ const wholeBytes = Math.floor(prefix / 8);
122
+ for (let index = 0; index < wholeBytes; index += 1) {
123
+ if (address[index] !== network[index]) return false;
124
+ }
125
+ const remainingBits = prefix % 8;
126
+ if (remainingBits === 0) return true;
127
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
128
+ return ((address[wholeBytes] ?? 0) & mask) === ((network[wholeBytes] ?? 0) & mask);
129
+ }
130
+
131
+ function compareIpv6(left: Uint8Array, right: Uint8Array): number {
132
+ for (let index = 0; index < 16; index += 1) {
133
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
134
+ if (difference !== 0) return difference;
135
+ }
136
+ return 0;
137
+ }
138
+
139
+ function cidrMaximum(network: Uint8Array, prefix: number): Uint8Array {
140
+ const maximum = network.slice();
141
+ for (let bit = prefix; bit < 128; bit += 1) {
142
+ const byteIndex = Math.floor(bit / 8);
143
+ maximum[byteIndex] = (maximum[byteIndex] ?? 0) | (1 << (7 - (bit % 8)));
144
+ }
145
+ return maximum;
146
+ }
147
+
148
+ function rangesOverlap(
149
+ leftMin: Uint8Array,
150
+ leftMax: Uint8Array,
151
+ rightMin: Uint8Array,
152
+ rightMax: Uint8Array,
153
+ ): boolean {
154
+ return compareIpv6(leftMin, rightMax) <= 0 && compareIpv6(rightMin, leftMax) <= 0;
155
+ }
156
+
157
+ function ipv6CidrOverlap(network: Uint8Array, prefix: number): Ipv6CidrOverlap | undefined {
158
+ const maximum = cidrMaximum(network, prefix);
159
+ const mappedMin = new Uint8Array(16);
160
+ mappedMin.set(IPV4_MAPPED_PREFIX);
161
+ const mappedMax = mappedMin.slice();
162
+ mappedMax.fill(0xff, 12);
163
+ if (rangesOverlap(network, maximum, mappedMin, mappedMax)) return "ipv4-mapped";
164
+ if (rangesOverlap(network, maximum, IPV4_COMPATIBLE_MIN, IPV4_COMPATIBLE_MAX))
165
+ return "ipv4-compatible";
166
+ return undefined;
167
+ }
168
+
169
+ export function parseIpv4Cidr(
170
+ value: string,
171
+ ):
172
+ | { readonly ok: true; readonly network: number; readonly prefix: number }
173
+ | { readonly ok: false; readonly reason: "malformed" | "non-canonical-network" } {
174
+ const separator = value.indexOf("/");
175
+ if (separator <= 0 || separator !== value.lastIndexOf("/"))
176
+ return { ok: false, reason: "malformed" };
177
+ const address = parseStrictIpv4(value.slice(0, separator));
178
+ const prefixText = value.slice(separator + 1);
179
+ const prefix = Number(prefixText);
180
+ if (
181
+ address === undefined ||
182
+ !Number.isInteger(prefix) ||
183
+ prefix < 0 ||
184
+ prefix > 32 ||
185
+ String(prefix) !== prefixText
186
+ )
187
+ return { ok: false, reason: "malformed" };
188
+ const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
189
+ const network = (address & mask) >>> 0;
190
+ if (address !== network) return { ok: false, reason: "non-canonical-network" };
191
+ return { ok: true, network, prefix };
192
+ }
193
+
194
+ export function parseIpv6Cidr(value: string): Ipv6CidrParseResult {
195
+ const separator = value.indexOf("/");
196
+ if (separator <= 0 || separator !== value.lastIndexOf("/"))
197
+ return { ok: false, reason: "malformed" };
198
+ const address = parseIpv6(value.slice(0, separator));
199
+ const prefixText = value.slice(separator + 1);
200
+ const prefix = Number(prefixText);
201
+ if (
202
+ address === undefined ||
203
+ !Number.isInteger(prefix) ||
204
+ prefix < 0 ||
205
+ prefix > 128 ||
206
+ String(prefix) !== prefixText
207
+ )
208
+ return { ok: false, reason: "malformed" };
209
+ const network = address.slice();
210
+ const wholeBytes = Math.floor(prefix / 8);
211
+ const remainingBits = prefix % 8;
212
+ if (remainingBits > 0) {
213
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
214
+ network[wholeBytes] = (network[wholeBytes] ?? 0) & mask;
215
+ }
216
+ network.fill(0, wholeBytes + (remainingBits > 0 ? 1 : 0));
217
+ if (compareIpv6(address, network) !== 0) return { ok: false, reason: "non-canonical-network" };
218
+ const overlap = ipv6CidrOverlap(network, prefix);
219
+ if (overlap) return { ok: false, reason: "malformed", overlap };
220
+ return { ok: true, network, prefix };
221
+ }
222
+
223
+ export function ipv4InCidr(address: number, cidr: string): boolean {
224
+ const parsed = parseIpv4Cidr(cidr);
225
+ if (!parsed.ok) return false;
226
+ return (
227
+ parsed.prefix === 0 ||
228
+ address >>> (32 - parsed.prefix) === parsed.network >>> (32 - parsed.prefix)
229
+ );
230
+ }
231
+
232
+ export function ipv6InCidr(address: Uint8Array, cidr: string): boolean {
233
+ if (address.length !== 16) return false;
234
+ const parsed = parseIpv6Cidr(cidr);
235
+ return parsed.ok && prefixMatches(address, parsed.network, parsed.prefix);
236
+ }
237
+
238
+ export function embeddedIpv4FromIpv6(address: Uint8Array): number | undefined {
239
+ if (address.length !== 16) return undefined;
240
+ const mapped = IPV4_MAPPED_PREFIX.every((byte, index) => address[index] === byte);
241
+ const compatible = address.slice(0, 12).every((byte) => byte === 0);
242
+ const embedded =
243
+ ((address[12] ?? 0) * 0x1000000 +
244
+ (address[13] ?? 0) * 0x10000 +
245
+ (address[14] ?? 0) * 0x100 +
246
+ (address[15] ?? 0)) >>>
247
+ 0;
248
+ if (mapped || (compatible && embedded !== 0 && embedded !== 1)) return embedded;
249
+ return undefined;
250
+ }
251
+
252
+ export function formatIpv6(address: Uint8Array): string {
253
+ if (address.length !== 16) throw new TypeError("IPv6 addresses must contain exactly 16 bytes");
254
+ const groups = Array.from({ length: 8 }, (_, index) =>
255
+ (((address[index * 2] ?? 0) << 8) | (address[index * 2 + 1] ?? 0)).toString(16),
256
+ );
257
+ let bestStart = -1;
258
+ let bestLength = 0;
259
+ for (let index = 0; index < groups.length; ) {
260
+ if (groups[index] !== "0") {
261
+ index += 1;
262
+ continue;
263
+ }
264
+ let end = index + 1;
265
+ while (end < groups.length && groups[end] === "0") end += 1;
266
+ if (end - index > bestLength && end - index >= 2) {
267
+ bestStart = index;
268
+ bestLength = end - index;
269
+ }
270
+ index = end;
271
+ }
272
+ if (bestStart < 0) return groups.join(":");
273
+ const left = groups.slice(0, bestStart).join(":");
274
+ const right = groups.slice(bestStart + bestLength).join(":");
275
+ return `${left}::${right}`;
276
+ }
277
+
278
+ /** The single family and ambiguity classifier used by policy and runtime matching. */
279
+ export function classifyEgressHost(host: string): EgressHostKind {
280
+ if (
281
+ hasReservedEgressHostDelimiter(host) ||
282
+ hasEgressHostControlCharacter(host) ||
283
+ /\s/u.test(host)
284
+ )
285
+ return "numeric-ambiguous";
286
+ if (parseStrictIpv4(host) !== undefined) return "ipv4";
287
+ const ipv6 = parseIpv6(host);
288
+ if (ipv6) return embeddedIpv4FromIpv6(ipv6) === undefined ? "ipv6" : "ipv4-mapped-ipv6";
289
+ if (host.includes(":")) return "numeric-ambiguous";
290
+ const labels = host.split(".");
291
+ if (
292
+ labels.every(
293
+ (label) =>
294
+ DECIMAL_COMPONENT_PATTERN.exec(label)?.[0] === label ||
295
+ HEX_COMPONENT_PATTERN.exec(label)?.[0] === label,
296
+ )
297
+ )
298
+ return "numeric-ambiguous";
299
+ return "dns";
300
+ }
301
+
302
+ export function hasReservedEgressHostDelimiter(value: string): boolean {
303
+ return RESERVED_EGRESS_HOST_DELIMITERS.some((delimiter) => value.includes(delimiter));
304
+ }
305
+
306
+ export function hasEgressHostControlCharacter(value: string): boolean {
307
+ for (let index = 0; index < value.length; index += 1) {
308
+ const code = value.charCodeAt(index);
309
+ if (code <= 31 || code === 127) return true;
310
+ }
311
+ return FORMAT_CONTROL_PATTERN.test(value);
312
+ }
313
+
314
+ export function canonicalizeEgressHost(value: unknown): EgressHostCanonicalizationResult {
315
+ if (typeof value !== "string") return { ok: false, reason: "not-string" };
316
+ if (hasReservedEgressHostDelimiter(value)) return { ok: false, reason: "reserved-delimiter" };
317
+ if (hasEgressHostControlCharacter(value)) return { ok: false, reason: "control-character" };
318
+ if (/\s/u.test(value)) return { ok: false, reason: "whitespace" };
319
+ const raw = value.trim();
320
+ if (!raw) return { ok: false, reason: "canonicalization-empty" };
321
+
322
+ const ipv6 = parseIpv6(raw);
323
+ if (ipv6) return { ok: true, host: formatIpv6(ipv6) };
324
+
325
+ // Classify the spelling with one optional root-label dot removed before IDNA.
326
+ // This keeps resolver-numeric ASCII forms from being widened into canonical IPv4.
327
+ const numericCandidate = raw.endsWith(".") ? raw.slice(0, -1) : raw;
328
+ const normalizedNumericCandidate = numericCandidate.toLowerCase();
329
+ if (classifyEgressHost(normalizedNumericCandidate) === "numeric-ambiguous")
330
+ return { ok: true, host: normalizedNumericCandidate };
331
+
332
+ let ascii: string;
333
+ try {
334
+ ascii = domainToASCII(raw).toLowerCase().replace(/\.$/, "");
335
+ } catch {
336
+ return { ok: false, reason: "canonicalization-empty" };
337
+ }
338
+ if (!ascii || /^\.+$/.test(ascii)) return { ok: false, reason: "canonicalization-empty" };
339
+ return { ok: true, host: ascii };
340
+ }
@@ -5,7 +5,13 @@ import type {
5
5
  NativeTcpPortRange,
6
6
  NativeTcpTlsMode,
7
7
  } from "./types.js";
8
- import { canonicalizeEgressHost, parseIpv4Cidr } from "./native-ipv4.js";
8
+ import {
9
+ canonicalizeEgressHost,
10
+ classifyEgressHost,
11
+ formatIpv6,
12
+ parseIpv4Cidr,
13
+ parseIpv6Cidr,
14
+ } from "./native-address.js";
9
15
 
10
16
  type NativeNetworkDeclaration = NonNullable<NativeProviderConfig["network"]>;
11
17
 
@@ -24,10 +30,13 @@ const NATIVE_TCP_RULE_FIELD_RECORD = {
24
30
  const NATIVE_DYNAMIC_TCP_RULE_FIELD_RECORD = {
25
31
  sourceHost: true,
26
32
  sourceHostSuffixes: true,
33
+ sourceIpv4Cidrs: true,
34
+ sourceIpv6Cidrs: true,
27
35
  sourcePorts: true,
28
36
  sourcePortRanges: true,
29
37
  targetHostSuffixes: true,
30
38
  targetIpv4Cidrs: true,
39
+ targetIpv6Cidrs: true,
31
40
  targetPorts: true,
32
41
  targetPortRanges: true,
33
42
  tls: true,
@@ -54,10 +63,13 @@ export type StaticEgressRuleSnapshot = {
54
63
  export type DynamicEgressRuleSnapshot = {
55
64
  readonly sourceHost?: string;
56
65
  readonly sourceHostSuffixes: readonly string[];
66
+ readonly sourceIpv4Cidrs: readonly string[];
67
+ readonly sourceIpv6Cidrs: readonly string[];
57
68
  readonly sourcePorts: readonly number[];
58
69
  readonly sourcePortRanges: readonly NativeTcpPortRange[];
59
70
  readonly targetHostSuffixes: readonly string[];
60
71
  readonly targetIpv4Cidrs: readonly string[];
72
+ readonly targetIpv6Cidrs: readonly string[];
61
73
  readonly targetPorts: readonly number[];
62
74
  readonly targetPortRanges: readonly NativeTcpPortRange[];
63
75
  readonly tls: NativeTcpTlsMode;
@@ -125,6 +137,13 @@ function host(value: unknown, fieldPath: string, suffix = false): string {
125
137
  fail(`${fieldPath} must be an exact ${suffix ? "DNS suffix" : "hostname"}, not a wildcard`);
126
138
  const canonical = canonicalizeEgressHost(value);
127
139
  if (!canonical.ok) fail(`${fieldPath} must be a non-empty hostname`);
140
+ const kind = classifyEgressHost(canonical.host);
141
+ if (kind === "numeric-ambiguous")
142
+ fail(`${fieldPath} value is an unresolvable numeric-ambiguous spelling`);
143
+ if (suffix && kind !== "dns") {
144
+ const family = kind === "ipv4" ? "IPv4" : "IPv6";
145
+ fail(`${fieldPath} must be a DNS suffix, not an ${family} literal`);
146
+ }
128
147
  return canonical.host;
129
148
  }
130
149
 
@@ -162,6 +181,28 @@ function ipv4Cidrs(value: unknown, fieldPath: string): readonly string[] {
162
181
  });
163
182
  }
164
183
 
184
+ function ipv6Cidrs(value: unknown, fieldPath: string): readonly string[] {
185
+ const seen = new Set<string>();
186
+ return dataArray(value, fieldPath).map((value, index) => {
187
+ const cidrPath = `${fieldPath}[${index}]`;
188
+ if (typeof value !== "string") fail(`${cidrPath} must be an IPv6 CIDR in address/nn form`);
189
+ const parsed = parseIpv6Cidr(value);
190
+ if (!parsed.ok) {
191
+ if (parsed.reason === "non-canonical-network")
192
+ fail(`${cidrPath} must use the canonical network address with no host bits set`);
193
+ if (parsed.overlap === "ipv4-mapped")
194
+ fail(`${cidrPath} overlaps IPv4-mapped ::ffff:0:0/96 address space`);
195
+ if (parsed.overlap === "ipv4-compatible")
196
+ fail(`${cidrPath} overlaps IPv4-compatible ::/96 address space`);
197
+ fail(`${cidrPath} must be an IPv6 CIDR in address/nn form`);
198
+ }
199
+ const duplicateKey = `${formatIpv6(parsed.network)}/${parsed.prefix}`;
200
+ if (seen.has(duplicateKey)) fail(`${fieldPath} must not contain duplicate CIDRs`);
201
+ seen.add(duplicateKey);
202
+ return value;
203
+ });
204
+ }
205
+
165
206
  function ranges(value: unknown, fieldPath: string): readonly NativeTcpPortRange[] {
166
207
  return dataArray(value, fieldPath).map((value, index) => {
167
208
  const rangePath = `${fieldPath}[${index}]`;
@@ -216,8 +257,23 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
216
257
  rule.sourceHostSuffixes === undefined
217
258
  ? []
218
259
  : hostSuffixes(rule.sourceHostSuffixes, `${fieldPath}.sourceHostSuffixes`);
219
- if (sourceHost === undefined && sourceHostSuffixes.length === 0)
220
- fail(`${fieldPath} must declare sourceHost or a non-empty sourceHostSuffixes list`);
260
+ const sourceIpv4Cidrs =
261
+ rule.sourceIpv4Cidrs === undefined
262
+ ? []
263
+ : ipv4Cidrs(rule.sourceIpv4Cidrs, `${fieldPath}.sourceIpv4Cidrs`);
264
+ const sourceIpv6Cidrs =
265
+ rule.sourceIpv6Cidrs === undefined
266
+ ? []
267
+ : ipv6Cidrs(rule.sourceIpv6Cidrs, `${fieldPath}.sourceIpv6Cidrs`);
268
+ if (
269
+ sourceHost === undefined &&
270
+ sourceHostSuffixes.length === 0 &&
271
+ sourceIpv4Cidrs.length === 0 &&
272
+ sourceIpv6Cidrs.length === 0
273
+ )
274
+ fail(
275
+ `${fieldPath} must declare sourceHost or a non-empty sourceHostSuffixes, sourceIpv4Cidrs, or sourceIpv6Cidrs list`,
276
+ );
221
277
  const sourcePorts =
222
278
  rule.sourcePorts === undefined
223
279
  ? []
@@ -236,9 +292,17 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
236
292
  rule.targetIpv4Cidrs === undefined
237
293
  ? []
238
294
  : ipv4Cidrs(rule.targetIpv4Cidrs, `${fieldPath}.targetIpv4Cidrs`);
239
- if (targetHostSuffixes.length === 0 && targetIpv4Cidrs.length === 0)
295
+ const targetIpv6Cidrs =
296
+ rule.targetIpv6Cidrs === undefined
297
+ ? []
298
+ : ipv6Cidrs(rule.targetIpv6Cidrs, `${fieldPath}.targetIpv6Cidrs`);
299
+ if (
300
+ targetHostSuffixes.length === 0 &&
301
+ targetIpv4Cidrs.length === 0 &&
302
+ targetIpv6Cidrs.length === 0
303
+ )
240
304
  fail(
241
- `${fieldPath} must declare a non-empty targetHostSuffixes or targetIpv4Cidrs list`,
305
+ `${fieldPath} must declare a non-empty targetHostSuffixes, targetIpv4Cidrs, or targetIpv6Cidrs list`,
242
306
  );
243
307
  const targetPorts =
244
308
  rule.targetPorts === undefined
@@ -253,10 +317,13 @@ export function parseNativeEgressPolicy(value: unknown): NativeEgressPolicySnaps
253
317
  return {
254
318
  ...(sourceHost === undefined ? {} : { sourceHost }),
255
319
  sourceHostSuffixes,
320
+ sourceIpv4Cidrs,
321
+ sourceIpv6Cidrs,
256
322
  sourcePorts,
257
323
  sourcePortRanges,
258
324
  targetHostSuffixes,
259
325
  targetIpv4Cidrs,
326
+ targetIpv6Cidrs,
260
327
  targetPorts,
261
328
  targetPortRanges,
262
329
  tls: tls(rule.tls, `${fieldPath}.tls`),
@@ -21,11 +21,14 @@ import {
21
21
  } from "../native-egress-policy.js";
22
22
  import {
23
23
  canonicalizeEgressHost,
24
- classifyEgressTargetHost,
24
+ classifyEgressHost,
25
25
  type EgressHostCanonicalizationFailure,
26
+ embeddedIpv4FromIpv6,
26
27
  ipv4InCidr,
28
+ ipv6InCidr,
29
+ parseIpv6,
27
30
  parseStrictIpv4,
28
- } from "../native-ipv4.js";
31
+ } from "../native-address.js";
29
32
  import type {
30
33
  NativeNetworkClient,
31
34
  NativeNetworkConnectInput,
@@ -1209,15 +1212,48 @@ function matchesDnsSuffix(host: string, suffix: string): boolean {
1209
1212
  return host === suffix || host.endsWith(`.${suffix}`);
1210
1213
  }
1211
1214
 
1212
- function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
1213
- const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
1215
+ /** Internal validator-independent source selector matcher. */
1216
+ export function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
1217
+ const kind = classifyEgressHost(host);
1218
+ if (kind === "numeric-ambiguous") return false;
1219
+ const hasSelector =
1220
+ rule.sourceHost !== undefined ||
1221
+ rule.sourceHostSuffixes.length > 0 ||
1222
+ rule.sourceIpv4Cidrs.length > 0 ||
1223
+ rule.sourceIpv6Cidrs.length > 0;
1214
1224
  if (!hasSelector) return false;
1215
- return (
1216
- host === rule.sourceHost ||
1217
- rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix))
1225
+ if (host === rule.sourceHost) return true;
1226
+ return matchesHostFamilySelectors(
1227
+ host,
1228
+ rule.sourceHostSuffixes,
1229
+ rule.sourceIpv4Cidrs,
1230
+ rule.sourceIpv6Cidrs,
1218
1231
  );
1219
1232
  }
1220
1233
 
1234
+ function matchesHostFamilySelectors(
1235
+ host: string,
1236
+ hostSuffixes: readonly string[],
1237
+ ipv4Cidrs: readonly string[],
1238
+ ipv6Cidrs: readonly string[],
1239
+ ): boolean {
1240
+ const kind = classifyEgressHost(host);
1241
+ if (kind === "ipv4") {
1242
+ const address = parseStrictIpv4(host);
1243
+ return address !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(address, cidr));
1244
+ }
1245
+ if (kind === "ipv4-mapped-ipv6") {
1246
+ const address = parseIpv6(host);
1247
+ const embedded = address && embeddedIpv4FromIpv6(address);
1248
+ return embedded !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(embedded, cidr));
1249
+ }
1250
+ if (kind === "ipv6") {
1251
+ const address = parseIpv6(host);
1252
+ return address !== undefined && ipv6Cidrs.some((cidr) => ipv6InCidr(address, cidr));
1253
+ }
1254
+ return kind === "dns" && hostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix));
1255
+ }
1256
+
1221
1257
  function matchesPortSelectors(
1222
1258
  port: number,
1223
1259
  ports: readonly number[],
@@ -1253,16 +1289,11 @@ function matchesDynamicRuleSelectors(
1253
1289
  }
1254
1290
 
1255
1291
  function matchesDynamicTargetHost(rule: DynamicEgressRuleSnapshot, targetHost: string): boolean {
1256
- const targetKind = classifyEgressTargetHost(targetHost);
1257
- if (targetKind === "ipv4") {
1258
- const targetIp = parseStrictIpv4(targetHost);
1259
- return (
1260
- targetIp !== undefined && rule.targetIpv4Cidrs.some((cidr) => ipv4InCidr(targetIp, cidr))
1261
- );
1262
- }
1263
- return (
1264
- targetKind === "dns" &&
1265
- rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix))
1292
+ return matchesHostFamilySelectors(
1293
+ targetHost,
1294
+ rule.targetHostSuffixes,
1295
+ rule.targetIpv4Cidrs,
1296
+ rule.targetIpv6Cidrs,
1266
1297
  );
1267
1298
  }
1268
1299
 
@@ -1487,7 +1518,7 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
1487
1518
  ? [index]
1488
1519
  : [],
1489
1520
  );
1490
- const targetKind = classifyEgressTargetHost(diagnosticTargetHost);
1521
+ const targetKind = classifyEgressHost(diagnosticTargetHost);
1491
1522
  let selectorDetails: string;
1492
1523
  if (sourceMatchingRuleIndices.length > 0) {
1493
1524
  const failedByRule: string[] = [];
package/src/types.ts CHANGED
@@ -1319,9 +1319,10 @@ export interface NativeTcpEgressRule {
1319
1319
  * Bounded native TCP egress discovered through a declared bootstrap endpoint.
1320
1320
  * Host suffixes are exact DNS suffixes, not wildcard patterns.
1321
1321
  * Dynamic rules must declare at least one target host selector through
1322
- * targetHostSuffixes and/or targetIpv4Cidrs. IPv4-literal grant targets match
1323
- * only targetIpv4Cidrs, while DNS-name targets match only targetHostSuffixes.
1324
- * CIDRs are exact IPv4 networks in a.b.c.d/nn form.
1322
+ * targetHostSuffixes, targetIpv4Cidrs, and/or targetIpv6Cidrs. Literal grant
1323
+ * sources and targets match only same-family selectors (except exact
1324
+ * sourceHost); DNS names match only host/suffix selectors. IPv4-mapped and
1325
+ * IPv4-compatible IPv6 literals are authorized only by IPv4 CIDRs.
1325
1326
  *
1326
1327
  * Dynamic rules are ordered. The first rule whose source, target, port, and TLS
1327
1328
  * selectors match exclusively owns the grant; its ttlMs and maxGrants bounds
@@ -1332,10 +1333,13 @@ export interface NativeTcpEgressRule {
1332
1333
  export interface NativeTcpDynamicEgressRule {
1333
1334
  readonly sourceHost?: string;
1334
1335
  readonly sourceHostSuffixes?: readonly string[];
1336
+ readonly sourceIpv4Cidrs?: readonly string[];
1337
+ readonly sourceIpv6Cidrs?: readonly string[];
1335
1338
  readonly sourcePorts?: readonly number[];
1336
1339
  readonly sourcePortRanges?: readonly NativeTcpPortRange[];
1337
1340
  readonly targetHostSuffixes?: readonly string[];
1338
1341
  readonly targetIpv4Cidrs?: readonly string[];
1342
+ readonly targetIpv6Cidrs?: readonly string[];
1339
1343
  readonly targetPorts?: readonly number[];
1340
1344
  readonly targetPortRanges?: readonly NativeTcpPortRange[];
1341
1345
  readonly tls: NativeTcpTlsMode;
@@ -1,22 +0,0 @@
1
- export type EgressHostCanonicalizationFailure = "not-string" | "reserved-delimiter" | "control-character" | "whitespace" | "canonicalization-empty";
2
- export type EgressHostCanonicalizationResult = {
3
- readonly ok: true;
4
- readonly host: string;
5
- } | {
6
- readonly ok: false;
7
- readonly reason: EgressHostCanonicalizationFailure;
8
- };
9
- export declare function parseStrictIpv4(value: string): number | undefined;
10
- export declare function classifyEgressTargetHost(host: string): "ipv4" | "numeric-ambiguous" | "dns";
11
- export declare function hasReservedEgressHostDelimiter(value: string): boolean;
12
- export declare function hasEgressHostControlCharacter(value: string): boolean;
13
- export declare function canonicalizeEgressHost(value: unknown): EgressHostCanonicalizationResult;
14
- export declare function parseIpv4Cidr(value: string): {
15
- readonly ok: true;
16
- readonly network: number;
17
- readonly prefix: number;
18
- } | {
19
- readonly ok: false;
20
- readonly reason: "malformed" | "non-canonical-network";
21
- };
22
- export declare function ipv4InCidr(address: number, cidr: string): boolean;