ssrf-agent-guard 0.1.6 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs.js CHANGED
@@ -7,12 +7,36 @@ var https = require('https');
7
7
  var isValidDomain = require('is-valid-domain');
8
8
  var ipaddr = require('ipaddr.js');
9
9
 
10
- const CLOUD_METADATA_HOSTS = [
10
+ // lib/types.ts
11
+ /**
12
+ * Default cloud metadata hosts to block.
13
+ * Includes AWS, GCP, Azure, Oracle Cloud, DigitalOcean, and Kubernetes.
14
+ */
15
+ const CLOUD_METADATA_HOSTS = new Set([
16
+ // AWS EC2 metadata service
11
17
  '169.254.169.254',
12
18
  '169.254.169.253',
19
+ // GCP metadata service
13
20
  'metadata.google.internal',
21
+ 'metadata.goog',
22
+ // Azure IMDS
23
+ '169.254.169.254',
24
+ '168.63.129.16',
25
+ // ECS task metadata (AWS Fargate)
14
26
  '169.254.170.2',
15
- ];
27
+ // Kubernetes metadata
28
+ 'kubernetes.default',
29
+ 'kubernetes.default.svc',
30
+ 'kubernetes.default.svc.cluster.local',
31
+ // Oracle Cloud
32
+ '169.254.169.254',
33
+ // DigitalOcean
34
+ '169.254.169.254',
35
+ // Alibaba Cloud
36
+ '100.100.100.200',
37
+ // Link-local for metadata
38
+ '169.254.0.0',
39
+ ]);
16
40
 
17
41
  // lib/utils.ts
18
42
  /**
@@ -27,87 +51,266 @@ function isIp(input) {
27
51
  function isPublicIp(ip) {
28
52
  return ipaddr.parse(ip).range() === 'unicast';
29
53
  }
54
+ /**
55
+ * Extracts the TLD from a hostname.
56
+ * @param hostname The hostname to extract TLD from
57
+ * @returns The TLD or empty string if not found
58
+ */
59
+ function getTLD(hostname) {
60
+ const parts = hostname.toLowerCase().split('.');
61
+ return parts.length > 0 ? parts[parts.length - 1] : '';
62
+ }
63
+ /**
64
+ * Checks if a hostname matches a domain pattern.
65
+ * Supports exact match and wildcard subdomain matching.
66
+ * @param hostname The hostname to check
67
+ * @param pattern The domain pattern (e.g., 'example.com' or '*.example.com')
68
+ */
69
+ function matchesDomain(hostname, pattern) {
70
+ const normalizedHost = hostname.toLowerCase();
71
+ const normalizedPattern = pattern.toLowerCase();
72
+ // Exact match
73
+ if (normalizedHost === normalizedPattern) {
74
+ return true;
75
+ }
76
+ // Wildcard match (*.example.com matches sub.example.com)
77
+ if (normalizedPattern.startsWith('*.')) {
78
+ const baseDomain = normalizedPattern.slice(2);
79
+ return normalizedHost.endsWith('.' + baseDomain) || normalizedHost === baseDomain;
80
+ }
81
+ // Subdomain match (example.com matches sub.example.com)
82
+ return normalizedHost.endsWith('.' + normalizedPattern);
83
+ }
84
+ /**
85
+ * Checks if a hostname matches any domain in a list.
86
+ */
87
+ function matchesAnyDomain(hostname, domains) {
88
+ return domains.some(domain => matchesDomain(hostname, domain));
89
+ }
90
+ /**
91
+ * Validates a host against policy options.
92
+ * @param hostname The hostname to validate
93
+ * @param policy The policy options
94
+ * @returns ValidationResult with safe status and reason if blocked
95
+ */
96
+ function validatePolicy(hostname, policy) {
97
+ if (!policy) {
98
+ return { safe: true };
99
+ }
100
+ // Check allowDomains first (explicit allowlist takes precedence)
101
+ if (policy.allowDomains && policy.allowDomains.length > 0) {
102
+ if (matchesAnyDomain(hostname, policy.allowDomains)) {
103
+ return { safe: true };
104
+ }
105
+ // If allowDomains is specified but host doesn't match, it's not allowed
106
+ return { safe: false, reason: 'not_allowed_domain' };
107
+ }
108
+ // Check denyDomains
109
+ if (policy.denyDomains && policy.denyDomains.length > 0) {
110
+ if (matchesAnyDomain(hostname, policy.denyDomains)) {
111
+ return { safe: false, reason: 'denied_domain' };
112
+ }
113
+ }
114
+ // Check denyTLD
115
+ if (policy.denyTLD && policy.denyTLD.length > 0) {
116
+ const tld = getTLD(hostname);
117
+ if (policy.denyTLD.map(t => t.toLowerCase()).includes(tld)) {
118
+ return { safe: false, reason: 'denied_tld' };
119
+ }
120
+ }
121
+ return { safe: true };
122
+ }
123
+ /**
124
+ * Checks if a hostname is a cloud metadata endpoint.
125
+ * @param hostname The hostname to check
126
+ * @param customHosts Additional custom metadata hosts to check
127
+ */
128
+ function isCloudMetadata(hostname, customHosts) {
129
+ if (CLOUD_METADATA_HOSTS.has(hostname)) {
130
+ return true;
131
+ }
132
+ if (customHosts && customHosts.includes(hostname)) {
133
+ return true;
134
+ }
135
+ return false;
136
+ }
30
137
  /**
31
138
  * High-level validation for hostnames (domains + public IPs).
139
+ * Returns detailed validation result with reason for blocking.
140
+ *
141
+ * @param hostname The hostname or IP to validate
142
+ * @param options Configuration options including policy and metadata settings
143
+ * @returns ValidationResult with safe status and optional reason
32
144
  */
