n8n-nodes-dominusnode 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/dist/credentials/DominusNodeApi.credentials.d.ts +7 -0
  5. package/dist/credentials/DominusNodeApi.credentials.js +42 -0
  6. package/dist/credentials/DominusNodeApi.credentials.js.map +1 -0
  7. package/dist/index.d.ts +4 -0
  8. package/dist/index.js +12 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/nodes/DominusNodeProxy/DominusNodeProxy.node.d.ts +24 -0
  11. package/dist/nodes/DominusNodeProxy/DominusNodeProxy.node.js +436 -0
  12. package/dist/nodes/DominusNodeProxy/DominusNodeProxy.node.js.map +1 -0
  13. package/dist/nodes/DominusNodeUsage/DominusNodeUsage.node.d.ts +13 -0
  14. package/dist/nodes/DominusNodeUsage/DominusNodeUsage.node.js +105 -0
  15. package/dist/nodes/DominusNodeUsage/DominusNodeUsage.node.js.map +1 -0
  16. package/dist/nodes/DominusNodeWallet/DominusNodeWallet.node.d.ts +33 -0
  17. package/dist/nodes/DominusNodeWallet/DominusNodeWallet.node.js +656 -0
  18. package/dist/nodes/DominusNodeWallet/DominusNodeWallet.node.js.map +1 -0
  19. package/dist/shared/auth.d.ts +74 -0
  20. package/dist/shared/auth.js +264 -0
  21. package/dist/shared/auth.js.map +1 -0
  22. package/dist/shared/constants.d.ts +9 -0
  23. package/dist/shared/constants.js +13 -0
  24. package/dist/shared/constants.js.map +1 -0
  25. package/dist/shared/ssrf.d.ts +42 -0
  26. package/dist/shared/ssrf.js +252 -0
  27. package/dist/shared/ssrf.js.map +1 -0
  28. package/package.json +41 -0
  29. package/src/credentials/DominusNodeApi.credentials.ts +39 -0
  30. package/src/index.ts +4 -0
  31. package/src/nodes/DominusNodeProxy/DominusNodeProxy.node.ts +459 -0
  32. package/src/nodes/DominusNodeUsage/DominusNodeUsage.node.ts +130 -0
  33. package/src/nodes/DominusNodeWallet/DominusNodeWallet.node.ts +898 -0
  34. package/src/shared/auth.ts +272 -0
  35. package/src/shared/constants.ts +11 -0
  36. package/src/shared/ssrf.ts +257 -0
  37. package/tests/DominusNodeProxy.test.ts +281 -0
  38. package/tests/DominusNodeUsage.test.ts +250 -0
  39. package/tests/DominusNodeWallet.test.ts +591 -0
  40. package/tests/ssrf.test.ts +238 -0
  41. package/tsconfig.json +18 -0
  42. package/vitest.config.ts +8 -0
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Authentication and security utilities for the DomiNode n8n integration.
3
+ *
4
+ * Provides:
5
+ * - Credential sanitization (redact dn_live_/dn_test_ keys from errors)
6
+ * - Prototype pollution prevention (strip dangerous keys from parsed JSON)
7
+ * - DNS rebinding protection (resolve hostnames, check all IPs)
8
+ * - Authenticated HTTP client for the DomiNode REST API
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import dns from "dns/promises";
14
+ import { isPrivateIp } from "./ssrf";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Constants
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const CREDENTIAL_RE = /dn_(live|test)_[a-zA-Z0-9]+/g;
21
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
22
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10 MB
23
+ const SANCTIONED_COUNTRIES = new Set(["CU", "IR", "KP", "RU", "SY"]);
24
+
25
+ export { SANCTIONED_COUNTRIES };
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Credential sanitization
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Redact DomiNode API keys from error messages to prevent credential leakage.
33
+ */
34
+ export function sanitizeError(message: string): string {
35
+ return message.replace(CREDENTIAL_RE, "***");
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Prototype pollution prevention
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /**
43
+ * Recursively remove __proto__, constructor, and prototype keys from an object.
44
+ * Prevents prototype pollution when parsing untrusted JSON.
45
+ */
46
+ export function stripDangerousKeys(obj: unknown, depth = 0): void {
47
+ if (depth > 50 || !obj || typeof obj !== "object") return;
48
+ if (Array.isArray(obj)) {
49
+ for (const item of obj) stripDangerousKeys(item, depth + 1);
50
+ return;
51
+ }
52
+ const record = obj as Record<string, unknown>;
53
+ for (const key of Object.keys(record)) {
54
+ if (DANGEROUS_KEYS.has(key)) {
55
+ delete record[key];
56
+ } else if (record[key] && typeof record[key] === "object") {
57
+ stripDangerousKeys(record[key], depth + 1);
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Parse JSON safely, stripping prototype pollution vectors.
64
+ */
65
+ export function safeJsonParse<T>(text: string): T {
66
+ const parsed = JSON.parse(text);
67
+ stripDangerousKeys(parsed);
68
+ return parsed as T;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // DNS rebinding protection
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Resolve a hostname and verify none of the resolved IPs are private.
77
+ * Prevents DNS rebinding attacks where a hostname initially resolves to a
78
+ * public IP during validation but later resolves to a private IP.
79
+ */
80
+ export async function checkDnsRebinding(hostname: string): Promise<void> {
81
+ // Skip if hostname is already an IP literal
82
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || hostname.startsWith("[")) {
83
+ return;
84
+ }
85
+
86
+ // Check IPv4 addresses
87
+ try {
88
+ const addresses = await dns.resolve4(hostname);
89
+ for (const addr of addresses) {
90
+ if (isPrivateIp(addr)) {
91
+ throw new Error(`Hostname resolves to private IP ${addr}`);
92
+ }
93
+ }
94
+ } catch (err) {
95
+ if ((err as NodeJS.ErrnoException).code === "ENOTFOUND") {
96
+ throw new Error(`Could not resolve hostname: ${hostname}`);
97
+ }
98
+ if (err instanceof Error && err.message.includes("private IP")) throw err;
99
+ }
100
+
101
+ // Check IPv6 addresses
102
+ try {
103
+ const addresses = await dns.resolve6(hostname);
104
+ for (const addr of addresses) {
105
+ if (isPrivateIp(addr)) {
106
+ throw new Error(`Hostname resolves to private IPv6 ${addr}`);
107
+ }
108
+ }
109
+ } catch (err) {
110
+ // Re-throw if we detected a private IPv6 address
111
+ if (err instanceof Error && err.message.includes("private IPv6")) throw err;
112
+ // IPv6 resolution failure is acceptable
113
+ }
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Period to date range helper
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Convert a human-readable period string to ISO date range.
122
+ * Backend expects since/until ISO dates, NOT a days integer.
123
+ */
124
+ export function periodToDateRange(period: string): { since: string; until: string } {
125
+ const now = new Date();
126
+ const until = now.toISOString();
127
+ let since: Date;
128
+
129
+ switch (period) {
130
+ case "day":
131
+ since = new Date(now.getTime() - 24 * 60 * 60 * 1000);
132
+ break;
133
+ case "week":
134
+ since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
135
+ break;
136
+ case "month":
137
+ default:
138
+ since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
139
+ break;
140
+ }
141
+
142
+ return { since: since.toISOString(), until };
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // DominusNodeAuth — authenticated HTTP client
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * Authenticated HTTP client for the DomiNode REST API.
151
+ *
152
+ * Lazily authenticates on first request using the API key via the
153
+ * /api/auth/verify-key endpoint. Automatically retries on 401 (token expired).
154
+ */
155
+ export class DominusNodeAuth {
156
+ private token: string | null = null;
157
+ private authPromise: Promise<void> | null = null;
158
+
159
+ constructor(
160
+ private apiKey: string,
161
+ private baseUrl: string,
162
+ private timeoutMs: number = 30000,
163
+ ) {
164
+ if (!apiKey || typeof apiKey !== "string") {
165
+ throw new Error("apiKey is required and must be a non-empty string");
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Ensure the client is authenticated. Lazily authenticates on first call.
171
+ * Concurrent calls share the same auth promise to avoid duplicate requests.
172
+ */
173
+ async ensureAuth(): Promise<void> {
174
+ if (this.token) return;
175
+ if (!this.authPromise) {
176
+ this.authPromise = this.authenticate().finally(() => {
177
+ this.authPromise = null;
178
+ });
179
+ }
180
+ await this.authPromise;
181
+ }
182
+
183
+ /**
184
+ * Make an authenticated API request.
185
+ *
186
+ * @param method - HTTP method (GET, POST, PATCH, DELETE)
187
+ * @param path - API path (e.g., /api/wallet)
188
+ * @param body - Optional request body (will be JSON-serialized)
189
+ * @returns Parsed response body
190
+ * @throws {Error} On HTTP errors or response size violations
191
+ */
192
+ async apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
193
+ await this.ensureAuth();
194
+
195
+ if (!this.token) throw new Error("Not authenticated");
196
+
197
+ const url = `${this.baseUrl}${path}`;
198
+
199
+ const headers: Record<string, string> = {
200
+ "User-Agent": "n8n-nodes-dominusnode/1.0.0",
201
+ "Content-Type": "application/json",
202
+ Authorization: `Bearer ${this.token}`,
203
+ };
204
+
205
+ const response = await fetch(url, {
206
+ method,
207
+ headers,
208
+ body: body !== undefined ? JSON.stringify(body) : undefined,
209
+ signal: AbortSignal.timeout(this.timeoutMs),
210
+ redirect: "error",
211
+ });
212
+
213
+ const contentLength = parseInt(response.headers.get("content-length") ?? "0", 10);
214
+ if (contentLength > MAX_RESPONSE_BYTES) {
215
+ throw new Error("Response body too large");
216
+ }
217
+
218
+ const responseText = await response.text();
219
+ if (responseText.length > MAX_RESPONSE_BYTES) {
220
+ throw new Error("Response body exceeds size limit");
221
+ }
222
+
223
+ if (!response.ok) {
224
+ // On 401, clear token so ensureAuth will re-authenticate
225
+ if (response.status === 401) {
226
+ this.token = null;
227
+ }
228
+ let message: string;
229
+ try {
230
+ const parsed = JSON.parse(responseText);
231
+ message = parsed.error ?? parsed.message ?? responseText;
232
+ } catch {
233
+ message = responseText;
234
+ }
235
+ if (message.length > 500) message = message.slice(0, 500) + "... [truncated]";
236
+ throw new Error(`API error ${response.status}: ${sanitizeError(message)}`);
237
+ }
238
+
239
+ return responseText ? safeJsonParse(responseText) : {};
240
+ }
241
+
242
+ /**
243
+ * Clear auth token, forcing re-authentication on next request.
244
+ */
245
+ clearToken(): void {
246
+ this.token = null;
247
+ }
248
+
249
+ private async authenticate(): Promise<void> {
250
+ const response = await fetch(`${this.baseUrl}/api/auth/verify-key`, {
251
+ method: "POST",
252
+ headers: {
253
+ "User-Agent": "n8n-nodes-dominusnode/1.0.0",
254
+ "Content-Type": "application/json",
255
+ },
256
+ body: JSON.stringify({ apiKey: this.apiKey }),
257
+ signal: AbortSignal.timeout(this.timeoutMs),
258
+ redirect: "error",
259
+ });
260
+
261
+ if (!response.ok) {
262
+ const text = await response.text();
263
+ throw new Error(`Authentication failed (${response.status}): ${sanitizeError(text.slice(0, 500))}`);
264
+ }
265
+
266
+ const data = safeJsonParse<{ token: string }>(await response.text());
267
+ if (!data.token) {
268
+ throw new Error("Authentication response missing token");
269
+ }
270
+ this.token = data.token;
271
+ }
272
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Constants shared across all DomiNode n8n nodes.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /** HTTP methods allowed for proxied fetch (read-only to prevent abuse). */
8
+ export const ALLOWED_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
9
+
10
+ /** Maximum response body length returned to n8n workflows. */
11
+ export const MAX_BODY_TRUNCATE = 4000;
@@ -0,0 +1,257 @@
1
+ /**
2
+ * SSRF prevention utilities for the DomiNode n8n integration.
3
+ *
4
+ * Blocks private IPs, localhost, internal hostnames, embedded credentials,
5
+ * hex/octal/decimal-encoded IPs, IPv4-mapped IPv6, IPv4-compatible IPv6,
6
+ * Teredo (2001:0000::/32), 6to4 (2002::/16), CGNAT, multicast, .localhost,
7
+ * .local, .internal, .arpa TLDs, and IPv6 zone IDs.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Blocked hostnames
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const BLOCKED_HOSTNAMES = new Set([
17
+ "localhost",
18
+ "localhost.localdomain",
19
+ "ip6-localhost",
20
+ "ip6-loopback",
21
+ "[::1]",
22
+ "[::ffff:127.0.0.1]",
23
+ "0.0.0.0",
24
+ "[::]",
25
+ "metadata.google.internal",
26
+ "169.254.169.254",
27
+ ]);
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // IPv4 normalization (hex, octal, decimal integer)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Normalize non-standard IPv4 representations to standard dotted-decimal.
35
+ * Handles decimal integers (2130706433), hex (0x7f000001), and octal (0177.0.0.1).
36
+ *
37
+ * @returns Normalized dotted-decimal string or null if not a recognizable IP.
38
+ */
39
+ export function normalizeIpv4(hostname: string): string | null {
40
+ // Single decimal integer (e.g., 2130706433 = 127.0.0.1)
41
+ if (/^\d+$/.test(hostname)) {
42
+ const n = parseInt(hostname, 10);
43
+ if (n >= 0 && n <= 0xffffffff) {
44
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
45
+ }
46
+ }
47
+
48
+ // Hex notation (e.g., 0x7f000001)
49
+ if (/^0x[0-9a-fA-F]+$/i.test(hostname)) {
50
+ const n = parseInt(hostname, 16);
51
+ if (n >= 0 && n <= 0xffffffff) {
52
+ return `${(n >>> 24) & 0xff}.${(n >>> 16) & 0xff}.${(n >>> 8) & 0xff}.${n & 0xff}`;
53
+ }
54
+ }
55
+
56
+ // Octal or mixed-radix octets (e.g., 0177.0.0.1)
57
+ const parts = hostname.split(".");
58
+ if (parts.length === 4) {
59
+ const octets: number[] = [];
60
+ for (const part of parts) {
61
+ let val: number;
62
+ if (/^0x[0-9a-fA-F]+$/i.test(part)) {
63
+ val = parseInt(part, 16);
64
+ } else if (/^0\d+$/.test(part)) {
65
+ val = parseInt(part, 8);
66
+ } else if (/^\d+$/.test(part)) {
67
+ val = parseInt(part, 10);
68
+ } else {
69
+ return null;
70
+ }
71
+ if (isNaN(val) || val < 0 || val > 255) return null;
72
+ octets.push(val);
73
+ }
74
+ return octets.join(".");
75
+ }
76
+
77
+ return null;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Private IP detection
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Check whether a hostname/IP is a private, loopback, link-local, CGNAT,
86
+ * multicast, or other reserved address.
87
+ *
88
+ * Handles:
89
+ * - Standard IPv4 private ranges (10/8, 172.16/12, 192.168/16, 127/8, 0/8)
90
+ * - CGNAT (100.64/10)
91
+ * - Multicast (224/4) and reserved (240+)
92
+ * - Link-local (169.254/16)
93
+ * - IPv6 loopback (::1), unspecified (::), ULA (fc00::/7), link-local (fe80::/10)
94
+ * - IPv4-mapped IPv6 (::ffff:x.x.x.x), IPv4-compatible IPv6 (::x.x.x.x)
95
+ * - Teredo tunneling (2001:0000::/32) — embeds IPv4 in last 32 bits
96
+ * - 6to4 (2002::/16) — embeds IPv4 in bits 16-48
97
+ * - Bracketed IPv6 ([::1])
98
+ * - IPv6 zone IDs (%eth0)
99
+ */
100
+ export function isPrivateIp(hostname: string): boolean {
101
+ let ip = hostname.replace(/^\[|\]$/g, "");
102
+
103
+ // Strip IPv6 zone ID (%25eth0, %eth0)
104
+ const zoneIdx = ip.indexOf("%");
105
+ if (zoneIdx !== -1) {
106
+ ip = ip.substring(0, zoneIdx);
107
+ }
108
+
109
+ const normalized = normalizeIpv4(ip);
110
+ const checkIp = normalized ?? ip;
111
+
112
+ // IPv4 private ranges
113
+ const ipv4Match = checkIp.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
114
+ if (ipv4Match) {
115
+ const a = Number(ipv4Match[1]);
116
+ const b = Number(ipv4Match[2]);
117
+ if (a === 0) return true; // 0.0.0.0/8
118
+ if (a === 10) return true; // 10.0.0.0/8
119
+ if (a === 127) return true; // 127.0.0.0/8
120
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16
121
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
122
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
123
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
124
+ if (a >= 224) return true; // multicast + reserved
125
+ return false;
126
+ }
127
+
128
+ // IPv6 private ranges
129
+ const ipLower = ip.toLowerCase();
130
+ if (ipLower === "::1") return true;
131
+ if (ipLower === "::") return true;
132
+ if (ipLower.startsWith("fc") || ipLower.startsWith("fd")) return true;
133
+ if (ipLower.startsWith("fe80")) return true;
134
+
135
+ // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:HHHH:HHHH)
136
+ if (ipLower.startsWith("::ffff:")) {
137
+ const embedded = ipLower.slice(7);
138
+ if (embedded.includes(".")) return isPrivateIp(embedded);
139
+ // Hex form: ::ffff:7f00:0001
140
+ const hexParts = embedded.split(":");
141
+ if (hexParts.length === 2) {
142
+ const hi = parseInt(hexParts[0], 16);
143
+ const lo = parseInt(hexParts[1], 16);
144
+ if (!isNaN(hi) && !isNaN(lo)) {
145
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
146
+ return isPrivateIp(reconstructed);
147
+ }
148
+ }
149
+ return isPrivateIp(embedded);
150
+ }
151
+
152
+ // IPv4-compatible IPv6 (::x.x.x.x or ::HHHH:HHHH without ffff)
153
+ if (ipLower.startsWith("::") && !ipLower.startsWith("::ffff:")) {
154
+ const rest = ipLower.slice(2);
155
+ if (rest && rest.includes(".")) return isPrivateIp(rest);
156
+ // Hex form: ::7f00:0001
157
+ const hexParts = rest.split(":");
158
+ if (hexParts.length === 2 && hexParts[0] && hexParts[1]) {
159
+ const hi = parseInt(hexParts[0], 16);
160
+ const lo = parseInt(hexParts[1], 16);
161
+ if (!isNaN(hi) && !isNaN(lo)) {
162
+ const reconstructed = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
163
+ return isPrivateIp(reconstructed);
164
+ }
165
+ }
166
+ }
167
+
168
+ // Teredo tunneling (2001:0000::/32) — last 32 bits are inverted client IPv4
169
+ if (ipLower.startsWith("2001:0000:") || ipLower.startsWith("2001:0:")) {
170
+ const segments = ipLower.split(":");
171
+ if (segments.length >= 8) {
172
+ const hi = parseInt(segments[6], 16);
173
+ const lo = parseInt(segments[7], 16);
174
+ if (!isNaN(hi) && !isNaN(lo)) {
175
+ // Teredo inverts the IPv4 bits
176
+ const invertedIp = `${((hi >> 8) & 0xff) ^ 0xff}.${(hi & 0xff) ^ 0xff}.${((lo >> 8) & 0xff) ^ 0xff}.${(lo & 0xff) ^ 0xff}`;
177
+ return isPrivateIp(invertedIp);
178
+ }
179
+ }
180
+ // If we can't parse it, block conservatively
181
+ return true;
182
+ }
183
+
184
+ // 6to4 (2002::/16) — bits 16-48 contain the embedded IPv4
185
+ if (ipLower.startsWith("2002:")) {
186
+ const segments = ipLower.split(":");
187
+ if (segments.length >= 3) {
188
+ const hi = parseInt(segments[1], 16);
189
+ const lo = parseInt(segments[2], 16);
190
+ if (!isNaN(hi) && !isNaN(lo)) {
191
+ const embeddedIp = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
192
+ return isPrivateIp(embeddedIp);
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+
198
+ // IPv6 multicast (ff00::/8)
199
+ if (ipLower.startsWith("ff")) return true;
200
+
201
+ return false;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // URL validation
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * Validate a URL for safety before sending through the proxy.
210
+ * Blocks private IPs, localhost, internal hostnames, non-HTTP(S) protocols,
211
+ * and embedded credentials.
212
+ *
213
+ * @throws {Error} If the URL is invalid or targets a private/blocked address.
214
+ */
215
+ export function validateUrl(url: string): URL {
216
+ let parsed: URL;
217
+ try {
218
+ parsed = new URL(url);
219
+ } catch {
220
+ throw new Error(`Invalid URL: ${url}`);
221
+ }
222
+
223
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
224
+ throw new Error(`Only http: and https: protocols are supported, got ${parsed.protocol}`);
225
+ }
226
+
227
+ const hostname = parsed.hostname.toLowerCase();
228
+
229
+ if (BLOCKED_HOSTNAMES.has(hostname)) {
230
+ throw new Error("Requests to localhost/loopback addresses are blocked");
231
+ }
232
+
233
+ if (isPrivateIp(hostname)) {
234
+ throw new Error("Requests to private/internal IP addresses are blocked");
235
+ }
236
+
237
+ // .localhost TLD (RFC 6761)
238
+ if (hostname.endsWith(".localhost")) {
239
+ throw new Error("Requests to localhost/loopback addresses are blocked");
240
+ }
241
+
242
+ // Internal network hostnames
243
+ if (
244
+ hostname.endsWith(".local") ||
245
+ hostname.endsWith(".internal") ||
246
+ hostname.endsWith(".arpa")
247
+ ) {
248
+ throw new Error("Requests to internal network hostnames are blocked");
249
+ }
250
+
251
+ // Block embedded credentials in URL
252
+ if (parsed.username || parsed.password) {
253
+ throw new Error("URLs with embedded credentials are not allowed");
254
+ }
255
+
256
+ return parsed;
257
+ }