ssrf-agent-guard 0.1.6 → 0.1.8

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 CHANGED
@@ -6,10 +6,17 @@
6
6
  ## Features
7
7
 
8
8
  * Block requests to internal/private IPs
9
- * Detect and block cloud provider metadata endpoints (AWS, GCP, Azure)
9
+ * Detect and block cloud provider metadata endpoints (AWS, GCP, Azure, Oracle, DigitalOcean, Kubernetes)
10
10
  * DNS rebinding detection
11
+ * Policy-based domain filtering (allowlists, denylists, TLD blocking)
12
+ * Multiple operation modes (block, report, allow)
13
+ * Custom logging support
11
14
  * Fully written in TypeScript with type definitions
12
15
 
16
+ ## Documentation
17
+
18
+ For complete API documentation, see [API.md](./API.md).
19
+
13
20
  ---
14
21
 
15
22
  ## Installation
@@ -24,20 +31,15 @@ yarn add ssrf-agent-guard
24
31
 
25
32
  ## Usage
26
33
 
27
- `isValidDomainOptions` reference [is-valid-domain](https://github.com/miguelmota/is-valid-domain)
28
-
29
34
  ### axios
30
35
 
31
36
  ```ts
32
37
  const ssrfAgentGuard = require('ssrf-agent-guard');
33
38
  const url = 'https://127.0.0.1'
34
- const isValidDomainOptions = {
35
- subdomain: true,
36
- wildcard: true
37
- };
38
39
  axios.get(
39
40
  url, {
40
- httpAgent: ssrfAgentGuard(url, isValidDomainOptions), httpsAgent: ssrfAgentGuard(url, isValidDomainOptions)
41
+ httpAgent: ssrfAgentGuard(url),
42
+ httpsAgent: ssrfAgentGuard(url)
41
43
  })
42
44
  .then((response) => {
43
45
  console.log(`Success`);
@@ -55,12 +57,8 @@ axios.get(
55
57
  ```ts
56
58
  const ssrfAgentGuard = require('ssrf-agent-guard');
57
59
  const url = 'https://127.0.0.1'
58
- const isValidDomainOptions = {
59
- subdomain: true,
60
- wildcard: true
61
- };
62
60
  fetch(url, {
63
- agent: ssrfAgentGuard(url, isValidDomainOptions)
61
+ agent: ssrfAgentGuard(url)
64
62
  })
65
63
  .then((response) => {
66
64
  console.log(`Success`);
@@ -70,6 +68,26 @@ fetch(url, {
70
68
  });
71
69
  ```
72
70
 
71
+ ### Advanced Configuration
72
+
73
+ ```ts
74
+ const ssrfAgentGuard = require('ssrf-agent-guard');
75
+
76
+ const agent = ssrfAgentGuard('https://api.example.com', {
77
+ mode: 'block', // 'block' | 'report' | 'allow'
78
+ blockCloudMetadata: true, // Block AWS/GCP/Azure metadata endpoints
79
+ detectDnsRebinding: true, // Detect DNS rebinding attacks
80
+ policy: {
81
+ allowDomains: ['*.trusted.com'], // Only allow these domains
82
+ denyDomains: ['evil.com'], // Block these domains
83
+ denyTLD: ['local', 'internal'] // Block these TLDs
84
+ },
85
+ logger: (level, msg, meta) => {
86
+ console.log(`[${level}] ${msg}`, meta);
87
+ }
88
+ });
89
+ ```
90
+
73
91
  ---
74
92
 
75
93
  ## Development
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;