33
- function isSafeHost(hostname, isValidDomainOptions) {
145
+ function validateHost(hostname, options) {
146
+ const blockCloudMetadata = options?.blockCloudMetadata !== false; // default true
34
147
  // Block cloud metadata IP/domains
35
- if (CLOUD_METADATA_HOSTS.indexOf(hostname) !== -1)
36
- return false;
148
+ if (blockCloudMetadata && isCloudMetadata(hostname, options?.metadataHosts)) {
149
+ return { safe: false, reason: 'cloud_metadata' };
150
+ }
151
+ // Check policy-based rules (only for non-IP hostnames)
152
+ if (!isIp(hostname)) {
153
+ const policyResult = validatePolicy(hostname, options?.policy);
154
+ if (!policyResult.safe) {
155
+ return policyResult;
156
+ }
157
+ }
37
158
  // Case 1: IP address
38
- if (isIp(hostname))
39
- return isPublicIp(hostname);
40
- // Case 2: Domain name
41
- return isValidDomain(hostname, {
42
- allowUnicode: false,
43
- subdomain: true,
44
- ...isValidDomainOptions,
45
- });
159
+ if (isIp(hostname)) {
160
+ if (!isPublicIp(hostname)) {
161
+ return { safe: false, reason: 'private_ip' };
162
+ }
163
+ return { safe: true };
164
+ }
165
+ // Case 2: Domain name validation
166
+ if (!isValidDomain(hostname, { allowUnicode: false, subdomain: true })) {
167
+ return { safe: false, reason: 'invalid_domain' };
168
+ }
169
+ return { safe: true };
46
170
  }
47
171
 
48
- // Instantiate the default agents
49
- const httpAgent = new http.Agent();
50
- const httpsAgent = new https.Agent();
172
+ // WeakMap to track patched agents without modifying the agent object
173
+ const patchedAgents = new WeakMap();
51
174
  /**
52
- * Determines the correct Agent instance based on the input.
53
- * @param url The URL or another input to determine the agent type.
54
- * @returns The appropriate HttpAgent or HttpsAgent instance.
175
+ * Determines the correct Agent instance based on the protocol.
176
+ * @param url The URL or protocol hint to determine the agent type.
177
+ * @param options Optional options that may contain protocol hint.
178
+ * @returns A new HttpAgent or HttpsAgent instance.
55
179
  */
56
- const getAgent = (url) => {
57
- // If it's a string, check if it implies HTTPS
58
- if (typeof url === 'string' && url.startsWith('https')) {
59
- return httpsAgent;
180
+ const createAgent = (url, options) => {
181
+ const protocol = options?.protocol || url;
182
+ if (typeof protocol === 'string' && protocol.startsWith('https')) {
183
+ return new https.Agent();
60
184
  }
61
- // Default to HTTP agent
62
- return httpAgent;
185
+ return new http.Agent();
63
186
  };
64
- // Define a Symbol for a unique property to prevent double-patching the agent.
65
- const CREATE_CONNECTION = Symbol('createConnection');
66
187
  /**
67
- * Patches an http.Agent or https.Agent to enforce an HOST/IP check
68
- * before and after a DNS lookup.
188
+ * Creates a BlockEvent for logging.
189
+ */
190
+ function createBlockEvent(url, reason, ip, hostname) {
191
+ return {
192
+ url,
193
+ reason,
194
+ ip,
195
+ hostname,
196
+ timestamp: Date.now(),
197
+ };
198
+ }
199
+ /**
200
+ * Handles a block action based on mode.
201
+ * @returns true if the request should be blocked, false if allowed
202
+ */
203
+ function handleBlock(options, url, reason, ip, hostname) {
204
+ const mode = options?.mode || 'block';
205
+ const logger = options?.logger;
206
+ // Create event for logging
207
+ const event = createBlockEvent(url, reason, ip, hostname);
208
+ // Log based on mode
209
+ if (logger) {
210
+ if (mode === 'block') {
211
+ logger('error', `SSRF blocked: ${reason}`, event);
212
+ }
213
+ else if (mode === 'report') {
214
+ logger('warn', `SSRF detected (report mode): ${reason}`, event);
215
+ }
216
+ }
217
+ // Return whether to actually block
218
+ return mode === 'block';
219
+ }
220
+ /**
221
+ * Gets a human-readable error message for a block reason.
222
+ */
223
+ function getErrorMessage(reason, target) {
224
+ switch (reason) {
225
+ case 'private_ip':
226
+ return `Private IP address ${target} is not allowed`;
227
+ case 'cloud_metadata':
228
+ return `Cloud metadata endpoint ${target} is not allowed`;
229
+ case 'invalid_domain':
230
+ return `Invalid domain ${target}`;
231
+ case 'dns_rebinding':
232
+ return `DNS rebinding attack detected for ${target}`;
233
+ case 'denied_domain':
234
+ return `Domain ${target} is denied by policy`;
235
+ case 'denied_tld':
236
+ return `TLD of ${target} is denied by policy`;
237
+ case 'not_allowed_domain':
238
+ return `Domain ${target} is not in the allowed list`;
239
+ default:
240
+ return `Request to ${target} is not allowed`;
241
+ }
242
+ }
243
+ /**
244
+ * Patches an http.Agent or https.Agent to enforce HOST/IP checks
245
+ * before and after DNS lookup, with full policy support.
69
246
  *
70
- * @param url The URL or another input to determine the agent type.
71
- * @param isValidDomainOptions Options for validating domain names.
247
+ * @param url The URL or protocol hint to determine the agent type.
248
+ * @param options Configuration options for SSRF protection.
72
249
  * @returns The patched CustomAgent instance.
73
250
  */
74
- const ssrfAgentGuard = function (url, isValidDomainOptions) {
75
- const finalAgent = getAgent(url);
76
- // Prevent patching the agent multiple times
77
- if (finalAgent[CREATE_CONNECTION]) {
251
+ function ssrfAgentGuard(url, options) {
252
+ // Create a new agent for each call to avoid shared state issues
253
+ const finalAgent = createAgent(url, options);
254
+ // If mode is 'allow', return unpatched agent
255
+ if (options?.mode === 'allow') {
78
256
  return finalAgent;
79
257
  }
80
- finalAgent[CREATE_CONNECTION] = true;
81
- // The original createConnection function from the Agent
82
- const createConnection = finalAgent.createConnection;
83
- // Patch the createConnection method on the agent
84
- finalAgent.createConnection = function (options, fn) {
85
- const { host: address } = options;
258
+ // Check if already patched (shouldn't happen with new agents, but safety check)
259
+ if (patchedAgents.get(finalAgent)) {
260
+ return finalAgent;
261
+ }
262
+ patchedAgents.set(finalAgent, true);
263
+ // Store original createConnection
264
+ const originalCreateConnection = finalAgent.createConnection;
265
+ // Whether to detect DNS rebinding (default: true)
266
+ const detectDnsRebinding = options?.detectDnsRebinding !== false;
267
+ // Patch createConnection
268
+ finalAgent.createConnection = function (connectionOptions, callback) {
269
+ const hostname = connectionOptions.host || '';
86
270
  // --- 1. Pre-DNS Check (Host/Address Check) ---
87
- // If the 'host' option is an IP address, check it immediately.
88
- // If it's a hostname, this check will usually pass (via defaultIpChecker).
89
- if (address && !isSafeHost(address, isValidDomainOptions)) {
90
- throw new Error(`DNS lookup ${address} is not allowed.`);
271
+ const preCheckResult = validateHost(hostname, options);
272
+ if (!preCheckResult.safe && preCheckResult.reason) {
273
+ const shouldBlock = handleBlock(options, hostname, preCheckResult.reason, undefined, hostname);
274
+ if (shouldBlock) {
275
+ throw new Error(getErrorMessage(preCheckResult.reason, hostname));
276
+ }
91
277
  }
92
278
  // Call the original createConnection
93
- const client = createConnection.call(this, options, fn);
279
+ const client = originalCreateConnection.call(this, connectionOptions, callback);
94
280
  // --- 2. Post-DNS Check (Lookup Event Check) ---
95
- // The 'lookup' event fires after the DNS lookup is complete
96
- // and provides the resolved IP address.
97
- client?.on('lookup', (err, resolvedAddress) => {
98
- // Ensure resolvedAddress is a string for the check (it's typically a string for simple lookups)
99
- const ipToCheck = Array.isArray(resolvedAddress) ? resolvedAddress[0] : resolvedAddress;
100
- // If there was an error in lookup, or if the resolved IP is allowed, do nothing.
101
- if (err || isSafeHost(ipToCheck, isValidDomainOptions)) {
102
- return false;
103
- }
104
- // If the resolved IP is NOT allowed (e.g., a private IP), destroy the connection.
105
- return client?.destroy(new Error(`DNS lookup ${ipToCheck} is not allowed.`));
106
- });
281
+ // Only add listener if DNS rebinding detection is enabled
282
+ if (detectDnsRebinding && client) {
283
+ client.on('lookup', (err, resolvedAddress) => {
284
+ if (err) {
285
+ return; // DNS lookup failed, let it propagate naturally
286
+ }
287
+ // Check all resolved IPs (handle both single IP and array)
288
+ const ipsToCheck = Array.isArray(resolvedAddress) ? resolvedAddress : [resolvedAddress];
289
+ for (const ip of ipsToCheck) {
290
+ if (!ip)
291
+ continue;
292
+ const postCheckResult = validateHost(ip, options);
293
+ if (!postCheckResult.safe && postCheckResult.reason) {
294
+ // For post-DNS check, the reason is DNS rebinding
295
+ const reason = 'dns_rebinding';
296
+ const shouldBlock = handleBlock(options, hostname, reason, ip, hostname);
297
+ if (shouldBlock) {
298
+ client.destroy(new Error(getErrorMessage(reason, `${hostname} -> ${ip}`)));
299
+ return;
300
+ }
301
+ }
302
+ }
303
+ });
304
+ }
107
305
  return client;
108
306
  };
109
307
  return finalAgent;
110
- };
308
+ }
111
309
  module.exports = ssrfAgentGuard;
112
310
 
113
311
  exports.default = ssrfAgentGuard;
312
+ exports.getTLD = getTLD;
313
+ exports.isCloudMetadata = isCloudMetadata;
314
+ exports.matchesDomain = matchesDomain;
315
+ exports.validateHost = validateHost;
316
+ exports.validatePolicy = validatePolicy;
package/dist/index.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { Agent as HttpAgent } from 'http';
2
2
  import { Agent as HttpsAgent } from 'https';
3
- import { IsValidDomainOptions } from './lib/types';
3
+ import { Options } from './lib/types';
4
+ export { Options, PolicyOptions, BlockEvent, BlockReason, ValidationResult } from './lib/types';
5
+ export { validateHost, isCloudMetadata, validatePolicy, matchesDomain, getTLD } from './lib/utils';
4
6
  type CustomAgent = HttpAgent | HttpsAgent;
5
7
  /**
6
- * Patches an http.Agent or https.Agent to enforce an HOST/IP check
7
- * before and after a DNS lookup.
8
+ * Patches an http.Agent or https.Agent to enforce HOST/IP checks
9
+ * before and after DNS lookup, with full policy support.
8
10
  *
9
- * @param url The URL or another input to determine the agent type.
10
- * @param isValidDomainOptions Options for validating domain names.
11
+ * @param url The URL or protocol hint to determine the agent type.
12
+ * @param options Configuration options for SSRF protection.
11
13
  * @returns The patched CustomAgent instance.
12
14
  */
13
- declare const ssrfAgentGuard: (url: string, isValidDomainOptions?: IsValidDomainOptions) => CustomAgent;
15
+ declare function ssrfAgentGuard(url: string, options?: Options): CustomAgent;
14
16
  export default ssrfAgentGuard;
package/dist/index.esm.js CHANGED
@@ -1,14 +1,38 @@
1
- import { Agent } from 'http';
2
- import { Agent as Agent$1 } from 'https';
1
+ import { Agent as Agent$1 } from 'http';
2
+ import { Agent } from 'https';
3
3
  import isValidDomain from 'is-valid-domain';
4
4
  import ipaddr from 'ipaddr.js';
5
5
 
6
- const CLOUD_METADATA_HOSTS = [
6
+ // lib/types.ts
7
+ /**
8
+ * Default cloud metadata hosts to block.
9
+ * Includes AWS, GCP, Azure, Oracle Cloud, DigitalOcean, and Kubernetes.
10
+ */
11
+ const CLOUD_METADATA_HOSTS = new Set([
12
+ // AWS EC2 metadata service
7
13
  '169.254.169.254',
8
14
  '169.254.169.253',
15
+ // GCP metadata service
9
16
  'metadata.google.internal',
17
+ 'metadata.goog',
18
+ // Azure IMDS
19
+ '169.254.169.254',
20
+ '168.63.129.16',
21
+ // ECS task metadata (AWS Fargate)
10
22
  '169.254.170.2',
11
- ];
23
+ // Kubernetes metadata
24
+ 'kubernetes.default',
25
+ 'kubernetes.default.svc',
26
+ 'kubernetes.default.svc.cluster.local',
27
+ // Oracle Cloud
28
+ '169.254.169.254',
29
+ // DigitalOcean
30
+ '169.254.169.254',
31
+ // Alibaba Cloud
32
+ '100.100.100.200',
33
+ // Link-local for metadata
34
+ '169.254.0.0',
35
+ ]);
12
36
 
13
37
  // lib/utils.ts
14
38
  /**
@@ -23,87 +47,261 @@ function isIp(input) {
23
47
  function isPublicIp(ip) {
24
48
  return ipaddr.parse(ip).range() === 'unicast';
25
49
  }
50
+ /**
51
+ * Extracts the TLD from a hostname.
52
+ * @param hostname The hostname to extract TLD from
53
+ * @returns The TLD or empty string if not found
54
+ */
55
+ function getTLD(hostname) {
56
+ const parts = hostname.toLowerCase().split('.');
57
+ return parts.length > 0 ? parts[parts.length - 1] : '';
58
+ }
59
+ /**
60
+ * Checks if a hostname matches a domain pattern.
61
+ * Supports exact match and wildcard subdomain matching.
62
+ * @param hostname The hostname to check
63
+ * @param pattern The domain pattern (e.g., 'example.com' or '*.example.com')
64
+ */
65
+ function matchesDomain(hostname, pattern) {
66
+ const normalizedHost = hostname.toLowerCase();
67
+ const normalizedPattern = pattern.toLowerCase();
68
+ // Exact match
69
+ if (normalizedHost === normalizedPattern) {
70
+ return true;
71
+ }
72
+ // Wildcard match (*.example.com matches sub.example.com)
73
+ if (normalizedPattern.startsWith('*.')) {
74
+ const baseDomain = normalizedPattern.slice(2);
75
+ return normalizedHost.endsWith('.' + baseDomain) || normalizedHost === baseDomain;
76
+ }
77
+ // Subdomain match (example.com matches sub.example.com)
78
+ return normalizedHost.endsWith('.' + normalizedPattern);
79
+ }
80
+ /**
81
+ * Checks if a hostname matches any domain in a list.
82
+ */
83
+ function matchesAnyDomain(hostname, domains) {
84
+ return domains.some(domain => matchesDomain(hostname, domain));
85
+ }
86
+ /**
87
+ * Validates a host against policy options.
88
+ * @param hostname The hostname to validate
89
+ * @param policy The policy options
90
+ * @returns ValidationResult with safe status and reason if blocked
91
+ */
92
+ function validatePolicy(hostname, policy) {
93
+ if (!policy) {
94
+ return { safe: true };
95
+ }
96
+ // Check allowDomains first (explicit allowlist takes precedence)
97
+ if (policy.allowDomains && policy.allowDomains.length > 0) {
98
+ if (matchesAnyDomain(hostname, policy.allowDomains)) {
99
+ return { safe: true };
100
+ }
101
+ // If allowDomains is specified but host doesn't match, it's not allowed
102
+ return { safe: false, reason: 'not_allowed_domain' };
103
+ }
104
+ // Check denyDomains
105
+ if (policy.denyDomains && policy.denyDomains.length > 0) {
106
+ if (matchesAnyDomain(hostname, policy.denyDomains)) {
107
+ return { safe: false, reason: 'denied_domain' };
108
+ }
109
+ }
110
+ // Check denyTLD
111
+ if (policy.denyTLD && policy.denyTLD.length > 0) {
112
+ const tld = getTLD(hostname);
113
+ if (policy.denyTLD.map(t => t.toLowerCase()).includes(tld)) {
114
+ return { safe: false, reason: 'denied_tld' };
115
+ }
116
+ }
117
+ return { safe: true };
118
+ }
119
+ /**
120
+ * Checks if a hostname is a cloud metadata endpoint.
121
+ * @param hostname The hostname to check
122
+ * @param customHosts Additional custom metadata hosts to check
123
+ */
124
+ function isCloudMetadata(hostname, customHosts) {
125
+ if (CLOUD_METADATA_HOSTS.has(hostname)) {
126
+ return true;
127
+ }
128
+ if (customHosts && customHosts.includes(hostname)) {
129
+ return true;
130
+ }
131
+ return false;
132
+ }
26
133
  /**
27
134
  * High-level validation for hostnames (domains + public IPs).
135
+ * Returns detailed validation result with reason for blocking.
136
+ *
137
+ * @param hostname The hostname or IP to validate
138
+ * @param options Configuration options including policy and metadata settings
139
+ * @returns ValidationResult with safe status and optional reason
28
140
  */
29
- function isSafeHost(hostname, isValidDomainOptions) {
141
+ function validateHost(hostname, options) {
142
+ const blockCloudMetadata = options?.blockCloudMetadata !== false; // default true
30
143
  // Block cloud metadata IP/domains
31
- if (CLOUD_METADATA_HOSTS.indexOf(hostname) !== -1)
32
- return false;
144
+ if (blockCloudMetadata && isCloudMetadata(hostname, options?.metadataHosts)) {
145
+ return { safe: false, reason: 'cloud_metadata' };
146
+ }
147
+ // Check policy-based rules (only for non-IP hostnames)
148
+ if (!isIp(hostname)) {
149
+ const policyResult = validatePolicy(hostname, options?.policy);
150
+ if (!policyResult.safe) {
151
+ return policyResult;
152
+ }
153
+ }
33
154
  // Case 1: IP address
34
- if (isIp(hostname))
35
- return isPublicIp(hostname);
36
- // Case 2: Domain name
37
- return isValidDomain(hostname, {
38
- allowUnicode: false,
39
- subdomain: true,
40
- ...isValidDomainOptions,
41
- });
155
+ if (isIp(hostname)) {
156
+ if (!isPublicIp(hostname)) {
157
+ return { safe: false, reason: 'private_ip' };
158
+ }
159
+ return { safe: true };
160
+ }
161
+ // Case 2: Domain name validation
162
+ if (!isValidDomain(hostname, { allowUnicode: false, subdomain: true })) {
163
+ return { safe: false, reason: 'invalid_domain' };
164
+ }
165
+ return { safe: true };
42
166
  }
43
167
 
44
- // Instantiate the default agents
45
- const httpAgent = new Agent();
46
- const httpsAgent = new Agent$1();
168
+ // WeakMap to track patched agents without modifying the agent object
169
+ const patchedAgents = new WeakMap();
47
170
  /**
48
- * Determines the correct Agent instance based on the input.
49
- * @param url The URL or another input to determine the agent type.
50
- * @returns The appropriate HttpAgent or HttpsAgent instance.
171
+ * Determines the correct Agent instance based on the protocol.
172
+ * @param url The URL or protocol hint to determine the agent type.
173
+ * @param options Optional options that may contain protocol hint.
174
+ * @returns A new HttpAgent or HttpsAgent instance.
51
175
  */
52
- const getAgent = (url) => {
53
- // If it's a string, check if it implies HTTPS
54
- if (typeof url === 'string' && url.startsWith('https')) {
55
- return httpsAgent;
176
+ const createAgent = (url, options) => {
177
+ const protocol = options?.protocol || url;
178
+ if (typeof protocol === 'string' && protocol.startsWith('https')) {
179
+ return new Agent();
56
180
  }
57
- // Default to HTTP agent
58
- return httpAgent;
181
+ return new Agent$1();
59
182
  };
60
- // Define a Symbol for a unique property to prevent double-patching the agent.
61
- const CREATE_CONNECTION = Symbol('createConnection');
62
183
  /**
63
- * Patches an http.Agent or https.Agent to enforce an HOST/IP check
64
- * before and after a DNS lookup.
184
+ * Creates a BlockEvent for logging.
185
+ */
186
+ function createBlockEvent(url, reason, ip, hostname) {
187
+ return {
188
+ url,
189
+ reason,
190
+ ip,
191
+ hostname,
192
+ timestamp: Date.now(),
193
+ };
194
+ }
195
+ /**
196
+ * Handles a block action based on mode.
197
+ * @returns true if the request should be blocked, false if allowed
198
+ */
199
+ function handleBlock(options, url, reason, ip, hostname) {
200
+ const mode = options?.mode || 'block';
201
+ const logger = options?.logger;
202
+ // Create event for logging
203
+ const event = createBlockEvent(url, reason, ip, hostname);
204
+ // Log based on mode
205
+ if (logger) {
206
+ if (mode === 'block') {
207
+ logger('error', `SSRF blocked: ${reason}`, event);
208
+ }
209
+ else if (mode === 'report') {
210
+ logger('warn', `SSRF detected (report mode): ${reason}`, event);
211
+ }
212
+ }
213
+ // Return whether to actually block
214
+ return mode === 'block';
215
+ }
216
+ /**
217
+ * Gets a human-readable error message for a block reason.
218
+ */
219
+ function getErrorMessage(reason, target) {
220
+ switch (reason) {
221
+ case 'private_ip':
222
+ return `Private IP address ${target} is not allowed`;
223
+ case 'cloud_metadata':
224
+ return `Cloud metadata endpoint ${target} is not allowed`;
225
+ case 'invalid_domain':
226
+ return `Invalid domain ${target}`;
227
+ case 'dns_rebinding':
228
+ return `DNS rebinding attack detected for ${target}`;
229
+ case 'denied_domain':
230
+ return `Domain ${target} is denied by policy`;
231
+ case 'denied_tld':
232
+ return `TLD of ${target} is denied by policy`;
233
+ case 'not_allowed_domain':
234
+ return `Domain ${target} is not in the allowed list`;
235
+ default:
236
+ return `Request to ${target} is not allowed`;
237
+ }
238
+ }
239
+ /**
240
+ * Patches an http.Agent or https.Agent to enforce HOST/IP checks
241
+ * before and after DNS lookup, with full policy support.
65
242
  *
66
- * @param url The URL or another input to determine the agent type.
67
- * @param isValidDomainOptions Options for validating domain names.
243
+ * @param url The URL or protocol hint to determine the agent type.
244
+ * @param options Configuration options for SSRF protection.
68
245
  * @returns The patched CustomAgent instance.
69
246
  */
70
- const ssrfAgentGuard = function (url, isValidDomainOptions) {
71
- const finalAgent = getAgent(url);
72
- // Prevent patching the agent multiple times
73
- if (finalAgent[CREATE_CONNECTION]) {
247
+ function ssrfAgentGuard(url, options) {
248
+ // Create a new agent for each call to avoid shared state issues
249
+ const finalAgent = createAgent(url, options);
250
+ // If mode is 'allow', return unpatched agent
251
+ if (options?.mode === 'allow') {
74
252
  return finalAgent;
75
253
  }
76
- finalAgent[CREATE_CONNECTION] = true;
77
- // The original createConnection function from the Agent
78
- const createConnection = finalAgent.createConnection;
79
- // Patch the createConnection method on the agent
80
- finalAgent.createConnection = function (options, fn) {
81
- const { host: address } = options;
254
+ // Check if already patched (shouldn't happen with new agents, but safety check)
255
+ if (patchedAgents.get(finalAgent)) {
256
+ return finalAgent;
257
+ }
258
+ patchedAgents.set(finalAgent, true);
259
+ // Store original createConnection
260
+ const originalCreateConnection = finalAgent.createConnection;
261
+ // Whether to detect DNS rebinding (default: true)
262
+ const detectDnsRebinding = options?.detectDnsRebinding !== false;
263
+ // Patch createConnection
264
+ finalAgent.createConnection = function (connectionOptions, callback) {
265
+ const hostname = connectionOptions.host || '';
82
266
  // --- 1. Pre-DNS Check (Host/Address Check) ---
83
- // If the 'host' option is an IP address, check it immediately.
84
- // If it's a hostname, this check will usually pass (via defaultIpChecker).
85
- if (address && !isSafeHost(address, isValidDomainOptions)) {
86
- throw new Error(`DNS lookup ${address} is not allowed.`);
267
+ const preCheckResult = validateHost(hostname, options);
268
+ if (!preCheckResult.safe && preCheckResult.reason) {
269
+ const shouldBlock = handleBlock(options, hostname, preCheckResult.reason, undefined, hostname);
270
+ if (shouldBlock) {
271
+ throw new Error(getErrorMessage(preCheckResult.reason, hostname));
272
+ }
87
273
  }
88
274
  // Call the original createConnection
89
- const client = createConnection.call(this, options, fn);
275
+ const client = originalCreateConnection.call(this, connectionOptions, callback);
90
276
  // --- 2. Post-DNS Check (Lookup Event Check) ---
91
- // The 'lookup' event fires after the DNS lookup is complete
92
- // and provides the resolved IP address.
93
- client?.on('lookup', (err, resolvedAddress) => {
94
- // Ensure resolvedAddress is a string for the check (it's typically a string for simple lookups)
95
- const ipToCheck = Array.isArray(resolvedAddress) ? resolvedAddress[0] : resolvedAddress;
96
- // If there was an error in lookup, or if the resolved IP is allowed, do nothing.
97
- if (err || isSafeHost(ipToCheck, isValidDomainOptions)) {
98
- return false;
99
- }
100
- // If the resolved IP is NOT allowed (e.g., a private IP), destroy the connection.
101
- return client?.destroy(new Error(`DNS lookup ${ipToCheck} is not allowed.`));
102
- });
277
+ // Only add listener if DNS rebinding detection is enabled
278
+ if (detectDnsRebinding && client) {
279
+ client.on('lookup', (err, resolvedAddress) => {
280
+ if (err) {
281
+ return; // DNS lookup failed, let it propagate naturally
282
+ }
283
+ // Check all resolved IPs (handle both single IP and array)
284
+ const ipsToCheck = Array.isArray(resolvedAddress) ? resolvedAddress : [resolvedAddress];
285
+ for (const ip of ipsToCheck) {
286
+ if (!ip)
287
+ continue;
288
+ const postCheckResult = validateHost(ip, options);
289
+ if (!postCheckResult.safe && postCheckResult.reason) {
290
+ // For post-DNS check, the reason is DNS rebinding
291
+ const reason = 'dns_rebinding';
292
+ const shouldBlock = handleBlock(options, hostname, reason, ip, hostname);
293
+ if (shouldBlock) {
294
+ client.destroy(new Error(getErrorMessage(reason, `${hostname} -> ${ip}`)));
295
+ return;
296
+ }
297
+ }
298
+ }
299
+ });
300
+ }
103
301
  return client;
104
302
  };
105
303
  return finalAgent;
106
- };
304
+ }
107
305
  module.exports = ssrfAgentGuard;
108
306
 
109
- export { ssrfAgentGuard as default };
307
+ export { ssrfAgentGuard as default, getTLD, isCloudMetadata, matchesDomain, validateHost, validatePolicy };
@@ -1,27 +1,66 @@
1
+ /**
2
+ * Block reasons for SSRF detection
3
+ */
4
+ export type BlockReason = 'private_ip' | 'cloud_metadata' | 'invalid_domain' | 'dns_rebinding' | 'denied_domain' | 'denied_tld' | 'not_allowed_domain';
5
+ /**
6
+ * Main configuration options for ssrf-agent-guard
7
+ */
1
8
  export interface Options {
2
- protocal?: string;
9
+ /** Protocol hint (http or https) - typically inferred from URL */
10
+ protocol?: string;
11
+ /** Custom cloud metadata hosts to block (merged with defaults) */
3
12
  metadataHosts?: string[];
13
+ /**
14
+ * Operation mode:
15
+ * - 'block': Block and throw error (default)
16
+ * - 'report': Log but allow the request
17
+ * - 'allow': Disable all checks (for debugging)
18
+ */
4
19
  mode?: 'block' | 'report' | 'allow';
20
+ /** Domain/TLD policy options */
5
21
  policy?: PolicyOptions;
22
+ /** Whether to block cloud metadata endpoints (default: true) */
6
23
  blockCloudMetadata?: boolean;
24
+ /** Whether to detect DNS rebinding attacks (default: true) */
7
25
  detectDnsRebinding?: boolean;
8
- logger?: (level: 'info' | 'warn' | 'error', msg: string, meta?: any) => void;
26
+ /** Logger callback for blocked requests and warnings */
27
+ logger?: (level: 'info' | 'warn' | 'error', msg: string, meta?: BlockEvent) => void;
9
28
  }
29
+ /**
30
+ * Policy options for domain-based filtering
31
+ */
10
32
  export interface PolicyOptions {
33
+ /** Domains explicitly allowed (bypasses other checks) */
11
34
  allowDomains?: string[];
35
+ /** Domains explicitly denied */
12
36
  denyDomains?: string[];
37
+ /** Top-level domains to deny (e.g., ['local', 'internal']) */
13
38
  denyTLD?: string[];
14
39
  }
40
+ /**
41
+ * Event data passed to logger when a request is blocked or flagged
42
+ */
15
43
  export interface BlockEvent {
44
+ /** The original URL or hostname */
16
45
  url: string;
17
- reason: string;
46
+ /** The reason for blocking */
47
+ reason: BlockReason;
48
+ /** The resolved IP address (if available) */
18
49
  ip?: string;
50
+ /** Timestamp of the event */
19
51
  timestamp: number;
52
+ /** The original hostname before DNS resolution */
53
+ hostname?: string;
20
54
  }
21
- export interface IsValidDomainOptions {
22
- subdomain?: boolean;
23
- wildcard?: boolean;
24
- allowUnicode?: boolean;
25
- topLevel?: boolean;
55
+ /**
56
+ * Default cloud metadata hosts to block.
57
+ * Includes AWS, GCP, Azure, Oracle Cloud, DigitalOcean, and Kubernetes.
58
+ */
59
+ export declare const CLOUD_METADATA_HOSTS: Set<string>;
60
+ /**
61
+ * Result of host validation check
62
+ */
63
+ export interface ValidationResult {
64
+ safe: boolean;
65
+ reason?: BlockReason;
26
66
  }
27
- export declare const CLOUD_METADATA_HOSTS: string[];
@@ -1,5 +1,44 @@
1
- import { IsValidDomainOptions } from './types';
1
+ import { Options, PolicyOptions, ValidationResult } from './types';
2
+ /**
3
+ * Checks if the input is an IP address (v4/v6).
4
+ */
5
+ export declare function isIp(input: string): boolean;
6
+ /**
7
+ * Returns true for valid public unicast IP addresses.
8
+ */
9
+ export declare function isPublicIp(ip: string): boolean;
10
+ /**
11
+ * Extracts the TLD from a hostname.
12
+ * @param hostname The hostname to extract TLD from
13
+ * @returns The TLD or empty string if not found
14
+ */
15
+ export declare function getTLD(hostname: string): string;
16
+ /**
17
+ * Checks if a hostname matches a domain pattern.
18
+ * Supports exact match and wildcard subdomain matching.
19
+ * @param hostname The hostname to check
20
+ * @param pattern The domain pattern (e.g., 'example.com' or '*.example.com')
21
+ */
22
+ export declare function matchesDomain(hostname: string, pattern: string): boolean;
23
+ /**
24
+ * Validates a host against policy options.
25
+ * @param hostname The hostname to validate
26
+ * @param policy The policy options
27
+ * @returns ValidationResult with safe status and reason if blocked
28
+ */
29
+ export declare function validatePolicy(hostname: string, policy?: PolicyOptions): ValidationResult;
30
+ /**
31
+ * Checks if a hostname is a cloud metadata endpoint.
32
+ * @param hostname The hostname to check
33
+ * @param customHosts Additional custom metadata hosts to check
34
+ */
35
+ export declare function isCloudMetadata(hostname: string, customHosts?: string[]): boolean;
2
36
  /**
3
37
  * High-level validation for hostnames (domains + public IPs).
38
+ * Returns detailed validation result with reason for blocking.
39
+ *
40
+ * @param hostname The hostname or IP to validate
41
+ * @param options Configuration options including policy and metadata settings
42
+ * @returns ValidationResult with safe status and optional reason
4
43
  */
5
- export declare function isSafeHost(hostname: string, isValidDomainOptions?: IsValidDomainOptions): boolean;
44
+ export declare function validateHost(hostname: string, options?: Options): ValidationResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ssrf-agent-guard",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "A TypeScript SSRF protection library for Node.js (express/axios) with advanced policies, DNS rebinding detection and cloud metadata protection.",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.esm.js",