@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/LICENSE +201 -0
- package/README.md +44 -0
- package/dist/index.cjs +496 -0
- package/dist/index.d.cts +160 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +453 -0
- package/package.json +48 -0
package/dist/index.d.ts
ADDED
|
@@ -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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import * as dnsPromises from "dns/promises";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as https from "https";
|
|
5
|
+
import * as net from "net";
|
|
6
|
+
import * as tls from "tls";
|
|
7
|
+
import { Agent as Agent2, fetch as undiciFetch } from "undici";
|
|
8
|
+
import { DNSSECState, VerificationCode, VerificationError } from "@dnsid-ai/protocol";
|
|
9
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
10
|
+
var DEFAULT_MAX_RESPONSE_BYTES = 1 * 1024 * 1024;
|
|
11
|
+
var DNSID_USER_AGENT = "dnsid-ts";
|
|
12
|
+
function createDnsidFetch(config) {
|
|
13
|
+
if (!config.dnsServer && !config.caBundlePath) {
|
|
14
|
+
return globalThis.fetch.bind(globalThis);
|
|
15
|
+
}
|
|
16
|
+
const connect = {};
|
|
17
|
+
if (config.dnsServer) connect.lookup = createLookup(config.dnsServer);
|
|
18
|
+
if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
19
|
+
return createFetchWithDispatcher(new Agent2({ connect }));
|
|
20
|
+
}
|
|
21
|
+
function createSsrfSafeFetch(config = {}, options = {}) {
|
|
22
|
+
const allowedUnsafeHosts = new Set((options.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost));
|
|
23
|
+
const connect = { lookup: createSsrfSafeLookup(config.dnsServer, allowedUnsafeHosts) };
|
|
24
|
+
if (config.caBundlePath) connect.ca = [...tls.rootCertificates, fs.readFileSync(config.caBundlePath, "utf8")];
|
|
25
|
+
const fetchWithSafeLookup = createFetchWithDispatcher(new Agent2({ connect }));
|
|
26
|
+
return async (input, init) => {
|
|
27
|
+
const url = new URL(input instanceof Request ? input.url : input);
|
|
28
|
+
if (isUnsafeIp(url.hostname) && !(allowedUnsafeHosts.has(url.hostname) && isPrivateOrLoopbackIp(url.hostname))) {
|
|
29
|
+
throw new VerificationError(`unsafe target IP address: ${url.hostname}`, {
|
|
30
|
+
code: VerificationCode.TLSError
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return fetchWithSafeLookup(input, { ...init, redirect: "manual" });
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function createFetchWithDispatcher(dispatcher) {
|
|
37
|
+
return async (input, init) => {
|
|
38
|
+
const { url, init: normalizedInit } = normalizeFetchArgs(input, init);
|
|
39
|
+
const fetchInit = { ...normalizedInit, dispatcher };
|
|
40
|
+
const fetchWithDispatcher = undiciFetch;
|
|
41
|
+
return fetchWithDispatcher(url, fetchInit);
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function createDefaultDnsResolver(config) {
|
|
45
|
+
return config.dnsServer ? createDnsResolverFromServer(config.dnsServer) : createSystemDnsResolver();
|
|
46
|
+
}
|
|
47
|
+
function createDnsResolverFromServer(server) {
|
|
48
|
+
const parsed = parseDnsServer(server);
|
|
49
|
+
if (!parsed || net.isIP(parsed.host)) {
|
|
50
|
+
const resolver = new dnsPromises.Resolver();
|
|
51
|
+
resolver.setServers([server]);
|
|
52
|
+
return createSystemDnsResolver(resolver);
|
|
53
|
+
}
|
|
54
|
+
let resolverPromise = null;
|
|
55
|
+
return {
|
|
56
|
+
async fetchTXT(name) {
|
|
57
|
+
resolverPromise ??= (async () => {
|
|
58
|
+
const { address } = await dnsPromises.lookup(parsed.host);
|
|
59
|
+
const resolver = new dnsPromises.Resolver();
|
|
60
|
+
resolver.setServers([formatDnsServer(address, parsed.port)]);
|
|
61
|
+
return resolver;
|
|
62
|
+
})();
|
|
63
|
+
return fetchTxtViaSystem(name, await resolverPromise);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function fetchJson(url, opts) {
|
|
68
|
+
return fetchWithRedirects(url, opts ?? {}, 0);
|
|
69
|
+
}
|
|
70
|
+
function parseDnsServer(server) {
|
|
71
|
+
const bracket = server.match(/^\[([^\]]+)](?::(\d+))?$/);
|
|
72
|
+
if (bracket) return { host: bracket[1], port: bracket[2] };
|
|
73
|
+
const idx = server.lastIndexOf(":");
|
|
74
|
+
if (idx > -1 && server.indexOf(":") === idx) {
|
|
75
|
+
return { host: server.slice(0, idx), port: server.slice(idx + 1) };
|
|
76
|
+
}
|
|
77
|
+
return { host: server };
|
|
78
|
+
}
|
|
79
|
+
function formatDnsServer(address, port) {
|
|
80
|
+
const host = net.isIP(address) === 6 ? `[${address}]` : address;
|
|
81
|
+
return port ? `${host}:${port}` : host;
|
|
82
|
+
}
|
|
83
|
+
function createLookup(dnsServer) {
|
|
84
|
+
let resolverPromise = null;
|
|
85
|
+
async function resolver() {
|
|
86
|
+
resolverPromise ??= (async () => {
|
|
87
|
+
const parsed = parseDnsServer(dnsServer);
|
|
88
|
+
const server = parsed && !net.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
89
|
+
const r = new dnsPromises.Resolver();
|
|
90
|
+
r.setServers([server]);
|
|
91
|
+
return r;
|
|
92
|
+
})();
|
|
93
|
+
return resolverPromise;
|
|
94
|
+
}
|
|
95
|
+
return (hostname, opts, cb) => {
|
|
96
|
+
const family = typeof opts === "object" ? opts.family : opts;
|
|
97
|
+
const all = typeof opts === "object" && opts.all;
|
|
98
|
+
void (async () => {
|
|
99
|
+
const r = await resolver();
|
|
100
|
+
const addresses = family === 6 ? (await r.resolve6(hostname)).map((address) => ({ address, family: 6 })) : family === 4 ? (await r.resolve4(hostname)).map((address) => ({ address, family: 4 })) : [
|
|
101
|
+
...(await r.resolve4(hostname).catch(() => [])).map((address) => ({ address, family: 4 })),
|
|
102
|
+
...(await r.resolve6(hostname).catch(() => [])).map((address) => ({ address, family: 6 }))
|
|
103
|
+
];
|
|
104
|
+
if (addresses.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
|
|
105
|
+
if (all) cb(null, addresses);
|
|
106
|
+
else {
|
|
107
|
+
const first = addresses[0];
|
|
108
|
+
cb(null, first.address, first.family);
|
|
109
|
+
}
|
|
110
|
+
})().catch((err) => cb(err, "", 0));
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function createSsrfSafeLookup(dnsServer, allowedUnsafeHosts = /* @__PURE__ */ new Set()) {
|
|
114
|
+
let resolverPromise = null;
|
|
115
|
+
async function customResolver() {
|
|
116
|
+
if (!dnsServer) throw new Error("no custom DNS server configured");
|
|
117
|
+
resolverPromise ??= (async () => {
|
|
118
|
+
const parsed = parseDnsServer(dnsServer);
|
|
119
|
+
const server = parsed && !net.isIP(parsed.host) ? formatDnsServer((await dnsPromises.lookup(parsed.host)).address, parsed.port) : dnsServer;
|
|
120
|
+
const r = new dnsPromises.Resolver();
|
|
121
|
+
r.setServers([server]);
|
|
122
|
+
return r;
|
|
123
|
+
})();
|
|
124
|
+
return resolverPromise;
|
|
125
|
+
}
|
|
126
|
+
return (hostname, opts, cb) => {
|
|
127
|
+
const rawFamily = typeof opts === "object" ? opts.family : opts;
|
|
128
|
+
const family = rawFamily === "IPv4" ? 4 : rawFamily === "IPv6" ? 6 : rawFamily;
|
|
129
|
+
const all = typeof opts === "object" && opts.all;
|
|
130
|
+
void (async () => {
|
|
131
|
+
const addresses = await resolveAddresses(hostname, family, dnsServer ? await customResolver() : void 0);
|
|
132
|
+
const unsafe = addresses.find(({ address }) => isUnsafeIp(address));
|
|
133
|
+
if (unsafe && !(allowedUnsafeHosts.has(hostname) && addresses.every(({ address }) => isPrivateOrLoopbackIp(address)))) {
|
|
134
|
+
throw new VerificationError(`unsafe resolved IP address for ${hostname}: ${unsafe.address}`, {
|
|
135
|
+
code: VerificationCode.TLSError
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (all) cb(null, addresses);
|
|
139
|
+
else {
|
|
140
|
+
const first = addresses[0];
|
|
141
|
+
cb(null, first.address, first.family);
|
|
142
|
+
}
|
|
143
|
+
})().catch((err) => cb(err, "", 0));
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function normalizeAllowedUnsafeHost(host) {
|
|
147
|
+
let parsed;
|
|
148
|
+
try {
|
|
149
|
+
parsed = new URL(`https://${host}`);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
|
|
152
|
+
}
|
|
153
|
+
if (!host || parsed.username || parsed.password || parsed.port || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
154
|
+
throw new TypeError(`invalid allowed unsafe hostname: ${host}`);
|
|
155
|
+
}
|
|
156
|
+
return parsed.hostname;
|
|
157
|
+
}
|
|
158
|
+
async function resolveAddresses(hostname, family, resolver) {
|
|
159
|
+
const ipFamily = net.isIP(hostname);
|
|
160
|
+
if (ipFamily === 4 || ipFamily === 6) return [{ address: hostname, family: ipFamily }];
|
|
161
|
+
if (!resolver) {
|
|
162
|
+
const records = await dnsPromises.lookup(hostname, { all: true, family: family === 4 || family === 6 ? family : 0 });
|
|
163
|
+
if (records.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
|
|
164
|
+
return records.map(({ address, family: recordFamily }) => ({ address, family: recordFamily }));
|
|
165
|
+
}
|
|
166
|
+
const addresses = family === 6 ? (await resolver.resolve6(hostname)).map((address) => ({ address, family: 6 })) : family === 4 ? (await resolver.resolve4(hostname)).map((address) => ({ address, family: 4 })) : [
|
|
167
|
+
...(await resolver.resolve4(hostname).catch(() => [])).map((address) => ({ address, family: 4 })),
|
|
168
|
+
...(await resolver.resolve6(hostname).catch(() => [])).map((address) => ({ address, family: 6 }))
|
|
169
|
+
];
|
|
170
|
+
if (addresses.length === 0) throw new Error(`no A/AAAA records for ${hostname}`);
|
|
171
|
+
return addresses;
|
|
172
|
+
}
|
|
173
|
+
function isUnsafeIp(address) {
|
|
174
|
+
let host = address;
|
|
175
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
176
|
+
const zoneIndex = host.indexOf("%");
|
|
177
|
+
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
178
|
+
const family = net.isIP(normalized);
|
|
179
|
+
if (family === 4) return isUnsafeIpv4(normalized);
|
|
180
|
+
if (family === 6) return isUnsafeIpv6(normalized);
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
function isPrivateOrLoopbackIp(address) {
|
|
184
|
+
let host = address;
|
|
185
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
186
|
+
const zoneIndex = host.indexOf("%");
|
|
187
|
+
const normalized = zoneIndex === -1 ? host : host.slice(0, zoneIndex);
|
|
188
|
+
const family = net.isIP(normalized);
|
|
189
|
+
if (family === 4) {
|
|
190
|
+
const [a, b] = normalized.split(".").map(Number);
|
|
191
|
+
return a === 10 || a === 127 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
|
|
192
|
+
}
|
|
193
|
+
if (family !== 6) return false;
|
|
194
|
+
const bytes = ipv6ToBytes(normalized);
|
|
195
|
+
if (!bytes) return false;
|
|
196
|
+
const mapped = ipv4FromMappedIpv6(bytes) ?? ipv4FromCompatibleIpv6(bytes);
|
|
197
|
+
if (mapped) return isPrivateOrLoopbackIp(mapped);
|
|
198
|
+
const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
|
|
199
|
+
return loopback || (bytes[0] & 254) === 252;
|
|
200
|
+
}
|
|
201
|
+
function isUnsafeIpv4(address) {
|
|
202
|
+
const octets = address.split(".").map((part) => Number(part));
|
|
203
|
+
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return true;
|
|
204
|
+
const [a, b, c] = octets;
|
|
205
|
+
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;
|
|
206
|
+
}
|
|
207
|
+
function isUnsafeIpv6(address) {
|
|
208
|
+
const bytes = ipv6ToBytes(address);
|
|
209
|
+
if (!bytes) return true;
|
|
210
|
+
const mapped = ipv4FromMappedIpv6(bytes);
|
|
211
|
+
if (mapped) return isUnsafeIpv4(mapped);
|
|
212
|
+
const compatible = ipv4FromCompatibleIpv6(bytes);
|
|
213
|
+
if (compatible) return isUnsafeIpv4(compatible);
|
|
214
|
+
const first = bytes[0];
|
|
215
|
+
const second = bytes[1];
|
|
216
|
+
const allZero = bytes.every((byte) => byte === 0);
|
|
217
|
+
const loopback = bytes.slice(0, 15).every((byte) => byte === 0) && bytes[15] === 1;
|
|
218
|
+
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;
|
|
219
|
+
}
|
|
220
|
+
function ipv4FromMappedIpv6(bytes) {
|
|
221
|
+
const isMapped = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 255 && bytes[11] === 255;
|
|
222
|
+
if (!isMapped) return null;
|
|
223
|
+
return bytes.slice(12, 16).join(".");
|
|
224
|
+
}
|
|
225
|
+
function ipv4FromCompatibleIpv6(bytes) {
|
|
226
|
+
const isCompatible = bytes.slice(0, 12).every((byte) => byte === 0);
|
|
227
|
+
if (!isCompatible) return null;
|
|
228
|
+
return bytes.slice(12, 16).join(".");
|
|
229
|
+
}
|
|
230
|
+
function ipv6ToBytes(address) {
|
|
231
|
+
let normalized = address;
|
|
232
|
+
const embeddedIpv4Start = address.lastIndexOf(":");
|
|
233
|
+
if (embeddedIpv4Start !== -1) {
|
|
234
|
+
const embeddedIpv4 = address.slice(embeddedIpv4Start + 1);
|
|
235
|
+
if (embeddedIpv4.includes(".")) {
|
|
236
|
+
const octets = embeddedIpv4.split(".").map((part) => Number(part));
|
|
237
|
+
if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
|
|
238
|
+
normalized = `${address.slice(0, embeddedIpv4Start + 1)}${(octets[0] << 8 | octets[1]).toString(16)}:${(octets[2] << 8 | octets[3]).toString(16)}`;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const halves = normalized.split("::");
|
|
242
|
+
if (halves.length > 2) return null;
|
|
243
|
+
const left = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
|
244
|
+
const right = halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
|
245
|
+
const missing = halves.length === 2 ? 8 - left.length - right.length : 0;
|
|
246
|
+
const groups = halves.length === 2 ? [...left, ...Array(missing).fill("0"), ...right] : left;
|
|
247
|
+
if (missing < 0 || groups.length !== 8) return null;
|
|
248
|
+
const bytes = [];
|
|
249
|
+
for (const group of groups) {
|
|
250
|
+
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
|
|
251
|
+
const value = Number.parseInt(group, 16);
|
|
252
|
+
bytes.push(value >> 8 & 255, value & 255);
|
|
253
|
+
}
|
|
254
|
+
return bytes;
|
|
255
|
+
}
|
|
256
|
+
function normalizeFetchArgs(input, init) {
|
|
257
|
+
if (input instanceof Request) {
|
|
258
|
+
const normalized = {
|
|
259
|
+
method: input.method,
|
|
260
|
+
headers: input.headers,
|
|
261
|
+
body: input.body,
|
|
262
|
+
cache: input.cache,
|
|
263
|
+
credentials: input.credentials,
|
|
264
|
+
integrity: input.integrity,
|
|
265
|
+
keepalive: input.keepalive,
|
|
266
|
+
mode: input.mode,
|
|
267
|
+
redirect: input.redirect,
|
|
268
|
+
referrer: input.referrer,
|
|
269
|
+
referrerPolicy: input.referrerPolicy,
|
|
270
|
+
signal: input.signal,
|
|
271
|
+
...init
|
|
272
|
+
};
|
|
273
|
+
if (normalized.body) normalized.duplex = "half";
|
|
274
|
+
return { url: input.url, init: normalized };
|
|
275
|
+
}
|
|
276
|
+
return { url: input, init: init ?? {} };
|
|
277
|
+
}
|
|
278
|
+
function createSystemDnsResolver(resolver = dnsPromises) {
|
|
279
|
+
return {
|
|
280
|
+
async fetchTXT(name) {
|
|
281
|
+
return fetchTxtViaSystem(name, resolver);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
async function fetchTxtViaSystem(name, resolver) {
|
|
286
|
+
try {
|
|
287
|
+
const records = await resolver.resolveTxt(name);
|
|
288
|
+
return [records.map((strings) => ({ strings, ttl: 0 })), DNSSECState.UNKNOWN];
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const nodeErr = err;
|
|
291
|
+
switch (nodeErr.code) {
|
|
292
|
+
case "ENODATA":
|
|
293
|
+
case "ENOTFOUND":
|
|
294
|
+
return [[], DNSSECState.UNKNOWN];
|
|
295
|
+
case "ESERVFAIL":
|
|
296
|
+
return [[], DNSSECState.FAILED];
|
|
297
|
+
default:
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function hostAllowed(host, allowedHost, domainBoundary = false) {
|
|
303
|
+
return host === allowedHost || domainBoundary && host.endsWith(`.${allowedHost}`);
|
|
304
|
+
}
|
|
305
|
+
var protocolAgent = new https.Agent({ keepAlive: true, maxCachedSessions: 0 });
|
|
306
|
+
function fetchWithRedirects(url, opts, depth) {
|
|
307
|
+
if (depth > 5) {
|
|
308
|
+
return Promise.reject(new VerificationError("too many redirects", { code: VerificationCode.TLSError }));
|
|
309
|
+
}
|
|
310
|
+
let urlObj;
|
|
311
|
+
try {
|
|
312
|
+
urlObj = new URL(url);
|
|
313
|
+
} catch {
|
|
314
|
+
return Promise.reject(new VerificationError(`invalid URL: ${url}`, { code: VerificationCode.TLSError }));
|
|
315
|
+
}
|
|
316
|
+
if (urlObj.protocol !== "https:") {
|
|
317
|
+
return Promise.reject(new VerificationError("only HTTPS URLs are permitted", { code: VerificationCode.TLSError }));
|
|
318
|
+
}
|
|
319
|
+
if (opts.allowedHost && !hostAllowed(urlObj.hostname, opts.allowedHost, opts.domainBoundary)) {
|
|
320
|
+
return Promise.reject(new VerificationError(
|
|
321
|
+
`URL host not permitted: expected ${opts.allowedHost}, got ${urlObj.hostname}`,
|
|
322
|
+
{ code: VerificationCode.TLSError }
|
|
323
|
+
));
|
|
324
|
+
}
|
|
325
|
+
if (isUnsafeIp(urlObj.hostname)) {
|
|
326
|
+
return Promise.reject(new VerificationError(`unsafe HTTPS target IP address: ${urlObj.hostname}`, {
|
|
327
|
+
code: VerificationCode.TLSError
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
return new Promise((resolve, reject) => {
|
|
331
|
+
const requestOptions = {
|
|
332
|
+
agent: protocolAgent,
|
|
333
|
+
signal: opts.signal,
|
|
334
|
+
rejectUnauthorized: true,
|
|
335
|
+
headers: {
|
|
336
|
+
"User-Agent": DNSID_USER_AGENT
|
|
337
|
+
},
|
|
338
|
+
lookup: createSsrfSafeLookup(opts.dnsServer, new Set((opts.allowedUnsafeHosts ?? []).map(normalizeAllowedUnsafeHost)))
|
|
339
|
+
};
|
|
340
|
+
if (opts.caBundlePath) requestOptions.ca = [...tls.rootCertificates, fs.readFileSync(opts.caBundlePath, "utf8")];
|
|
341
|
+
const req = https.get(url, requestOptions, (res) => {
|
|
342
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
|
343
|
+
const location = res.headers.location;
|
|
344
|
+
if (!location) {
|
|
345
|
+
res.resume();
|
|
346
|
+
return reject(new VerificationError("redirect with no Location header", { code: VerificationCode.TLSError }));
|
|
347
|
+
}
|
|
348
|
+
let redirectUrl;
|
|
349
|
+
try {
|
|
350
|
+
redirectUrl = new URL(location, url);
|
|
351
|
+
} catch {
|
|
352
|
+
res.resume();
|
|
353
|
+
return reject(new VerificationError(`invalid redirect URL: ${location}`, { code: VerificationCode.TLSError }));
|
|
354
|
+
}
|
|
355
|
+
if (redirectUrl.protocol !== "https:") {
|
|
356
|
+
res.resume();
|
|
357
|
+
return reject(new VerificationError("redirect to non-HTTPS URL", { code: VerificationCode.TLSError }));
|
|
358
|
+
}
|
|
359
|
+
if (opts.allowedHost && !hostAllowed(redirectUrl.hostname, opts.allowedHost, opts.domainBoundary)) {
|
|
360
|
+
res.resume();
|
|
361
|
+
return reject(new VerificationError(
|
|
362
|
+
`redirect changes host: expected ${opts.allowedHost}, got ${redirectUrl.hostname}`,
|
|
363
|
+
{ code: VerificationCode.TLSError }
|
|
364
|
+
));
|
|
365
|
+
}
|
|
366
|
+
res.resume();
|
|
367
|
+
return fetchWithRedirects(redirectUrl.toString(), opts, depth + 1).then(resolve, reject);
|
|
368
|
+
}
|
|
369
|
+
if (res.statusCode !== 200) {
|
|
370
|
+
res.resume();
|
|
371
|
+
return reject(new VerificationError(
|
|
372
|
+
`unexpected HTTP status ${res.statusCode}`,
|
|
373
|
+
{ code: VerificationCode.TLSError, transient: (res.statusCode ?? 0) >= 500 }
|
|
374
|
+
));
|
|
375
|
+
}
|
|
376
|
+
const socket = res.socket;
|
|
377
|
+
const peerCert = socket.getPeerCertificate();
|
|
378
|
+
const san = [];
|
|
379
|
+
if (peerCert.subjectaltname) {
|
|
380
|
+
for (const part of peerCert.subjectaltname.split(", ")) {
|
|
381
|
+
if (part.startsWith("DNS:")) san.push(part.slice(4));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const tlsCert = { notAfter: new Date(peerCert.valid_to), san };
|
|
385
|
+
const maxBodyBytes = opts.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
386
|
+
const contentLength = Number(res.headers["content-length"]);
|
|
387
|
+
if (Number.isFinite(contentLength) && contentLength > maxBodyBytes) {
|
|
388
|
+
res.resume();
|
|
389
|
+
return reject(new VerificationError(
|
|
390
|
+
`response body exceeds ${maxBodyBytes} byte limit`,
|
|
391
|
+
{ code: VerificationCode.TLSError }
|
|
392
|
+
));
|
|
393
|
+
}
|
|
394
|
+
let body = "";
|
|
395
|
+
let bodyBytes = 0;
|
|
396
|
+
let tooLarge = false;
|
|
397
|
+
res.setEncoding("utf8");
|
|
398
|
+
res.on("data", (chunk) => {
|
|
399
|
+
if (tooLarge) return;
|
|
400
|
+
bodyBytes += Buffer.byteLength(chunk, "utf8");
|
|
401
|
+
if (bodyBytes > maxBodyBytes) {
|
|
402
|
+
tooLarge = true;
|
|
403
|
+
req.destroy();
|
|
404
|
+
return reject(new VerificationError(
|
|
405
|
+
`response body exceeds ${maxBodyBytes} byte limit`,
|
|
406
|
+
{ code: VerificationCode.TLSError }
|
|
407
|
+
));
|
|
408
|
+
}
|
|
409
|
+
body += chunk;
|
|
410
|
+
});
|
|
411
|
+
res.on("end", () => {
|
|
412
|
+
try {
|
|
413
|
+
resolve({ data: JSON.parse(body), tlsCert });
|
|
414
|
+
} catch {
|
|
415
|
+
reject(new VerificationError("response body is not valid JSON", { code: VerificationCode.RecordInvalid }));
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
res.on("error", (err) => {
|
|
419
|
+
reject(new VerificationError(`response stream error: ${err.message}`, {
|
|
420
|
+
code: VerificationCode.TLSError,
|
|
421
|
+
transient: true
|
|
422
|
+
}));
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
426
|
+
req.setTimeout(timeoutMs, () => {
|
|
427
|
+
req.destroy();
|
|
428
|
+
reject(new VerificationError(`request timed out after ${timeoutMs}ms`, {
|
|
429
|
+
code: VerificationCode.TLSError,
|
|
430
|
+
transient: true
|
|
431
|
+
}));
|
|
432
|
+
});
|
|
433
|
+
req.on("error", (err) => {
|
|
434
|
+
const nodeErr = err;
|
|
435
|
+
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";
|
|
436
|
+
reject(new VerificationError(`HTTPS fetch failed: ${err.message}`, {
|
|
437
|
+
code: VerificationCode.TLSError,
|
|
438
|
+
transient: !isTls
|
|
439
|
+
}));
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
export {
|
|
444
|
+
createDefaultDnsResolver,
|
|
445
|
+
createDnsResolverFromServer,
|
|
446
|
+
createDnsidFetch,
|
|
447
|
+
createLookup,
|
|
448
|
+
createSsrfSafeFetch,
|
|
449
|
+
fetchJson,
|
|
450
|
+
formatDnsServer,
|
|
451
|
+
isUnsafeIp,
|
|
452
|
+
parseDnsServer
|
|
453
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dnsid-ai/transport",
|
|
3
|
+
"version": "0.20.1",
|
|
4
|
+
"description": "Node.js DNS and HTTPS transport implementations for DNSid.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.0.0"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/dnsid-ai/dnsid-ts.git",
|
|
23
|
+
"directory": "packages/transport"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/dnsid-ai/dnsid-ts/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/dnsid-ai/dnsid-ts#readme",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"dnsid",
|
|
31
|
+
"dns",
|
|
32
|
+
"https",
|
|
33
|
+
"transport",
|
|
34
|
+
"node"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@dnsid-ai/protocol": "^0.20.1",
|
|
42
|
+
"undici": "^8.10.2"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"license": "Apache-2.0"
|
|
48
|
+
}
|