@dnsid-ai/transport 0.21.0 → 0.22.0

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.
package/README.md CHANGED
@@ -41,4 +41,4 @@ Most Node applications should use this through `@dnsid-ai/sdk/node` or profile d
41
41
 
42
42
  Node's TXT lookup API exposes neither remaining TTLs nor DNSSEC validation state. Both system and configured-server resolvers therefore return TTL `0` and `DNSSECState.UNKNOWN`: fresh verification works in `auto` mode, but SDK identity caching is disabled and `validated`/`required` modes reject the unknown DNSSEC state. To enable caching, inject a `DNSResolver` that supplies real remaining TTLs; the published zone TTL is not a safe substitute for a recursive resolver's remaining TTL.
43
43
 
44
- `createSsrfSafeFetch()` enforces unsafe-address rejection in the lookup used by the outgoing socket and returns redirects without following them automatically. Trusted test/private deployments may pass an exact `allowedUnsafeHosts` list; only RFC 1918/ULA private and loopback results are then accepted for those hosts, while link-local and other unsafe ranges remain blocked. Profile packages that accept a custom `fetch` cannot force equivalent behavior on arbitrary implementations, so custom fetch injection remains trusted infrastructure.
44
+ `createSsrfSafeFetch()` enforces unsafe-address rejection in the lookup used by the outgoing socket and returns redirects without following them automatically. Trusted test/private deployments may pass `privateAddressHosts`: exact hostnames or leading-dot suffixes such as `.test` (label-bounded, case-insensitive); only RFC 1918/ULA private and loopback results are then accepted for matching hosts, while link-local, mixed public/private, and IP-literal URLs remain blocked. Nothing is allowed by default, not even `.test`; a local `dnsid` stack needs `privateAddressHosts: ['.test']` (or `DNSID_PRIVATE_HOSTS=.test` through `configFromEnvironment`). Profile packages that accept a custom `fetch` cannot force equivalent behavior on arbitrary implementations, so custom fetch injection remains trusted infrastructure.
package/dist/index.cjs CHANGED
@@ -61,13 +61,13 @@ function createDnsidFetch(config) {
61
61
  return createFetchWithDispatcher(new import_undici.Agent({ connect }));
62
62
  }
