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 +262 -59
- package/dist/index.d.ts +8 -6
- package/dist/index.esm.js +260 -62
- package/dist/lib/types.d.ts +48 -9
- package/dist/lib/utils.d.ts +41 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
|
145
|
+
function validateHost(hostname, options) {
|
|
146
|
+
const blockCloudMetadata = options?.blockCloudMetadata !== false; // default true
|
|
34
147
|
// Block cloud metadata IP/domains
|
|
35
|
-
if (
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
//
|
|
49
|
-
const
|
|
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
|
|
53
|
-
* @param url The URL or
|
|
54
|
-
* @
|
|
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
|
|
57
|
-
|
|
58
|
-
if (typeof
|
|
59
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
68
|
-
|
|
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
|
|
71
|
-
* @param
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
279
|
+
const client = originalCreateConnection.call(this, connectionOptions, callback);
|
|
94
280
|
// --- 2. Post-DNS Check (Lookup Event Check) ---
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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 {
|
|
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
|
|
7
|
-
* before and after
|
|
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
|
|
10
|
-
* @param
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
141
|
+
function validateHost(hostname, options) {
|
|
142
|
+
const blockCloudMetadata = options?.blockCloudMetadata !== false; // default true
|
|
30
143
|
// Block cloud metadata IP/domains
|
|
31
|
-
if (
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
//
|
|
45
|
-
const
|
|
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
|
|
49
|
-
* @param url The URL or
|
|
50
|
-
* @
|
|
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
|
|
53
|
-
|
|
54
|
-
if (typeof
|
|
55
|
-
return
|
|
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
|
-
|
|
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
|
-
*
|
|
64
|
-
|
|
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
|
|
67
|
-
* @param
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 =
|
|
275
|
+
const client = originalCreateConnection.call(this, connectionOptions, callback);
|
|
90
276
|
// --- 2. Post-DNS Check (Lookup Event Check) ---
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 };
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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[];
|
package/dist/lib/utils.d.ts
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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.
|
|
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",
|