@dnsid-ai/transport 0.23.1 → 0.24.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/README.md +1 -1
- package/dist/index.cjs +198 -64
- package/dist/index.d.cts +6 -8
- package/dist/index.d.ts +6 -8
- package/dist/index.js +168 -34
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -39,6 +39,6 @@ const safeFetch = createSsrfSafeFetch({ dnsServer: process.env.DNSID_DNS_SERVER
|
|
|
39
39
|
|
|
40
40
|
Most Node applications should use this through `@dnsid-ai/sdk/node` or profile defaults.
|
|
41
41
|
|
|
42
|
-
Node's
|
|
42
|
+
The default resolver queries the DNS servers returned by Node's `dns.getServers()` in order, using UDP with TCP fallback to capture the recursive server's remaining TXT TTL (capped at one day). `dnsServer` can override this list. On failures or empty answers it falls back to Node's native TXT lookup with TTL `0`, disabling identity caching for that result. Node's server list may omit macOS scoped/split-DNS routing, so even positive wire answers are not guaranteed to match the platform resolver in those setups. Neither path validates DNSSEC (`UNKNOWN`); SERVFAIL is a resolution error. `validated`/`required` DNSSEC modes still need an injected validating resolver. Cached verification still re-fetches status unless `verification.statusCheckInterval` is set.
|
|
43
43
|
|
|
44
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 `loadEnvironment`). 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
|
@@ -44,10 +44,136 @@ module.exports = __toCommonJS(index_exports);
|
|
|
44
44
|
var dnsPromises = __toESM(require("dns/promises"), 1);
|
|
45
45
|
var fs = __toESM(require("fs"), 1);
|
|
46
46
|
var https = __toESM(require("https"), 1);
|
|
47
|
-
var
|
|
47
|
+
var net2 = __toESM(require("net"), 1);
|
|
48
48
|
var tls = __toESM(require("tls"), 1);
|
|
49
49
|
var import_undici = require("undici");
|
|
50
|
+
var import_protocol2 = require("@dnsid-ai/protocol");
|
|
51
|
+
|
|
52
|
+
// src/txt-wire.ts
|
|
53
|
+
var import_node_crypto = require("crypto");
|
|
54
|
+
var dgram = __toESM(require("dgram"), 1);
|
|
55
|
+
var net = __toESM(require("net"), 1);
|
|
56
|
+
var import_dns_packet = __toESM(require("dns-packet"), 1);
|
|
50
57
|
var import_protocol = require("@dnsid-ai/protocol");
|
|
58
|
+
var normalize = (name) => name.replace(/\.$/, "").toLowerCase();
|
|
59
|
+
var badResponse = () => new Error("invalid DNS TXT response");
|
|
60
|
+
var remainingTtl = (ttl) => ttl !== void 0 && Number.isInteger(ttl) && ttl >= 0 && ttl <= 2147483647 ? ttl : 0;
|
|
61
|
+
async function resolveTxtWithTtl(name, host, port, signal) {
|
|
62
|
+
const deadline = AbortSignal.any([signal ?? new AbortController().signal, AbortSignal.timeout(2e3)]);
|
|
63
|
+
let owner = normalize(name);
|
|
64
|
+
const seen = /* @__PURE__ */ new Set([owner]);
|
|
65
|
+
let hops = 0;
|
|
66
|
+
let ttl = Infinity;
|
|
67
|
+
while (true) {
|
|
68
|
+
deadline.throwIfAborted();
|
|
69
|
+
const queried = owner;
|
|
70
|
+
const response = await query(owner, host, port, deadline);
|
|
71
|
+
if (response.rcode === "NXDOMAIN") return [[], import_protocol.DNSSECState.UNKNOWN];
|
|
72
|
+
if (response.rcode !== "NOERROR") throw new Error(`DNS query failed: ${response.rcode}`);
|
|
73
|
+
const answers = response.answers ?? [];
|
|
74
|
+
while (true) {
|
|
75
|
+
const cnames = answers.filter((a) => a.type === "CNAME" && normalize(a.name) === owner && a.class === "IN");
|
|
76
|
+
if (!cnames.length) break;
|
|
77
|
+
if (cnames.length !== 1 || ++hops > 8) throw badResponse();
|
|
78
|
+
const cname = cnames[0];
|
|
79
|
+
if (typeof cname.data !== "string") throw badResponse();
|
|
80
|
+
ttl = Math.min(ttl, remainingTtl(cname.ttl));
|
|
81
|
+
owner = normalize(cname.data);
|
|
82
|
+
if (seen.has(owner)) throw badResponse();
|
|
83
|
+
seen.add(owner);
|
|
84
|
+
}
|
|
85
|
+
const txt = answers.filter((a) => a.type === "TXT" && normalize(a.name) === owner && a.class === "IN");
|
|
86
|
+
if (txt.length) {
|
|
87
|
+
ttl = Math.min(ttl, ...txt.map((a) => remainingTtl(a.ttl)));
|
|
88
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
89
|
+
return [txt.map((a) => {
|
|
90
|
+
if (!Array.isArray(a.data) || !a.data.every(Buffer.isBuffer)) throw badResponse();
|
|
91
|
+
return { strings: a.data.map((part) => decoder.decode(part)), ttl: Math.min(ttl, 86400) };
|
|
92
|
+
}), import_protocol.DNSSECState.UNKNOWN];
|
|
93
|
+
}
|
|
94
|
+
if (owner === queried) return [[], import_protocol.DNSSECState.UNKNOWN];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function query(name, host, port, signal) {
|
|
98
|
+
const id = (0, import_node_crypto.randomInt)(65536);
|
|
99
|
+
const request = {
|
|
100
|
+
type: "query",
|
|
101
|
+
id,
|
|
102
|
+
flags: import_dns_packet.default.RECURSION_DESIRED,
|
|
103
|
+
questions: [{ type: "TXT", class: "IN", name }],
|
|
104
|
+
additionals: [{ type: "OPT", name: ".", udpPayloadSize: 1232 }]
|
|
105
|
+
};
|
|
106
|
+
const valid = (data) => {
|
|
107
|
+
try {
|
|
108
|
+
const response2 = import_dns_packet.default.decode(data);
|
|
109
|
+
if (import_dns_packet.default.decode.bytes > data.length || response2.type !== "response" || response2.id !== id || response2.opcode !== "QUERY" || response2.questions?.length !== 1 || response2.questions[0].type !== "TXT" || response2.questions[0].class !== "IN" || normalize(response2.questions[0].name) !== name) return null;
|
|
110
|
+
return response2;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const response = await udp(import_dns_packet.default.encode(request), host, port, signal, valid);
|
|
116
|
+
if (!response.flag_tc) return response;
|
|
117
|
+
const tcpResponse = valid(await tcp(import_dns_packet.default.streamEncode(request), host, port, signal));
|
|
118
|
+
if (!tcpResponse || tcpResponse.flag_tc) throw badResponse();
|
|
119
|
+
return tcpResponse;
|
|
120
|
+
}
|
|
121
|
+
function udp(buf, host, port, signal, valid) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const sock = dgram.createSocket(net.isIP(host) === 6 ? "udp6" : "udp4");
|
|
124
|
+
let finished = false;
|
|
125
|
+
const done = (error, response) => {
|
|
126
|
+
if (finished) return;
|
|
127
|
+
finished = true;
|
|
128
|
+
signal.removeEventListener("abort", onAbort);
|
|
129
|
+
sock.close();
|
|
130
|
+
if (error) reject(error);
|
|
131
|
+
else resolve(response);
|
|
132
|
+
};
|
|
133
|
+
const onAbort = () => done(signal.reason ?? new Error("DNS query aborted"));
|
|
134
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
135
|
+
sock.on("error", done);
|
|
136
|
+
sock.on("message", (data) => {
|
|
137
|
+
const response = valid(data);
|
|
138
|
+
if (response) done(void 0, response);
|
|
139
|
+
});
|
|
140
|
+
if (signal.aborted) return onAbort();
|
|
141
|
+
sock.connect(port, host, () => {
|
|
142
|
+
if (!finished) sock.send(buf);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function tcp(buf, host, port, signal) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
const sock = net.connect({ host, port });
|
|
149
|
+
let data = Buffer.alloc(0);
|
|
150
|
+
let finished = false;
|
|
151
|
+
const done = (error, response) => {
|
|
152
|
+
if (finished) return;
|
|
153
|
+
finished = true;
|
|
154
|
+
signal.removeEventListener("abort", onAbort);
|
|
155
|
+
sock.destroy();
|
|
156
|
+
if (error) reject(error);
|
|
157
|
+
else resolve(response);
|
|
158
|
+
};
|
|
159
|
+
const onAbort = () => done(signal.reason ?? new Error("DNS query aborted"));
|
|
160
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
161
|
+
sock.on("error", done);
|
|
162
|
+
sock.on("end", () => done(badResponse()));
|
|
163
|
+
sock.on("connect", () => sock.write(buf));
|
|
164
|
+
sock.on("data", (chunk) => {
|
|
165
|
+
if (!Buffer.isBuffer(chunk) || data.length + chunk.length > 65537) return done(badResponse());
|
|
166
|
+
data = Buffer.concat([data, chunk]);
|
|
167
|
+
if (data.length >= 2 && data.length >= data.readUInt16BE(0) + 2) {
|
|
168
|
+
if (data.length !== data.readUInt16BE(0) + 2) return done(badResponse());
|
|
169
|
+
done(void 0, data.subarray(2));
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
if (signal.aborted) onAbort();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// src/index.ts
|
|
51
177
|
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
52
178
|
var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
|
53
179
|
var DNSID_USER_AGENT = "dnsid-ts";
|
|
@@ -55,21 +181,21 @@ function createDnsidFetch(config) {
|
|
|
55
181
|
if (!config.dnsServer && !config.caBundlePath) {
|
|
56
182
|
return globalThis.fetch.bind(globalThis);
|
|
57
183
|
}
|
|
58
|
-
const
|
|
59
|
-
if (config.dnsServer)
|
|
60
|
-
if (config.caBundlePath)
|
|
61
|
-
return createFetchWithDispatcher(new import_undici.Agent({ connect }));
|
|
184
|
+
const connect2 = {};
|
|
185
|
+
if (config.dnsServer) connect2.lookup = createLookup(config.dnsServer);
|
|
186
|
+
if (config.caBundlePath) connect2.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
187
|
+
return createFetchWithDispatcher(new import_undici.Agent({ connect: connect2 }));
|
|
62
188
|
}
|
|
63
189
|
function createSsrfSafeFetch(config = {}, options = {}) {
|
|
64
|
-
const privateAddressHosts = (options.privateAddressHosts ?? []).map(
|
|
65
|
-
const
|
|
66
|
-
if (config.caBundlePath)
|
|
67
|
-
const fetchWithSafeLookup = createFetchWithDispatcher(new import_undici.Agent({ connect }));
|
|
190
|
+
const privateAddressHosts = (options.privateAddressHosts ?? []).map(import_protocol2.normalizePrivateAddressHost);
|
|
191
|
+
const connect2 = { lookup: createSsrfSafeLookup(config.dnsServer, privateAddressHosts) };
|
|
192
|
+
if (config.caBundlePath) connect2.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
193
|
+
const fetchWithSafeLookup = createFetchWithDispatcher(new import_undici.Agent({ connect: connect2 }));
|
|
68
194
|
return async (input, init) => {
|
|
69
195
|
const url = new URL(input instanceof Request ? input.url : input);
|
|
70
196
|
if (isUnsafeIp(url.hostname)) {
|
|
71
|
-
throw new
|
|
72
|
-
code:
|
|
197
|
+
throw new import_protocol2.VerificationError(`unsafe target IP address: ${url.hostname}`, {
|
|
198
|
+
code: import_protocol2.VerificationCode.TLSError
|
|
73
199
|
});
|
|
74
200
|
}
|
|
75
201
|
return fetchWithSafeLookup(input, { ...init, redirect: "manual" });
|
|
@@ -84,25 +210,35 @@ function createFetchWithDispatcher(dispatcher) {
|
|
|
84
210
|
};
|
|
85
211
|
}
|
|
86
212
|
function createDefaultDnsResolver(config) {
|
|
87
|
-
|
|
213
|
+
if (config.dnsServer) return createDnsResolverFromServer(config.dnsServer);
|
|
214
|
+
const system = createSystemDnsResolver();
|
|
215
|
+
return {
|
|
216
|
+
async fetchTXT(name, options) {
|
|
217
|
+
for (const server of dnsPromises.getServers()) {
|
|
218
|
+
options?.signal?.throwIfAborted();
|
|
219
|
+
try {
|
|
220
|
+
const result = await createDnsResolverFromServer(server).fetchTXT(name, options);
|
|
221
|
+
if (result[0].length) return result;
|
|
222
|
+
break;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (options?.signal?.aborted) throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
options?.signal?.throwIfAborted();
|
|
228
|
+
return system.fetchTXT(name, options);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
88
231
|
}
|
|
89
232
|
function createDnsResolverFromServer(server) {
|
|
90
233
|
const parsed = parseDnsServer(server);
|
|
91
|
-
if (!parsed
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
let resolverPromise = null;
|
|
234
|
+
if (!parsed) throw new import_protocol2.ArgumentError("invalid DNS server");
|
|
235
|
+
const port = Number(parsed.port ?? 53);
|
|
236
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new import_protocol2.ArgumentError(`invalid DNS server port: ${parsed.port}`);
|
|
237
|
+
let hostPromise;
|
|
97
238
|
return {
|
|
98
|
-
async fetchTXT(name) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const resolver = new dnsPromises.Resolver();
|
|
102
|
-
resolver.setServers([formatDnsServer(address, parsed.port)]);
|
|
103
|
-
return resolver;
|
|
104
|
-
})();
|
|
105
|
-
return fetchTxtViaSystem(name, await resolverPromise);
|
|
239
|
+
async fetchTXT(name, options) {
|
|
240
|
+
hostPromise ??= net2.isIP(parsed.host) ? Promise.resolve(parsed.host) : dnsPromises.lookup(parsed.host).then(({ address }) => address);
|
|
241
|
+
return resolveTxtWithTtl(name, await hostPromise, port, options?.signal);
|
|
106
242
|
}
|
|
107
243
|
};
|
|
108
244
|
}
|
|
@@ -119,7 +255,7 @@ function parseDnsServer(server) {
|
|
|
119
255
|
return { host: server };
|
|
120
256
|
}
|
|
121
257
|
function formatDnsServer(address, port) {
|
|
122
|
-
const host =
|
|
258
|
+
const host = net2.isIP(address) === 6 ? `[${address}]` : address;
|
|
123
259
|
return port ? `${host}:${port}` : host;
|
|
124
260
|
}
|
|
125
261
|
function createLookup(dnsServer) {
|
|
@@ -127,7 +263,7 @@ function createLookup(dnsServer) {
|
|
|
127
263
|
async function resolver() {
|
|
128
264
|
resolverPromise ??= (async () => {
|
|
129
265
|
const parsed = parseDnsServer(dnsServer);
|
|
130
|
-
const server = parsed && !
|
|
266
|
+
const server = parsed && !net2.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
131
267
|
const r = new dnsPromises.Resolver();
|
|
132
268
|
r.setServers([server]);
|
|
133
269
|
return r;
|
|
@@ -158,7 +294,7 @@ function createSsrfSafeLookup(dnsServer, privateAddressHosts = []) {
|
|
|
158
294
|
if (!dnsServer) throw new Error("no custom DNS server configured");
|
|
159
295
|
resolverPromise ??= (async () => {
|
|
160
296
|
const parsed = parseDnsServer(dnsServer);
|
|
161
|
-
const server = parsed && !
|
|
297
|
+
const server = parsed && !net2.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
162
298
|
const r = new dnsPromises.Resolver();
|
|
163
299
|
r.setServers([server]);
|
|
164
300
|
return r;
|
|
@@ -173,8 +309,8 @@ function createSsrfSafeLookup(dnsServer, privateAddressHosts = []) {
|
|
|
173
309
|
const addresses = await resolveAddresses(hostname, family, dnsServer ? await customResolver() : void 0);
|
|
174
310
|
const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
|
|
175
311
|
if (unsafe && !(privateAddressHostMatches(hostname, privateAddressHosts) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
|
|
176
|
-
throw new
|
|
177
|
-
code:
|
|
312
|
+
throw new import_protocol2.VerificationError(`unsafe resolved IP address for ${hostname}: ${unsafe.address}`, {
|
|
313
|
+
code: import_protocol2.VerificationCode.TLSError
|
|
178
314
|
});
|
|
179
315
|
}
|
|
180
316
|
if (all) cb(null, addresses);
|
|
@@ -190,7 +326,7 @@ function privateAddressHostMatches(hostname, privateAddressHosts) {
|
|
|
190
326
|
return privateAddressHosts.some((entry) => entry.startsWith(".") ? host === entry.slice(1) || host.endsWith(entry) : host === entry);
|
|
191
327
|
}
|
|
192
328
|
async function resolveAddresses(hostname, family, resolver) {
|
|
193
|
-
const ipFamily =
|
|
329
|
+
const ipFamily = net2.isIP(hostname);
|
|
194
330
|
if (ipFamily === 4 || ipFamily === 6) return [{ address: hostname, family: ipFamily }];
|
|
195
331
|
if (!resolver) {
|
|
196
332
|
const records = await dnsPromises.lookup(hostname, { all: true, family: family === 4 || family === 6 ? family : 0 });
|
|
@@ -209,7 +345,7 @@ function isUnsafeIp(address) {
|
|
|
209
345
|
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
210
346
|
const zoneIndex = host.indexOf("%");
|
|
211
347
|
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
212
|
-
const family =
|
|
348
|
+
const family = net2.isIP(normalized);
|
|
213
349
|
if (family === 4) return isUnsafeIpv4(normalized);
|
|
214
350
|
if (family === 6) return isUnsafeIpv6(normalized);
|
|
215
351
|
return false;
|
|
@@ -219,7 +355,7 @@ function isPrivateOrLoopbackIp(address) {
|
|
|
219
355
|
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
220
356
|
const zoneIndex = host.indexOf("%");
|
|
221
357
|
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
222
|
-
const family =
|
|
358
|
+
const family = net2.isIP(normalized);
|
|
223
359
|
if (family === 4) {
|
|
224
360
|
const [a, b] = normalized.split(".").map(Number);
|
|
225
361
|
return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
@@ -319,15 +455,13 @@ function createSystemDnsResolver(resolver = dnsPromises) {
|
|
|
319
455
|
async function fetchTxtViaSystem(name, resolver) {
|
|
320
456
|
try {
|
|
321
457
|
const records = await resolver.resolveTxt(name);
|
|
322
|
-
return [records.map((strings) => ({ strings, ttl: 0 })),
|
|
458
|
+
return [records.map((strings) => ({ strings, ttl: 0 })), import_protocol2.DNSSECState.UNKNOWN];
|
|
323
459
|
} catch (err) {
|
|
324
460
|
const nodeErr = err;
|
|
325
461
|
switch (nodeErr.code) {
|
|
326
462
|
case "ENODATA":
|
|
327
463
|
case "ENOTFOUND":
|
|
328
|
-
return [[],
|
|
329
|
-
case "ESERVFAIL":
|
|
330
|
-
return [[], import_protocol.DNSSECState.FAILED];
|
|
464
|
+
return [[], import_protocol2.DNSSECState.UNKNOWN];
|
|
331
465
|
default:
|
|
332
466
|
throw err;
|
|
333
467
|
}
|
|
@@ -339,26 +473,26 @@ function hostAllowed(host, allowedHost, domainBoundary = false) {
|
|
|
339
473
|
var protocolAgent = new https.Agent({ keepAlive: true, maxCachedSessions: 0 });
|
|
340
474
|
function fetchWithRedirects(url, opts, depth) {
|
|
341
475
|
if (depth > 5) {
|
|
342
|
-
return Promise.reject(new
|
|
476
|
+
return Promise.reject(new import_protocol2.VerificationError("too many redirects", { code: import_protocol2.VerificationCode.TLSError }));
|
|
343
477
|
}
|
|
344
478
|
let urlObj;
|
|
345
479
|
try {
|
|
346
480
|
urlObj = new URL(url);
|
|
347
481
|
} catch {
|
|
348
|
-
return Promise.reject(new
|
|
482
|
+
return Promise.reject(new import_protocol2.VerificationError(`invalid URL: ${url}`, { code: import_protocol2.VerificationCode.TLSError }));
|
|
349
483
|
}
|
|
350
484
|
if (urlObj.protocol !== "https:") {
|
|
351
|
-
return Promise.reject(new
|
|
485
|
+
return Promise.reject(new import_protocol2.VerificationError("only HTTPS URLs are permitted", { code: import_protocol2.VerificationCode.TLSError }));
|
|
352
486
|
}
|
|
353
487
|
if (opts.allowedHost && !hostAllowed(urlObj.hostname, opts.allowedHost, opts.domainBoundary)) {
|
|
354
|
-
return Promise.reject(new
|
|
488
|
+
return Promise.reject(new import_protocol2.VerificationError(
|
|
355
489
|
`URL host not permitted: expected ${opts.allowedHost}, got ${urlObj.hostname}`,
|
|
356
|
-
{ code:
|
|
490
|
+
{ code: import_protocol2.VerificationCode.TLSError }
|
|
357
491
|
));
|
|
358
492
|
}
|
|
359
493
|
if (isUnsafeIp(urlObj.hostname)) {
|
|
360
|
-
return Promise.reject(new
|
|
361
|
-
code:
|
|
494
|
+
return Promise.reject(new import_protocol2.VerificationError(`unsafe HTTPS target IP address: ${urlObj.hostname}`, {
|
|
495
|
+
code: import_protocol2.VerificationCode.TLSError
|
|
362
496
|
}));
|
|
363
497
|
}
|
|
364
498
|
return new Promise((resolve, reject) => {
|
|
@@ -369,7 +503,7 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
369
503
|
headers: {
|
|
370
504
|
"User-Agent": DNSID_USER_AGENT
|
|
371
505
|
},
|
|
372
|
-
lookup: createSsrfSafeLookup(opts.dnsServer, (opts.privateAddressHosts ?? []).map(
|
|
506
|
+
lookup: createSsrfSafeLookup(opts.dnsServer, (opts.privateAddressHosts ?? []).map(import_protocol2.normalizePrivateAddressHost))
|
|
373
507
|
};
|
|
374
508
|
if (opts.caBundlePath) requestOptions.ca = [...tls.rootCertificates, fs.readFileSync(opts.caBundlePath, "utf8")];
|
|
375
509
|
const req = https.get(url, requestOptions, (res) => {
|
|
@@ -377,24 +511,24 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
377
511
|
const location = res.headers.location;
|
|
378
512
|
if (!location) {
|
|
379
513
|
res.resume();
|
|
380
|
-
return reject(new
|
|
514
|
+
return reject(new import_protocol2.VerificationError("redirect with no Location header", { code: import_protocol2.VerificationCode.TLSError }));
|
|
381
515
|
}
|
|
382
516
|
let redirectUrl;
|
|
383
517
|
try {
|
|
384
518
|
redirectUrl = new URL(location, url);
|
|
385
519
|
} catch {
|
|
386
520
|
res.resume();
|
|
387
|
-
return reject(new
|
|
521
|
+
return reject(new import_protocol2.VerificationError(`invalid redirect URL: ${location}`, { code: import_protocol2.VerificationCode.TLSError }));
|
|
388
522
|
}
|
|
389
523
|
if (redirectUrl.protocol !== "https:") {
|
|
390
524
|
res.resume();
|
|
391
|
-
return reject(new
|
|
525
|
+
return reject(new import_protocol2.VerificationError("redirect to non-HTTPS URL", { code: import_protocol2.VerificationCode.TLSError }));
|
|
392
526
|
}
|
|
393
527
|
if (opts.allowedHost && !hostAllowed(redirectUrl.hostname, opts.allowedHost, opts.domainBoundary)) {
|
|
394
528
|
res.resume();
|
|
395
|
-
return reject(new
|
|
529
|
+
return reject(new import_protocol2.VerificationError(
|
|
396
530
|
`redirect changes host: expected ${opts.allowedHost}, got ${redirectUrl.hostname}`,
|
|
397
|
-
{ code:
|
|
531
|
+
{ code: import_protocol2.VerificationCode.TLSError }
|
|
398
532
|
));
|
|
399
533
|
}
|
|
400
534
|
res.resume();
|
|
@@ -402,9 +536,9 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
402
536
|
}
|
|
403
537
|
if (res.statusCode !== 200) {
|
|
404
538
|
res.resume();
|
|
405
|
-
return reject(new
|
|
539
|
+
return reject(new import_protocol2.VerificationError(
|
|
406
540
|
`unexpected HTTP status ${res.statusCode}`,
|
|
407
|
-
{ code:
|
|
541
|
+
{ code: import_protocol2.VerificationCode.TLSError, transient: (res.statusCode ?? 0) >= 500 }
|
|
408
542
|
));
|
|
409
543
|
}
|
|
410
544
|
const socket = res.socket;
|
|
@@ -420,9 +554,9 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
420
554
|
const contentLength = Number(res.headers["content-length"]);
|
|
421
555
|
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
422
556
|
res.resume();
|
|
423
|
-
return reject(new
|
|
557
|
+
return reject(new import_protocol2.VerificationError(
|
|
424
558
|
`response body exceeds ${maxBodyBytes} byte limit`,
|
|
425
|
-
{ code:
|
|
559
|
+
{ code: import_protocol2.VerificationCode.TLSError }
|
|
426
560
|
));
|
|
427
561
|
}
|
|
428
562
|
let body = "";
|
|
@@ -435,9 +569,9 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
435
569
|
if (bodyBytes > maxBodyBytes) {
|
|
436
570
|
tooLarge = true;
|
|
437
571
|
req.destroy();
|
|
438
|
-
return reject(new
|
|
572
|
+
return reject(new import_protocol2.VerificationError(
|
|
439
573
|
`response body exceeds ${maxBodyBytes} byte limit`,
|
|
440
|
-
{ code:
|
|
574
|
+
{ code: import_protocol2.VerificationCode.TLSError }
|
|
441
575
|
));
|
|
442
576
|
}
|
|
443
577
|
body += chunk;
|
|
@@ -446,12 +580,12 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
446
580
|
try {
|
|
447
581
|
resolve({ data: JSON.parse(body), tlsCert });
|
|
448
582
|
} catch {
|
|
449
|
-
reject(new
|
|
583
|
+
reject(new import_protocol2.VerificationError("response body is not valid JSON", { code: import_protocol2.VerificationCode.RecordInvalid }));
|
|
450
584
|
}
|
|
451
585
|
});
|
|
452
586
|
res.on("error", (err) => {
|
|
453
|
-
reject(new
|
|
454
|
-
code:
|
|
587
|
+
reject(new import_protocol2.VerificationError(`response stream error: ${err.message}`, {
|
|
588
|
+
code: import_protocol2.VerificationCode.TLSError,
|
|
455
589
|
transient: true
|
|
456
590
|
}));
|
|
457
591
|
});
|
|
@@ -459,16 +593,16 @@ function fetchWithRedirects(url, opts, depth) {
|
|
|
459
593
|
const timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
460
594
|
req.setTimeout(timeoutMs, () => {
|
|
461
595
|
req.destroy();
|
|
462
|
-
reject(new
|
|
463
|
-
code:
|
|
596
|
+
reject(new import_protocol2.VerificationError(`request timed out after ${timeoutMs}ms`, {
|
|
597
|
+
code: import_protocol2.VerificationCode.TLSError,
|
|
464
598
|
transient: true
|
|
465
599
|
}));
|
|
466
600
|
});
|
|
467
601
|
req.on("error", (err) => {
|
|
468
602
|
const nodeErr = err;
|
|
469
603
|
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";
|
|
470
|
-
reject(new
|
|
471
|
-
code:
|
|
604
|
+
reject(new import_protocol2.VerificationError(`HTTPS fetch failed: ${err.message}`, {
|
|
605
|
+
code: import_protocol2.VerificationCode.TLSError,
|
|
472
606
|
transient: !isTls
|
|
473
607
|
}));
|
|
474
608
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -85,10 +85,9 @@ declare function createSsrfSafeFetch(config?: TransportConfig, options?: SsrfSaf
|
|
|
85
85
|
/**
|
|
86
86
|
* Creates the DNS resolver used to fetch DNSid identity records (TXT).
|
|
87
87
|
*
|
|
88
|
-
* Uses the configured
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* Node's TXT API omits TTLs, so results use TTL 0 to disable SDK caching.
|
|
88
|
+
* Uses the configured server, or queries Node's system DNS server list in order.
|
|
89
|
+
* Wire replies supply remaining TXT TTLs; if those servers cannot answer, the
|
|
90
|
+
* system TXT API is the fallback (TTL 0). DNSSEC state is always `UNKNOWN`.
|
|
92
91
|
*/
|
|
93
92
|
declare function createDefaultDnsResolver(config: Pick<TransportConfig, 'dnsServer'>): DNSResolver;
|
|
94
93
|
/**
|
|
@@ -96,10 +95,9 @@ declare function createDefaultDnsResolver(config: Pick<TransportConfig, 'dnsServ
|
|
|
96
95
|
*
|
|
97
96
|
* Accepts `host`, `host:port`, or `[ipv6]:port`. A hostname (rather than an
|
|
98
97
|
* IP literal) is resolved once via the system resolver on first use, then
|
|
99
|
-
* cached for the resolver's lifetime. TXT answers
|
|
100
|
-
* (
|
|
101
|
-
* record set,
|
|
102
|
-
* are rethrown as-is.
|
|
98
|
+
* cached for the resolver's lifetime. TXT answers carry the remaining TTL
|
|
99
|
+
* (capped at one day) and `DNSSECState.UNKNOWN`; NXDOMAIN/NODATA yield an empty
|
|
100
|
+
* record set, and other errors (including SERVFAIL) reject.
|
|
103
101
|
*
|
|
104
102
|
* @param server - DNS server address, optionally with port.
|
|
105
103
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -85,10 +85,9 @@ declare function createSsrfSafeFetch(config?: TransportConfig, options?: SsrfSaf
|
|
|
85
85
|
/**
|
|
86
86
|
* Creates the DNS resolver used to fetch DNSid identity records (TXT).
|
|
87
87
|
*
|
|
88
|
-
* Uses the configured
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* Node's TXT API omits TTLs, so results use TTL 0 to disable SDK caching.
|
|
88
|
+
* Uses the configured server, or queries Node's system DNS server list in order.
|
|
89
|
+
* Wire replies supply remaining TXT TTLs; if those servers cannot answer, the
|
|
90
|
+
* system TXT API is the fallback (TTL 0). DNSSEC state is always `UNKNOWN`.
|
|
92
91
|
*/
|
|
93
92
|
declare function createDefaultDnsResolver(config: Pick<TransportConfig, 'dnsServer'>): DNSResolver;
|
|
94
93
|
/**
|
|
@@ -96,10 +95,9 @@ declare function createDefaultDnsResolver(config: Pick<TransportConfig, 'dnsServ
|
|
|
96
95
|
*
|
|
97
96
|
* Accepts `host`, `host:port`, or `[ipv6]:port`. A hostname (rather than an
|
|
98
97
|
* IP literal) is resolved once via the system resolver on first use, then
|
|
99
|
-
* cached for the resolver's lifetime. TXT answers
|
|
100
|
-
* (
|
|
101
|
-
* record set,
|
|
102
|
-
* are rethrown as-is.
|
|
98
|
+
* cached for the resolver's lifetime. TXT answers carry the remaining TTL
|
|
99
|
+
* (capped at one day) and `DNSSECState.UNKNOWN`; NXDOMAIN/NODATA yield an empty
|
|
100
|
+
* record set, and other errors (including SERVFAIL) reject.
|
|
103
101
|
*
|
|
104
102
|
* @param server - DNS server address, optionally with port.
|
|
105
103
|
*/
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,136 @@
|
|
|
2
2
|
import * as dnsPromises from "dns/promises";
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as https from "https";
|
|
5
|
-
import * as
|
|
5
|
+
import * as net2 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, normalizePrivateAddressHost } from "@dnsid-ai/protocol";
|
|
8
|
+
import { ArgumentError, DNSSECState as DNSSECState2, VerificationCode, VerificationError, normalizePrivateAddressHost } from "@dnsid-ai/protocol";
|
|
9
|
+
|
|
10
|
+
// src/txt-wire.ts
|
|
11
|
+
import { randomInt } from "crypto";
|
|
12
|
+
import * as dgram from "dgram";
|
|
13
|
+
import * as net from "net";
|
|
14
|
+
import packet from "dns-packet";
|
|
15
|
+
import { DNSSECState } from "@dnsid-ai/protocol";
|
|
16
|
+
var normalize = (name) => name.replace(/\.$/, "").toLowerCase();
|
|
17
|
+
var badResponse = () => new Error("invalid DNS TXT response");
|
|
18
|
+
var remainingTtl = (ttl) => ttl !== void 0 && Number.isInteger(ttl) && ttl >= 0 && ttl <= 2147483647 ? ttl : 0;
|
|
19
|
+
async function resolveTxtWithTtl(name, host, port, signal) {
|
|
20
|
+
const deadline = AbortSignal.any([signal ?? new AbortController().signal, AbortSignal.timeout(2e3)]);
|
|
21
|
+
let owner = normalize(name);
|
|
22
|
+
const seen = /* @__PURE__ */ new Set([owner]);
|
|
23
|
+
let hops = 0;
|
|
24
|
+
let ttl = Infinity;
|
|
25
|
+
while (true) {
|
|
26
|
+
deadline.throwIfAborted();
|
|
27
|
+
const queried = owner;
|
|
28
|
+
const response = await query(owner, host, port, deadline);
|
|
29
|
+
if (response.rcode === "NXDOMAIN") return [[], DNSSECState.UNKNOWN];
|
|
30
|
+
if (response.rcode !== "NOERROR") throw new Error(`DNS query failed: ${response.rcode}`);
|
|
31
|
+
const answers = response.answers ?? [];
|
|
32
|
+
while (true) {
|
|
33
|
+
const cnames = answers.filter((a) => a.type === "CNAME" && normalize(a.name) === owner && a.class === "IN");
|
|
34
|
+
if (!cnames.length) break;
|
|
35
|
+
if (cnames.length !== 1 || ++hops > 8) throw badResponse();
|
|
36
|
+
const cname = cnames[0];
|
|
37
|
+
if (typeof cname.data !== "string") throw badResponse();
|
|
38
|
+
ttl = Math.min(ttl, remainingTtl(cname.ttl));
|
|
39
|
+
owner = normalize(cname.data);
|
|
40
|
+
if (seen.has(owner)) throw badResponse();
|
|
41
|
+
seen.add(owner);
|
|
42
|
+
}
|
|
43
|
+
const txt = answers.filter((a) => a.type === "TXT" && normalize(a.name) === owner && a.class === "IN");
|
|
44
|
+
if (txt.length) {
|
|
45
|
+
ttl = Math.min(ttl, ...txt.map((a) => remainingTtl(a.ttl)));
|
|
46
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
47
|
+
return [txt.map((a) => {
|
|
48
|
+
if (!Array.isArray(a.data) || !a.data.every(Buffer.isBuffer)) throw badResponse();
|
|
49
|
+
return { strings: a.data.map((part) => decoder.decode(part)), ttl: Math.min(ttl, 86400) };
|
|
50
|
+
}), DNSSECState.UNKNOWN];
|
|
51
|
+
}
|
|
52
|
+
if (owner === queried) return [[], DNSSECState.UNKNOWN];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function query(name, host, port, signal) {
|
|
56
|
+
const id = randomInt(65536);
|
|
57
|
+
const request = {
|
|
58
|
+
type: "query",
|
|
59
|
+
id,
|
|
60
|
+
flags: packet.RECURSION_DESIRED,
|
|
61
|
+
questions: [{ type: "TXT", class: "IN", name }],
|
|
62
|
+
additionals: [{ type: "OPT", name: ".", udpPayloadSize: 1232 }]
|
|
63
|
+
};
|
|
64
|
+
const valid = (data) => {
|
|
65
|
+
try {
|
|
66
|
+
const response2 = packet.decode(data);
|
|
67
|
+
if (packet.decode.bytes > data.length || response2.type !== "response" || response2.id !== id || response2.opcode !== "QUERY" || response2.questions?.length !== 1 || response2.questions[0].type !== "TXT" || response2.questions[0].class !== "IN" || normalize(response2.questions[0].name) !== name) return null;
|
|
68
|
+
return response2;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const response = await udp(packet.encode(request), host, port, signal, valid);
|
|
74
|
+
if (!response.flag_tc) return response;
|
|
75
|
+
const tcpResponse = valid(await tcp(packet.streamEncode(request), host, port, signal));
|
|
76
|
+
if (!tcpResponse || tcpResponse.flag_tc) throw badResponse();
|
|
77
|
+
return tcpResponse;
|
|
78
|
+
}
|
|
79
|
+
function udp(buf, host, port, signal, valid) {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const sock = dgram.createSocket(net.isIP(host) === 6 ? "udp6" : "udp4");
|
|
82
|
+
let finished = false;
|
|
83
|
+
const done = (error, response) => {
|
|
84
|
+
if (finished) return;
|
|
85
|
+
finished = true;
|
|
86
|
+
signal.removeEventListener("abort", onAbort);
|
|
87
|
+
sock.close();
|
|
88
|
+
if (error) reject(error);
|
|
89
|
+
else resolve(response);
|
|
90
|
+
};
|
|
91
|
+
const onAbort = () => done(signal.reason ?? new Error("DNS query aborted"));
|
|
92
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
93
|
+
sock.on("error", done);
|
|
94
|
+
sock.on("message", (data) => {
|
|
95
|
+
const response = valid(data);
|
|
96
|
+
if (response) done(void 0, response);
|
|
97
|
+
});
|
|
98
|
+
if (signal.aborted) return onAbort();
|
|
99
|
+
sock.connect(port, host, () => {
|
|
100
|
+
if (!finished) sock.send(buf);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function tcp(buf, host, port, signal) {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const sock = net.connect({ host, port });
|
|
107
|
+
let data = Buffer.alloc(0);
|
|
108
|
+
let finished = false;
|
|
109
|
+
const done = (error, response) => {
|
|
110
|
+
if (finished) return;
|
|
111
|
+
finished = true;
|
|
112
|
+
signal.removeEventListener("abort", onAbort);
|
|
113
|
+
sock.destroy();
|
|
114
|
+
if (error) reject(error);
|
|
115
|
+
else resolve(response);
|
|
116
|
+
};
|
|
117
|
+
const onAbort = () => done(signal.reason ?? new Error("DNS query aborted"));
|
|
118
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
119
|
+
sock.on("error", done);
|
|
120
|
+
sock.on("end", () => done(badResponse()));
|
|
121
|
+
sock.on("connect", () => sock.write(buf));
|
|
122
|
+
sock.on("data", (chunk) => {
|
|
123
|
+
if (!Buffer.isBuffer(chunk) || data.length + chunk.length > 65537) return done(badResponse());
|
|
124
|
+
data = Buffer.concat([data, chunk]);
|
|
125
|
+
if (data.length >= 2 && data.length >= data.readUInt16BE(0) + 2) {
|
|
126
|
+
if (data.length !== data.readUInt16BE(0) + 2) return done(badResponse());
|
|
127
|
+
done(void 0, data.subarray(2));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if (signal.aborted) onAbort();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/index.ts
|
|
9
135
|
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
10
136
|
var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
|
11
137
|
var DNSID_USER_AGENT = "dnsid-ts";
|
|
@@ -13,16 +139,16 @@ function createDnsidFetch(config) {
|
|
|
13
139
|
if (!config.dnsServer && !config.caBundlePath) {
|
|
14
140
|
return globalThis.fetch.bind(globalThis);
|
|
15
141
|
}
|
|
16
|
-
const
|
|
17
|
-
if (config.dnsServer)
|
|
18
|
-
if (config.caBundlePath)
|
|
19
|
-
return createFetchWithDispatcher(new Agent2({ connect }));
|
|
142
|
+
const connect2 = {};
|
|
143
|
+
if (config.dnsServer) connect2.lookup = createLookup(config.dnsServer);
|
|
144
|
+
if (config.caBundlePath) connect2.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
145
|
+
return createFetchWithDispatcher(new Agent2({ connect: connect2 }));
|
|
20
146
|
}
|
|
21
147
|
function createSsrfSafeFetch(config = {}, options = {}) {
|
|
22
148
|
const privateAddressHosts = (options.privateAddressHosts ?? []).map(normalizePrivateAddressHost);
|
|
23
|
-
const
|
|
24
|
-
if (config.caBundlePath)
|
|
25
|
-
const fetchWithSafeLookup = createFetchWithDispatcher(new Agent2({ connect }));
|
|
149
|
+
const connect2 = { lookup: createSsrfSafeLookup(config.dnsServer, privateAddressHosts) };
|
|
150
|
+
if (config.caBundlePath) connect2.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
151
|
+
const fetchWithSafeLookup = createFetchWithDispatcher(new Agent2({ connect: connect2 }));
|
|
26
152
|
return async (input, init) => {
|
|
27
153
|
const url = new URL(input instanceof Request ? input.url : input);
|
|
28
154
|
if (isUnsafeIp(url.hostname)) {
|
|
@@ -42,25 +168,35 @@ function createFetchWithDispatcher(dispatcher) {
|
|
|
42
168
|
};
|
|
43
169
|
}
|
|
44
170
|
function createDefaultDnsResolver(config) {
|
|
45
|
-
|
|
171
|
+
if (config.dnsServer) return createDnsResolverFromServer(config.dnsServer);
|
|
172
|
+
const system = createSystemDnsResolver();
|
|
173
|
+
return {
|
|
174
|
+
async fetchTXT(name, options) {
|
|
175
|
+
for (const server of dnsPromises.getServers()) {
|
|
176
|
+
options?.signal?.throwIfAborted();
|
|
177
|
+
try {
|
|
178
|
+
const result = await createDnsResolverFromServer(server).fetchTXT(name, options);
|
|
179
|
+
if (result[0].length) return result;
|
|
180
|
+
break;
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (options?.signal?.aborted) throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
options?.signal?.throwIfAborted();
|
|
186
|
+
return system.fetchTXT(name, options);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
46
189
|
}
|
|
47
190
|
function createDnsResolverFromServer(server) {
|
|
48
191
|
const parsed = parseDnsServer(server);
|
|
49
|
-
if (!parsed
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
let resolverPromise = null;
|
|
192
|
+
if (!parsed) throw new ArgumentError("invalid DNS server");
|
|
193
|
+
const port = Number(parsed.port ?? 53);
|
|
194
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ArgumentError(`invalid DNS server port: ${parsed.port}`);
|
|
195
|
+
let hostPromise;
|
|
55
196
|
return {
|
|
56
|
-
async fetchTXT(name) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const resolver = new dnsPromises.Resolver();
|
|
60
|
-
resolver.setServers([formatDnsServer(address, parsed.port)]);
|
|
61
|
-
return resolver;
|
|
62
|
-
})();
|
|
63
|
-
return fetchTxtViaSystem(name, await resolverPromise);
|
|
197
|
+
async fetchTXT(name, options) {
|
|
198
|
+
hostPromise ??= net2.isIP(parsed.host) ? Promise.resolve(parsed.host) : dnsPromises.lookup(parsed.host).then(({ address }) => address);
|
|
199
|
+
return resolveTxtWithTtl(name, await hostPromise, port, options?.signal);
|
|
64
200
|
}
|
|
65
201
|
};
|
|
66
202
|
}
|
|
@@ -77,7 +213,7 @@ function parseDnsServer(server) {
|
|
|
77
213
|
return { host: server };
|
|
78
214
|
}
|
|
79
215
|
function formatDnsServer(address, port) {
|
|
80
|
-
const host =
|
|
216
|
+
const host = net2.isIP(address) === 6 ? `[${address}]` : address;
|
|
81
217
|
return port ? `${host}:${port}` : host;
|
|
82
218
|
}
|
|
83
219
|
function createLookup(dnsServer) {
|
|
@@ -85,7 +221,7 @@ function createLookup(dnsServer) {
|
|
|
85
221
|
async function resolver() {
|
|
86
222
|
resolverPromise ??= (async () => {
|
|
87
223
|
const parsed = parseDnsServer(dnsServer);
|
|
88
|
-
const server = parsed && !
|
|
224
|
+
const server = parsed && !net2.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
89
225
|
const r = new dnsPromises.Resolver();
|
|
90
226
|
r.setServers([server]);
|
|
91
227
|
return r;
|
|
@@ -116,7 +252,7 @@ function createSsrfSafeLookup(dnsServer, privateAddressHosts = []) {
|
|
|
116
252
|
if (!dnsServer) throw new Error("no custom DNS server configured");
|
|
117
253
|
resolverPromise ??= (async () => {
|
|
118
254
|
const parsed = parseDnsServer(dnsServer);
|
|
119
|
-
const server = parsed && !
|
|
255
|
+
const server = parsed && !net2.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
120
256
|
const r = new dnsPromises.Resolver();
|
|
121
257
|
r.setServers([server]);
|
|
122
258
|
return r;
|
|
@@ -148,7 +284,7 @@ function privateAddressHostMatches(hostname, privateAddressHosts) {
|
|
|
148
284
|
return privateAddressHosts.some((entry) => entry.startsWith(".") ? host === entry.slice(1) || host.endsWith(entry) : host === entry);
|
|
149
285
|
}
|
|
150
286
|
async function resolveAddresses(hostname, family, resolver) {
|
|
151
|
-
const ipFamily =
|
|
287
|
+
const ipFamily = net2.isIP(hostname);
|
|
152
288
|
if (ipFamily === 4 || ipFamily === 6) return [{ address: hostname, family: ipFamily }];
|
|
153
289
|
if (!resolver) {
|
|
154
290
|
const records = await dnsPromises.lookup(hostname, { all: true, family: family === 4 || family === 6 ? family : 0 });
|
|
@@ -167,7 +303,7 @@ function isUnsafeIp(address) {
|
|
|
167
303
|
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
168
304
|
const zoneIndex = host.indexOf("%");
|
|
169
305
|
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
170
|
-
const family =
|
|
306
|
+
const family = net2.isIP(normalized);
|
|
171
307
|
if (family === 4) return isUnsafeIpv4(normalized);
|
|
172
308
|
if (family === 6) return isUnsafeIpv6(normalized);
|
|
173
309
|
return false;
|
|
@@ -177,7 +313,7 @@ function isPrivateOrLoopbackIp(address) {
|
|
|
177
313
|
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
178
314
|
const zoneIndex = host.indexOf("%");
|
|
179
315
|
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
180
|
-
const family =
|
|
316
|
+
const family = net2.isIP(normalized);
|
|
181
317
|
if (family === 4) {
|
|
182
318
|
const [a, b] = normalized.split(".").map(Number);
|
|
183
319
|
return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
@@ -277,15 +413,13 @@ function createSystemDnsResolver(resolver = dnsPromises) {
|
|
|
277
413
|
async function fetchTxtViaSystem(name, resolver) {
|
|
278
414
|
try {
|
|
279
415
|
const records = await resolver.resolveTxt(name);
|
|
280
|
-
return [records.map((strings) => ({ strings, ttl: 0 })),
|
|
416
|
+
return [records.map((strings) => ({ strings, ttl: 0 })), DNSSECState2.UNKNOWN];
|
|
281
417
|
} catch (err) {
|
|
282
418
|
const nodeErr = err;
|
|
283
419
|
switch (nodeErr.code) {
|
|
284
420
|
case "ENODATA":
|
|
285
421
|
case "ENOTFOUND":
|
|
286
|
-
return [[],
|
|
287
|
-
case "ESERVFAIL":
|
|
288
|
-
return [[], DNSSECState.FAILED];
|
|
422
|
+
return [[], DNSSECState2.UNKNOWN];
|
|
289
423
|
default:
|
|
290
424
|
throw err;
|
|
291
425
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dnsid-ai/transport",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
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,8 @@
|
|
|
38
38
|
"typecheck": "tsc --noEmit"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@dnsid-ai/protocol": "^0.
|
|
41
|
+
"@dnsid-ai/protocol": "^0.24.1",
|
|
42
|
+
"dns-packet": "5.6.1",
|
|
42
43
|
"undici": "^8.10.2"
|
|
43
44
|
},
|
|
44
45
|
"publishConfig": {
|