63
63
  function createSsrfSafeFetch(config = {}, options = {}) {
64
- const allowedUnsafeHosts = new Set((options.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost));
65
- const connect = { lookup: createSsrfSafeLookup(config.dnsServer, allowedUnsafeHosts) };
64
+ const privateAddressHosts = (options.privateAddressHosts ?? []).map(import_protocol.normalizePrivateAddressHost);
65
+ const connect = { lookup: createSsrfSafeLookup(config.dnsServer, privateAddressHosts) };
66
66
  if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
67
67
  const fetchWithSafeLookup = createFetchWithDispatcher(new import_undici.Agent({ connect }));
68
68
  return async (input, init) => {
69
69
  const url = new URL(input instanceof Request ? input.url : input);
70
- if (isUnsafeIp(url.hostname) && !(allowedUnsafeHosts.has(url.hostname) && isPrivateOrLoopbackIp(url.hostname))) {
70
+ if (isUnsafeIp(url.hostname)) {
71
71
  throw new import_protocol.VerificationError(`unsafe target IP address: ${url.hostname}`, {
72
72
  code: import_protocol.VerificationCode.TLSError
73
73
  });
@@ -152,7 +152,7 @@ function createLookup(dnsServer) {
152
152
  })().catch((err) => cb(err, "", 0));
153
153
  };
154
154
  }
155
- function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ new Set()) {
155
+ function createSsrfSafeLookup(dnsServer, privateAddressHosts = []) {
156
156
  let resolverPromise = null;
157
157
  async function customResolver() {
158
158
  if (!dnsServer) throw new Error("no custom DNS server configured");
@@ -172,7 +172,7 @@ function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ ne
172
172
  void (async () => {
173
173
  const addresses = await resolveAddresses(hostname, family, dnsServer ? await customResolver() : void 0);
174
174
  const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
175
- if (unsafe && !(unsafeHostAllowed(hostname, allowedUnsafeHosts) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
175
+ if (unsafe && !(privateAddressHostMatches(hostname, privateAddressHosts) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
176
176
  throw new import_protocol.VerificationError(`unsafe resolved IP address for ${hostname}: ${unsafe.address}`, {
177
177
  code: import_protocol.VerificationCode.TLSError
178
178
  });
@@ -185,21 +185,9 @@ function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ ne
185
185
  })().catch((err) => cb(err, "", 0));
186
186
  };
187
187
  }
188
- function unsafeHostAllowed(hostname, allowedUnsafeHosts) {
188
+ function privateAddressHostMatches(hostname, privateAddressHosts) {
189
189
  const host = hostname.toLowerCase().replace(/\.$/, "");
190
- return allowedUnsafeHosts.has(hostname) || host === "test" || host.endsWith(".test");
191
- }
192
- function normalizeAllowedUnsafeHost(host) {
193
- let parsed;
194
- try {
195
- parsed = new URL(`https://${host}`);
196
- } catch {
197
- throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
198
- }
199
- if (!host || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash) {
200
- throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
201
- }
202
- return parsed.hostname;
190
+ return privateAddressHosts.some((entry) => entry.startsWith(".") ? host === entry.slice(1) || host.endsWith(entry) : host === entry);
203
191
  }
204
192
  async function resolveAddresses(hostname, family, resolver) {
205
193
  const ipFamily = net.isIP(hostname);
@@ -381,7 +369,7 @@ function fetchWithRedirects(url, opts, depth) {
381
369
  headers: {
382
370
  "User-Agent": DNSID_USER_AGENT
383
371
  },
384
- lookup: createSsrfSafeLookup(opts.dnsServer, new Set((opts.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost)))
372
+ lookup: createSsrfSafeLookup(opts.dnsServer, (opts.privateAddressHosts ?? []).map(import_protocol.normalizePrivateAddressHost))
385
373
  };
386
374
  if (opts.caBundlePath) requestOptions.ca = [...tls.rootCertificates, fs.readFileSync(opts.caBundlePath, "utf8")];
387
375
  const req = https.get(url, requestOptions, (res) => {
package/dist/index.d.cts CHANGED
@@ -27,21 +27,19 @@ interface HTTPSFetchOptions {
27
27
  dnsServer?: string;
28
28
  /** Maximum accepted response body size in bytes. Defaults to 1 MiB. */
29
29
  maxResponseBytes?: number;
30
- /**
31
- * Hostnames whose resolved private or loopback addresses may be contacted. For trusted test and
32
- * private deployments only; public defaults use none.
33
- */
34
- allowedUnsafeHosts?: readonly string[];
30
+ /** Same as `TransportConfig.privateAddressHosts`: hostnames or `.suffix` entries that may resolve to loopback/private addresses. */
31
+ privateAddressHosts?: readonly string[];
35
32
  }
36
33
 
37
34
  /** Explicit exceptions for trusted test/private deployments using {@link createSsrfSafeFetch}. */
38
35
  interface SsrfSafeFetchOptions {
39
36
  /**
40
- * Hostnames whose resolved RFC 1918/ULA private or loopback addresses may be contacted.
41
- * Link-local and other unsafe ranges remain blocked. Matching is exact after
42
- * URL hostname normalization; public defaults use no exceptions.
37
+ * Hostnames, or leading-dot suffixes such as `.test`, whose resolved RFC 1918/ULA private or
38
+ * loopback addresses may be contacted. Link-local and other unsafe ranges remain blocked, IP-literal
39
+ * URLs are never exempted, and nothing is allowed by default. Same semantics as
40
+ * `TransportConfig.privateAddressHosts`.
43
41
  */
44
- allowedUnsafeHosts?: readonly string[];
42
+ privateAddressHosts?: readonly string[];
45
43
  }
46
44
  /**
47
45
  * Creates a fetch function honoring the transport configuration.
package/dist/index.d.ts CHANGED
@@ -27,21 +27,19 @@ interface HTTPSFetchOptions {
27
27
  dnsServer?: string;
28
28
  /** Maximum accepted response body size in bytes. Defaults to 1 MiB. */
29
29
  maxResponseBytes?: number;
30
- /**
31
- * Hostnames whose resolved private or loopback addresses may be contacted. For trusted test and
32
- * private deployments only; public defaults use none.
33
- */
34
- allowedUnsafeHosts?: readonly string[];
30
+ /** Same as `TransportConfig.privateAddressHosts`: hostnames or `.suffix` entries that may resolve to loopback/private addresses. */
31
+ privateAddressHosts?: readonly string[];
35
32
  }
36
33
 
37
34
  /** Explicit exceptions for trusted test/private deployments using {@link createSsrfSafeFetch}. */
38
35
  interface SsrfSafeFetchOptions {
39
36
  /**
40
- * Hostnames whose resolved RFC 1918/ULA private or loopback addresses may be contacted.
41
- * Link-local and other unsafe ranges remain blocked. Matching is exact after
42
- * URL hostname normalization; public defaults use no exceptions.
37
+ * Hostnames, or leading-dot suffixes such as `.test`, whose resolved RFC 1918/ULA private or
38
+ * loopback addresses may be contacted. Link-local and other unsafe ranges remain blocked, IP-literal
39
+ * URLs are never exempted, and nothing is allowed by default. Same semantics as
40
+ * `TransportConfig.privateAddressHosts`.
43
41
  */
44
- allowedUnsafeHosts?: readonly string[];
42
+ privateAddressHosts?: readonly string[];
45
43
  }
46
44
  /**
47
45
  * Creates a fetch function honoring the transport configuration.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import * as https from "https";
5
5
  import * as net from "net";
6
6
  import * as tls from "tls";
7
7
  import { Agent as Agent2, fetch as undiciFetch } from "undici";
8
- import { DNSSECState, VerificationCode, VerificationError } from "@dnsid-ai/protocol";
8
+ import { DNSSECState, VerificationCode, VerificationError, normalizePrivateAddressHost } from "@dnsid-ai/protocol";
9
9
  var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
10
10
  var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
11
11
  var DNSID_USER_AGENT = "dnsid-ts";
@@ -19,13 +19,13 @@ function createDnsidFetch(config) {
19
19
  return createFetchWithDispatcher(new Agent2({ connect }));
20
20
  }
21
21
  function createSsrfSafeFetch(config = {}, options = {}) {
22
- const allowedUnsafeHosts = new Set((options.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost));
23
- const connect = { lookup: createSsrfSafeLookup(config.dnsServer, allowedUnsafeHosts) };
22
+ const privateAddressHosts = (options.privateAddressHosts ?? []).map(normalizePrivateAddressHost);
23
+ const connect = { lookup: createSsrfSafeLookup(config.dnsServer, privateAddressHosts) };
24
24
  if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
25
25
  const fetchWithSafeLookup = createFetchWithDispatcher(new Agent2({ connect }));
26
26
  return async (input, init) => {
27
27
  const url = new URL(input instanceof Request ? input.url : input);
28
- if (isUnsafeIp(url.hostname) && !(allowedUnsafeHosts.has(url.hostname) && isPrivateOrLoopbackIp(url.hostname))) {
28
+ if (isUnsafeIp(url.hostname)) {
29
29
  throw new VerificationError(`unsafe target IP address: ${url.hostname}`, {
30
30
  code: VerificationCode.TLSError
31
31
  });
@@ -110,7 +110,7 @@ function createLookup(dnsServer) {
110
110
  })().catch((err) => cb(err, "", 0));
111
111
  };
112
112
  }
113
- function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ new Set()) {
113
+ function createSsrfSafeLookup(dnsServer, privateAddressHosts = []) {
114
114
  let resolverPromise = null;
115
115
  async function customResolver() {
116
116
  if (!dnsServer) throw new Error("no custom DNS server configured");
@@ -130,7 +130,7 @@ function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ ne
130
130
  void (async () => {
131
131
  const addresses = await resolveAddresses(hostname, family, dnsServer ? await customResolver() : void 0);
132
132
  const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
133
- if (unsafe && !(unsafeHostAllowed(hostname, allowedUnsafeHosts) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
133
+ if (unsafe && !(privateAddressHostMatches(hostname, privateAddressHosts) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
134
134
  throw new VerificationError(`unsafe resolved IP address for ${hostname}: ${unsafe.address}`, {
135
135
  code: VerificationCode.TLSError
136
136
  });
@@ -143,21 +143,9 @@ function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ ne
143
143
  })().catch((err) => cb(err, "", 0));
144
144
  };
145
145
  }
146
- function unsafeHostAllowed(hostname, allowedUnsafeHosts) {
146
+ function privateAddressHostMatches(hostname, privateAddressHosts) {
147
147
  const host = hostname.toLowerCase().replace(/\.$/, "");
148
- return allowedUnsafeHosts.has(hostname) || host === "test" || host.endsWith(".test");
149
- }
150
- function normalizeAllowedUnsafeHost(host) {
151
- let parsed;
152
- try {
153
- parsed = new URL(`https://${host}`);
154
- } catch {
155
- throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
156
- }
157
- if (!host || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash) {
158
- throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
159
- }
160
- return parsed.hostname;
148
+ return privateAddressHosts.some((entry) => entry.startsWith(".") ? host === entry.slice(1) || host.endsWith(entry) : host === entry);
161
149
  }
162
150
  async function resolveAddresses(hostname, family, resolver) {
163
151
  const ipFamily = net.isIP(hostname);
@@ -339,7 +327,7 @@ function fetchWithRedirects(url, opts, depth) {
339
327
  headers: {
340
328
  "User-Agent": DNSID_USER_AGENT
341
329
  },
342
- lookup: createSsrfSafeLookup(opts.dnsServer, new Set((opts.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost)))
330
+ lookup: createSsrfSafeLookup(opts.dnsServer, (opts.privateAddressHosts ?? []).map(normalizePrivateAddressHost))
343
331
  };
344
332
  if (opts.caBundlePath) requestOptions.ca = [...tls.rootCertificates, fs.readFileSync(opts.caBundlePath, "utf8")];
345
333
  const req = https.get(url, requestOptions, (res) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dnsid-ai/transport",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Node.js DNS and HTTPS transport implementations for DNSid.",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",
@@ -38,7 +38,7 @@
38
38
  "typecheck": "tsc --noEmit"
39
39
  },
40
40
  "dependencies": {
41
- "@dnsid-ai/protocol": "^0.21.0",
41
+ "@dnsid-ai/protocol": "^0.22.0",
42
42
  "undici": "^8.10.2"
43
43
  },
44
44
  "publishConfig": {