@dnsid-ai/transport 0.20.1

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/dist/index.cjs ADDED
@@ -0,0 +1,496 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createDefaultDnsResolver: () => createDefaultDnsResolver,
34
+ createDnsResolverFromServer: () => createDnsResolverFromServer,
35
+ createDnsidFetch: () => createDnsidFetch,
36
+ createLookup: () => createLookup,
37
+ createSsrfSafeFetch: () => createSsrfSafeFetch,
38
+ fetchJson: () => fetchJson,
39
+ formatDnsServer: () => formatDnsServer,
40
+ isUnsafeIp: () => isUnsafeIp,
41
+ parseDnsServer: () => parseDnsServer
42
+ });
43
+ module.exports = __toCommonJS(index_exports);
44
+ var dnsPromises = __toESM(require("dns/promises"), 1);
45
+ var fs = __toESM(require("fs"), 1);
46
+ var https = __toESM(require("https"), 1);
47
+ var net = __toESM(require("net"), 1);
48
+ var tls = __toESM(require("tls"), 1);
49
+ var import_undici = require("undici");
50
+ var import_protocol = require("@dnsid-ai/protocol");
51
+ var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
52
+ var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
53
+ var DNSID_USER_AGENT = "dnsid-ts";
54
+ function createDnsidFetch(config) {
55
+ if (!config.dnsServer && !config.caBundlePath) {
56
+ return globalThis.fetch.bind(globalThis);
57
+ }
58
+ const connect = {};
59
+ if (config.dnsServer) connect.lookup = createLookup(config.dnsServer);
60
+ if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
61
+ return createFetchWithDispatcher(new import_undici.Agent({ connect }));
62
+ }
63
+ function createSsrfSafeFetch(config = {}, options = {}) {
64
+ const allowedUnsafeHosts = new Set((options.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost));
65
+ const connect = { lookup: createSsrfSafeLookup(config.dnsServer, allowedUnsafeHosts) };
66
+ if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
67
+ const fetchWithSafeLookup = createFetchWithDispatcher(new import_undici.Agent({ connect }));
68
+ return async (input, init) => {
69
+ const url = new URL(input instanceof Request ? input.url : input);
70
+ if (isUnsafeIp(url.hostname) && !(allowedUnsafeHosts.has(url.hostname) && isPrivateOrLoopbackIp(url.hostname))) {
71
+ throw new import_protocol.VerificationError(`unsafe target IP address: ${url.hostname}`, {
72
+ code: import_protocol.VerificationCode.TLSError
73
+ });
74
+ }
75
+ return fetchWithSafeLookup(input, { ...init, redirect: "manual" });
76
+ };
77
+ }
78
+ function createFetchWithDispatcher(dispatcher) {
79
+ return async (input, init) => {
80
+ const { url, init: normalizedInit } = normalizeFetchArgs(input, init);
81
+ const fetchInit = { ...normalizedInit, dispatcher };
82
+ const fetchWithDispatcher = import_undici.fetch;
83
+ return fetchWithDispatcher(url, fetchInit);
84
+ };
85
+ }
86
+ function createDefaultDnsResolver(config) {
87
+ return config.dnsServer ? createDnsResolverFromServer(config.dnsServer) : createSystemDnsResolver();
88
+ }
89
+ function createDnsResolverFromServer(server) {
90
+ const parsed = parseDnsServer(server);
91
+ if (!parsed || net.isIP(parsed.host)) {
92
+ const resolver = new dnsPromises.Resolver();
93
+ resolver.setServers([server]);
94
+ return createSystemDnsResolver(resolver);
95
+ }
96
+ let resolverPromise = null;
97
+ return {
98
+ async fetchTXT(name) {
99
+ resolverPromise ??= (async () => {
100
+ const { address } = await dnsPromises.lookup(parsed.host);
101
+ const resolver = new dnsPromises.Resolver();
102
+ resolver.setServers([formatDnsServer(address, parsed.port)]);
103
+ return resolver;
104
+ })();
105
+ return fetchTxtViaSystem(name, await resolverPromise);
106
+ }
107
+ };
108
+ }
109
+ function fetchJson(url, opts) {
110
+ return fetchWithRedirects(url, opts ?? {}, 0);
111
+ }
112
+ function parseDnsServer(server) {
113
+ const bracket = server.match(/^\[([^\]]+)](?::(\d+))?$/);
114
+ if (bracket) return { host: bracket[1], port: bracket[2] };
115
+ const idx = server.lastIndexOf(":");
116
+ if (idx > -1 && server.indexOf(":") === idx) {
117
+ return { host: server.slice(0, idx), port: server.slice(idx + 1) };
118
+ }
119
+ return { host: server };
120
+ }
121
+ function formatDnsServer(address, port) {
122
+ const host = net.isIP(address) === 6 ? `[${address}]` : address;
123
+ return port ? `${host}:${port}` : host;
124
+ }
125
+ function createLookup(dnsServer) {
126
+ let resolverPromise = null;
127
+ async function resolver() {
128
+ resolverPromise ??= (async () => {
129
+ const parsed = parseDnsServer(dnsServer);
130
+ const server = parsed && !net.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
131
+ const r = new dnsPromises.Resolver();
132
+ r.setServers([server]);
133
+ return r;
134
+ })();
135
+ return resolverPromise;
136
+ }
137
+ return (hostname, opts, cb) => {
138
+ const family = typeof opts === "object" ? opts.family : opts;
139
+ const all = typeof opts === "object" && opts.all;
140
+ void (async () => {
141
+ const r = await resolver();
142
+ const addresses = family === 6 ? (await r.resolve6(hostname)).map((address) => ({ address, family: 6 })) : family === 4 ? (await r.resolve4(hostname)).map((address) => ({ address, family: 4 })) : [
143
+ ...(await r.resolve4(hostname).catch(() => [])).map((address) => ({ address, family: 4 })),
144
+ ...(await r.resolve6(hostname).catch(() => [])).map((address) => ({ address, family: 6 }))
145
+ ];
146
+ if (addresses.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
147
+ if (all) cb(null, addresses);
148
+ else {
149
+ const first = addresses[0];
150
+ cb(null, first.address, first.family);
151
+ }
152
+ })().catch((err) => cb(err, "", 0));
153
+ };
154
+ }
155
+ function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ new Set()) {
156
+ let resolverPromise = null;
157
+ async function customResolver() {
158
+ if (!dnsServer) throw new Error("no custom DNS server configured");
159
+ resolverPromise ??= (async () => {
160
+ const parsed = parseDnsServer(dnsServer);
161
+ const server = parsed && !net.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
162
+ const r = new dnsPromises.Resolver();
163
+ r.setServers([server]);
164
+ return r;
165
+ })();
166
+ return resolverPromise;
167
+ }
168
+ return (hostname, opts, cb) => {
169
+ const rawFamily = typeof opts === "object" ? opts.family : opts;
170
+ const family = rawFamily === "IPv4" ? 4 : rawFamily === "IPv6" ? 6 : rawFamily;
171
+ const all = typeof opts === "object" && opts.all;
172
+ void (async () => {
173
+ const addresses = await resolveAddresses(hostname, family, dnsServer ? await customResolver() : void 0);
174
+ const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
175
+ if (unsafe && !(allowedUnsafeHosts.has(hostname) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
176
+ throw new import_protocol.VerificationError(`unsafe resolved IP address for ${hostname}: ${unsafe.address}`, {
177
+ code: import_protocol.VerificationCode.TLSError
178
+ });
179
+ }
180
+ if (all) cb(null, addresses);
181
+ else {
182
+ const first = addresses[0];
183
+ cb(null, first.address, first.family);
184
+ }
185
+ })().catch((err) => cb(err, "", 0));
186
+ };
187
+ }
188
+ function normalizeAllowedUnsafeHost(host) {
189
+ let parsed;
190
+ try {
191
+ parsed = new URL(`https://${host}`);
192
+ } catch {
193
+ throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
194
+ }
195
+ if (!host || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash) {
196
+ throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
197
+ }
198
+ return parsed.hostname;
199
+ }
200
+ async function resolveAddresses(hostname, family, resolver) {
201
+ const ipFamily = net.isIP(hostname);
202
+ if (ipFamily === 4 || ipFamily === 6) return [{ address: hostname, family: ipFamily }];
203
+ if (!resolver) {
204
+ const records = await dnsPromises.lookup(hostname, { all: true, family: family === 4 || family === 6 ? family : 0 });
205
+ if (records.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
206
+ return records.map(({ address, family: recordFamily }) => ({ address, family: recordFamily }));
207
+ }
208
+ const addresses = family === 6 ? (await resolver.resolve6(hostname)).map((address) => ({ address, family: 6 })) : family === 4 ? (await resolver.resolve4(hostname)).map((address) => ({ address, family: 4 })) : [
209
+ ...(await resolver.resolve4(hostname).catch(() => [])).map((address) => ({ address, family: 4 })),
210
+ ...(await resolver.resolve6(hostname).catch(() => [])).map((address) => ({ address, family: 6 }))
211
+ ];
212
+ if (addresses.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
213
+ return addresses;
214
+ }
215
+ function isUnsafeIp(address) {
216
+ let host = address;
217
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
218
+ const zoneIndex = host.indexOf("%");
219
+ const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
220
+ const family = net.isIP(normalized);
221
+ if (family === 4) return isUnsafeIpv4(normalized);
222
+ if (family === 6) return isUnsafeIpv6(normalized);
223
+ return false;
224
+ }
225
+ function isPrivateOrLoopbackIp(address) {
226
+ let host = address;
227
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
228
+ const zoneIndex = host.indexOf("%");
229
+ const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
230
+ const family = net.isIP(normalized);
231
+ if (family === 4) {
232
+ const [a, b] = normalized.split(".").map(Number);
233
+ return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
234
+ }
235
+ if (family !== 6) return false;
236
+ const bytes = ipv6ToBytes(normalized);
237
+ if (!bytes) return false;
238
+ const mapped = ipv4FromMappedIpv6(bytes) ?? ipv4FromCompatibleIpv6(bytes);
239
+ if (mapped) return isPrivateOrLoopbackIp(mapped);
240
+ const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
241
+ return loopback || (bytes[0] & 254) === 252;
242
+ }
243
+ function isUnsafeIpv4(address) {
244
+ const octets = address.split(".").map((part) => Number(part));
245
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return true;
246
+ const [a, b, c] = octets;
247
+ return a === 0 || a === 10 || a === 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 100 && b >= 64 && b <= 127 || a === 192 && b === 0 && c === 0 || a === 192 && b === 0 && c === 2 || a === 198 && (b === 18 || b === 19) || a === 198 && b === 51 && c === 100 || a === 203 && b === 0 && c === 113 || a >= 224 && a <= 239 || a >= 240;
248
+ }
249
+ function isUnsafeIpv6(address) {
250
+ const bytes = ipv6ToBytes(address);
251
+ if (!bytes) return true;
252
+ const mapped = ipv4FromMappedIpv6(bytes);
253
+ if (mapped) return isUnsafeIpv4(mapped);
254
+ const compatible = ipv4FromCompatibleIpv6(bytes);
255
+ if (compatible) return isUnsafeIpv4(compatible);
256
+ const first = bytes[0];
257
+ const second = bytes[1];
258
+ const allZero = bytes.every((byte) => byte === 0);
259
+ const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
260
+ return allZero || loopback || (first & 254) === 252 || first === 254 && (second & 192) === 128 || first === 255 || first === 32 && second === 1 && bytes[2] === 13 && bytes[3] === 184 || first === 32 && second === 2 || first === 32 && second === 1 && bytes[2] === 0 && bytes[3] === 0;
261
+ }
262
+ function ipv4FromMappedIpv6(bytes) {
263
+ const isMapped = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 255 && bytes[11] === 255;
264
+ if (!isMapped) return null;
265
+ return bytes.slice(12, 16).join(".");
266
+ }
267
+ function ipv4FromCompatibleIpv6(bytes) {
268
+ const isCompatible = bytes.slice(0, 12).every((byte) => byte === 0);
269
+ if (!isCompatible) return null;
270
+ return bytes.slice(12, 16).join(".");
271
+ }
272
+ function ipv6ToBytes(address) {
273
+ let normalized = address;
274
+ const embeddedIpv4Start = address.lastIndexOf(":");
275
+ if (embeddedIpv4Start !== -1) {
276
+ const embeddedIpv4 = address.slice(embeddedIpv4Start + 1);
277
+ if (embeddedIpv4.includes(".")) {
278
+ const octets = embeddedIpv4.split(".").map((part) => Number(part));
279
+ if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
280
+ normalized = `${address.slice(0, embeddedIpv4Start + 1)}${(octets[0] << 8 | octets[1]).toString(16)}:${(octets[2] << 8 | octets[3]).toString(16)}`;
281
+ }
282
+ }
283
+ const halves = normalized.split("::");
284
+ if (halves.length > 2) return null;
285
+ const left = halves[0] ? halves[0].split(":").filter(Boolean) : [];
286
+ const right = halves[1] ? halves[1].split(":").filter(Boolean) : [];
287
+ const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
288
+ const groups = halves.length === 2 ? [...left, ...Array(missing).fill("0"), ...right] : left;
289
+ if (missing < 0 || groups.length !== 8) return null;
290
+ const bytes = [];
291
+ for (const group of groups) {
292
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
293
+ const value = Number.parseInt(group, 16);
294
+ bytes.push(value >> 8 & 255, value & 255);
295
+ }
296
+ return bytes;
297
+ }
298
+ function normalizeFetchArgs(input, init) {
299
+ if (input instanceof Request) {
300
+ const normalized = {
301
+ method: input.method,
302
+ headers: input.headers,
303
+ body: input.body,
304
+ cache: input.cache,
305
+ credentials: input.credentials,
306
+ integrity: input.integrity,
307
+ keepalive: input.keepalive,
308
+ mode: input.mode,
309
+ redirect: input.redirect,
310
+ referrer: input.referrer,
311
+ referrerPolicy: input.referrerPolicy,
312
+ signal: input.signal,
313
+ ...init
314
+ };
315
+ if (normalized.body) normalized.duplex = "half";
316
+ return { url: input.url, init: normalized };
317
+ }
318
+ return { url: input, init: init ?? {} };
319
+ }
320
+ function createSystemDnsResolver(resolver = dnsPromises) {
321
+ return {
322
+ async fetchTXT(name) {
323
+ return fetchTxtViaSystem(name, resolver);
324
+ }
325
+ };
326
+ }
327
+ async function fetchTxtViaSystem(name, resolver) {
328
+ try {
329
+ const records = await resolver.resolveTxt(name);
330
+ return [records.map((strings) => ({ strings, ttl: 0 })), import_protocol.DNSSECState.UNKNOWN];
331
+ } catch (err) {
332
+ const nodeErr = err;
333
+ switch (nodeErr.code) {
334
+ case "ENODATA":
335
+ case "ENOTFOUND":
336
+ return [[], import_protocol.DNSSECState.UNKNOWN];
337
+ case "ESERVFAIL":
338
+ return [[], import_protocol.DNSSECState.FAILED];
339
+ default:
340
+ throw err;
341
+ }
342
+ }
343
+ }
344
+ function hostAllowed(host, allowedHost, domainBoundary = false) {
345
+ return host === allowedHost || domainBoundary && host.endsWith(`.${allowedHost}`);
346
+ }
347
+ var protocolAgent = new https.Agent({ keepAlive: true, maxCachedSessions: 0 });
348
+ function fetchWithRedirects(url, opts, depth) {
349
+ if (depth > 5) {
350
+ return Promise.reject(new import_protocol.VerificationError("too many redirects", { code: import_protocol.VerificationCode.TLSError }));
351
+ }
352
+ let urlObj;
353
+ try {
354
+ urlObj = new URL(url);
355
+ } catch {
356
+ return Promise.reject(new import_protocol.VerificationError(`invalid URL: ${url}`, { code: import_protocol.VerificationCode.TLSError }));
357
+ }
358
+ if (urlObj.protocol !== "https:") {
359
+ return Promise.reject(new import_protocol.VerificationError("only HTTPS URLs are permitted", { code: import_protocol.VerificationCode.TLSError }));
360
+ }
361
+ if (opts.allowedHost && !hostAllowed(urlObj.hostname, opts.allowedHost, opts.domainBoundary)) {
362
+ return Promise.reject(new import_protocol.VerificationError(
363
+ `URL host not permitted: expected ${opts.allowedHost}, got ${urlObj.hostname}`,
364
+ { code: import_protocol.VerificationCode.TLSError }
365
+ ));
366
+ }
367
+ if (isUnsafeIp(urlObj.hostname)) {
368
+ return Promise.reject(new import_protocol.VerificationError(`unsafe HTTPS target IP address: ${urlObj.hostname}`, {
369
+ code: import_protocol.VerificationCode.TLSError
370
+ }));
371
+ }
372
+ return new Promise((resolve, reject) => {
373
+ const requestOptions = {
374
+ agent: protocolAgent,
375
+ signal: opts.signal,
376
+ rejectUnauthorized: true,
377
+ headers: {
378
+ "User-Agent": DNSID_USER_AGENT
379
+ },
380
+ lookup: createSsrfSafeLookup(opts.dnsServer, new Set((opts.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost)))
381
+ };
382
+ if (opts.caBundlePath) requestOptions.ca = [...tls.rootCertificates, fs.readFileSync(opts.caBundlePath, "utf8")];
383
+ const req = https.get(url, requestOptions, (res) => {
384
+ if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
385
+ const location = res.headers.location;
386
+ if (!location) {
387
+ res.resume();
388
+ return reject(new import_protocol.VerificationError("redirect with no Location header", { code: import_protocol.VerificationCode.TLSError }));
389
+ }
390
+ let redirectUrl;
391
+ try {
392
+ redirectUrl = new URL(location, url);
393
+ } catch {
394
+ res.resume();
395
+ return reject(new import_protocol.VerificationError(`invalid redirect URL: ${location}`, { code: import_protocol.VerificationCode.TLSError }));
396
+ }
397
+ if (redirectUrl.protocol !== "https:") {
398
+ res.resume();
399
+ return reject(new import_protocol.VerificationError("redirect to non-HTTPS URL", { code: import_protocol.VerificationCode.TLSError }));
400
+ }
401
+ if (opts.allowedHost && !hostAllowed(redirectUrl.hostname, opts.allowedHost, opts.domainBoundary)) {
402
+ res.resume();
403
+ return reject(new import_protocol.VerificationError(
404
+ `redirect changes host: expected ${opts.allowedHost}, got ${redirectUrl.hostname}`,
405
+ { code: import_protocol.VerificationCode.TLSError }
406
+ ));
407
+ }
408
+ res.resume();
409
+ return fetchWithRedirects(redirectUrl.toString(), opts, depth + 1).then(resolve, reject);
410
+ }
411
+ if (res.statusCode !== 200) {
412
+ res.resume();
413
+ return reject(new import_protocol.VerificationError(
414
+ `unexpected HTTP status ${res.statusCode}`,
415
+ { code: import_protocol.VerificationCode.TLSError, transient: (res.statusCode ?? 0) >= 500 }
416
+ ));
417
+ }
418
+ const socket = res.socket;
419
+ const peerCert = socket.getPeerCertificate();
420
+ const san = [];
421
+ if (peerCert.subjectaltname) {
422
+ for (const part of peerCert.subjectaltname.split(", ")) {
423
+ if (part.startsWith("DNS:")) san.push(part.slice(4));
424
+ }
425
+ }
426
+ const tlsCert = { notAfter: new Date(peerCert.valid_to), san };
427
+ const maxBodyBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
428
+ const contentLength = Number(res.headers["content-length"]);
429
+ if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
430
+ res.resume();
431
+ return reject(new import_protocol.VerificationError(
432
+ `response body exceeds ${maxBodyBytes} byte limit`,
433
+ { code: import_protocol.VerificationCode.TLSError }
434
+ ));
435
+ }
436
+ let body = "";
437
+ let bodyBytes = 0;
438
+ let tooLarge = false;
439
+ res.setEncoding("utf8");
440
+ res.on("data", (chunk) => {
441
+ if (tooLarge) return;
442
+ bodyBytes += Buffer.byteLength(chunk, "utf8");
443
+ if (bodyBytes > maxBodyBytes) {
444
+ tooLarge = true;
445
+ req.destroy();
446
+ return reject(new import_protocol.VerificationError(
447
+ `response body exceeds ${maxBodyBytes} byte limit`,
448
+ { code: import_protocol.VerificationCode.TLSError }
449
+ ));
450
+ }
451
+ body += chunk;
452
+ });
453
+ res.on("end", () => {
454
+ try {
455
+ resolve({ data: JSON.parse(body), tlsCert });
456
+ } catch {
457
+ reject(new import_protocol.VerificationError("response body is not valid JSON", { code: import_protocol.VerificationCode.RecordInvalid }));
458
+ }
459
+ });
460
+ res.on("error", (err) => {
461
+ reject(new import_protocol.VerificationError(`response stream error: ${err.message}`, {
462
+ code: import_protocol.VerificationCode.TLSError,
463
+ transient: true
464
+ }));
465
+ });
466
+ });
467
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
468
+ req.setTimeout(timeoutMs, () => {
469
+ req.destroy();
470
+ reject(new import_protocol.VerificationError(`request timed out after ${timeoutMs}ms`, {
471
+ code: import_protocol.VerificationCode.TLSError,
472
+ transient: true
473
+ }));
474
+ });
475
+ req.on("error", (err) => {
476
+ const nodeErr = err;
477
+ const isTls = nodeErr.code === "DEPTH_ZERO_SELF_SIGNED_CERT" || nodeErr.code === "CERT_HAS_EXPIRED" || nodeErr.code === "ERR_TLS_CERT_ALTNAME_INVALID" || nodeErr.code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE";
478
+ reject(new import_protocol.VerificationError(`HTTPS fetch failed: ${err.message}`, {
479
+ code: import_protocol.VerificationCode.TLSError,
480
+ transient: !isTls
481
+ }));
482
+ });
483
+ });
484
+ }
485
+ // Annotate the CommonJS export names for ESM import in node:
486
+ 0 && (module.exports = {
487
+ createDefaultDnsResolver,
488
+ createDnsResolverFromServer,
489
+ createDnsidFetch,
490
+ createLookup,
491
+ createSsrfSafeFetch,
492
+ fetchJson,
493
+ formatDnsServer,
494
+ isUnsafeIp,
495
+ parseDnsServer
496
+ });
@@ -0,0 +1,160 @@
1
+ import * as https from 'node:https';
2
+ import { TLSCertificate, TransportConfig, DNSResolver } from '@dnsid-ai/protocol';
3
+ export { TransportConfig } from '@dnsid-ai/protocol';
4
+
5
+ /** Minimal WHATWG-fetch-compatible function signature returned by the fetch factories. */
6
+ type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
7
+ /** Result of {@link fetchJson}: the parsed body plus the TLS certificate presented by the peer. */
8
+ interface FetchResult {
9
+ /** JSON-decoded response body. */
10
+ data: unknown;
11
+ /** Peer certificate details (expiry and DNS SANs) captured from the TLS session. */
12
+ tlsCert: TLSCertificate;
13
+ }
14
+ /** Options controlling {@link fetchJson}. */
15
+ interface HTTPSFetchOptions {
16
+ /** Shared verification deadline/cancellation, including redirects. */
17
+ signal?: AbortSignal;
18
+ /** If set, the URL host (and every redirect host) must match this host exactly. */
19
+ allowedHost?: string;
20
+ /** With `allowedHost`, also accept subdomains of the allowed host (`*.allowedHost`). */
21
+ domainBoundary?: boolean;
22
+ /** Request timeout in milliseconds. Defaults to 10 000. */
23
+ timeoutMs?: number;
24
+ /** Path to a PEM CA bundle appended to the system root certificates. */
25
+ caBundlePath?: string;
26
+ /** Custom DNS server (`host`, `host:port`, or `[ipv6]:port`) used to resolve the target. */
27
+ dnsServer?: string;
28
+ /** Maximum accepted response body size in bytes. Defaults to 1 MiB. */
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[];
35
+ }
36
+
37
+ /** Explicit exceptions for trusted test/private deployments using {@link createSsrfSafeFetch}. */
38
+ interface SsrfSafeFetchOptions {
39
+ /**
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.
43
+ */
44
+ allowedUnsafeHosts?: readonly string[];
45
+ }
46
+ /**
47
+ * Creates a fetch function honoring the transport configuration.
48
+ *
49
+ * With no custom DNS server or CA bundle this returns the global `fetch`
50
+ * unchanged. Otherwise it returns an undici-backed fetch whose connections
51
+ * resolve hostnames via the configured DNS server and/or trust the extra CA
52
+ * bundle in addition to the system roots. Unlike {@link createSsrfSafeFetch},
53
+ * the returned fetch applies no private-address filtering.
54
+ *
55
+ * @param config - DNS server and/or CA bundle overrides.
56
+ * @returns A {@link FetchLike} suitable for passing to core verification APIs.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { createDnsidFetch, createDefaultDnsResolver } from '@dnsid-ai/transport';
61
+ *
62
+ * const config = { dnsServer: '1.1.1.1' };
63
+ * const fetchImpl = createDnsidFetch(config);
64
+ * const dnsResolver = createDefaultDnsResolver(config);
65
+ *
66
+ * const res = await fetchImpl('https://agent.example.com/.well-known/jwks.json');
67
+ * const [records, dnssec] = await dnsResolver.fetchTXT('_dnsid.agent.example.com');
68
+ * ```
69
+ */
70
+ declare function createDnsidFetch(config: TransportConfig): FetchLike;
71
+ /**
72
+ * Creates a fetch function that rejects connections to private, loopback,
73
+ * link-local, and other non-routable addresses (SSRF protection).
74
+ *
75
+ * Every hostname is resolved (via the configured DNS server, or the system
76
+ * resolver otherwise) and each resulting address is checked with
77
+ * {@link isUnsafeIp} before connecting; requests resolving to an unsafe
78
+ * address fail with a VerificationError (TLSError, surfaced through the
79
+ * fetch rejection). Redirects are returned to the caller instead of followed
80
+ * automatically so each destination can be explicitly policy-checked.
81
+ *
82
+ * @param config - Optional DNS server and/or CA bundle overrides.
83
+ * @param options - Explicit hostname-scoped private-address exceptions for trusted test/private deployments.
84
+ * @returns A {@link FetchLike} with address filtering applied on every lookup.
85
+ */
86
+ declare function createSsrfSafeFetch(config?: TransportConfig, options?: SsrfSafeFetchOptions): FetchLike;
87
+ /**
88
+ * Creates the DNS resolver used to fetch DNSid identity records (TXT).
89
+ *
90
+ * Uses the configured DNS server when present, otherwise the system resolver.
91
+ * Either way the underlying lookup cannot observe DNSSEC validation, so
92
+ * results carry `DNSSECState.UNKNOWN` (or `FAILED` when the query SERVFAILs).
93
+ * Node's TXT API omits TTLs, so results use TTL 0 to disable SDK caching.
94
+ */
95
+ declare function createDefaultDnsResolver(config: Pick<TransportConfig, 'dnsServer'>): DNSResolver;
96
+ /**
97
+ * Creates a {@link DNSResolver} that queries a specific DNS server.
98
+ *
99
+ * Accepts `host`, `host:port`, or `[ipv6]:port`. A hostname (rather than an
100
+ * IP literal) is resolved once via the system resolver on first use, then
101
+ * cached for the resolver's lifetime. TXT answers are returned with TTL 0
102
+ * (no SDK caching) and `DNSSECState.UNKNOWN`; `ENODATA`/`ENOTFOUND` yield an empty
103
+ * record set, `ESERVFAIL` yields `DNSSECState.FAILED`, and other DNS errors
104
+ * are rethrown as-is.
105
+ *
106
+ * @param server - DNS server address, optionally with port.
107
+ */
108
+ declare function createDnsResolverFromServer(server: string): DNSResolver;
109
+ /**
110
+ * Fetches a JSON document over HTTPS with strict transport checks, returning
111
+ * the parsed body together with the peer TLS certificate.
112
+ *
113
+ * Enforces: HTTPS-only URLs, optional host pinning via `allowedHost` (with
114
+ * optional subdomain boundary), SSRF-safe address resolution, at most 5
115
+ * same-policy HTTPS redirects, an expected 200 status, and a bounded response
116
+ * body size. All lookups route through `opts.dnsServer` when provided.
117
+ *
118
+ * @param url - Absolute HTTPS URL to fetch.
119
+ * @param opts - Transport constraints; see {@link HTTPSFetchOptions}.
120
+ * @returns The parsed JSON body and captured TLS certificate.
121
+ * @throws VerificationError with `VerificationCode.TLSError` for policy or
122
+ * transport failures (non-HTTPS URL, disallowed or unsafe host, redirect
123
+ * violations, non-200 status, oversized body, timeout, TLS/connection
124
+ * errors); with `VerificationCode.RecordInvalid` when the body is not valid
125
+ * JSON. Network-shaped failures are marked `transient`.
126
+ */
127
+ declare function fetchJson(url: string, opts?: HTTPSFetchOptions): Promise<FetchResult>;
128
+ /**
129
+ * Splits a DNS server string into host and optional port.
130
+ *
131
+ * Supports `host`, `host:port`, and `[ipv6]:port`. Unbracketed strings
132
+ * containing multiple colons (bare IPv6 literals) are returned whole as
133
+ * `host`, which callers pass through to Node unchanged.
134
+ */
135
+ declare function parseDnsServer(server: string): {
136
+ host: string;
137
+ port?: string;
138
+ } | null;
139
+ /** Formats a resolved address (bracketing IPv6) with an optional port for `Resolver.setServers`. */
140
+ declare function formatDnsServer(address: string, port?: string): string;
141
+ /**
142
+ * Creates a Node `lookup` function that resolves A/AAAA records via the given
143
+ * DNS server instead of the system resolver.
144
+ *
145
+ * A hostname-form server is itself resolved once via the system resolver and
146
+ * cached. Suitable for `https.RequestOptions.lookup` or an undici connect
147
+ * option. The callback receives an `Error` when no A/AAAA records exist.
148
+ *
149
+ * @param dnsServer - DNS server address (`host`, `host:port`, or `[ipv6]:port`).
150
+ */
151
+ declare function createLookup(dnsServer: string): NonNullable<https.RequestOptions['lookup']>;
152
+ /**
153
+ * True if an IP address must not be contacted by SSRF-safe transports:
154
+ * private, loopback, link-local, CGN, documentation, multicast, reserved,
155
+ * 6to4/Teredo, and IPv4-mapped/compatible IPv6 forms of any of these.
156
+ * Malformed IP-shaped input is treated as unsafe; non-IP strings return false.
157
+ */
158
+ declare function isUnsafeIp(address: string): boolean;
159
+
160
+ export { type FetchLike, type FetchResult, type HTTPSFetchOptions, type SsrfSafeFetchOptions, createDefaultDnsResolver, createDnsResolverFromServer, createDnsidFetch, createLookup, createSsrfSafeFetch, fetchJson, formatDnsServer, isUnsafeIp, parseDnsServer };