@blamejs/core 0.4.2 → 0.4.3
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/CHANGELOG.md +6 -0
- package/index.js +2 -0
- package/lib/auth/oauth.js +5 -1
- package/lib/http-client.js +20 -11
- package/lib/log-stream-webhook.js +3 -2
- package/lib/mail.js +3 -0
- package/lib/middleware/api-encrypt.js +1 -1
- package/lib/notify.js +2 -0
- package/lib/object-store/azure-blob.js +3 -0
- package/lib/object-store/gcs.js +3 -0
- package/lib/object-store/http-put.js +3 -0
- package/lib/object-store/sigv4.js +3 -0
- package/lib/ssrf-guard.js +324 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ Pre-1.0 the surface is intentionally evolving — every release may
|
|
|
6
6
|
change something operators depend on. Read each entry before
|
|
7
7
|
upgrading across more than a few patches at a time.
|
|
8
8
|
|
|
9
|
+
## v0.4.x
|
|
10
|
+
|
|
11
|
+
- **0.4.2** (2026-04-30) — npm keywords
|
|
12
|
+
- **0.4.1** (2026-04-29) — wiki bot-guard skips /healthz so the post-publish smoke check passes
|
|
13
|
+
- **0.4.0** (2026-04-29) — bench suite + drops the deprecated b.logger.createLogger
|
|
14
|
+
|
|
9
15
|
## v0.3.x
|
|
10
16
|
|
|
11
17
|
- **0.3.39** (2026-04-29) — MIGRATING.md generator scans deprecate() calls in lib/
|
package/index.js
CHANGED
|
@@ -85,6 +85,7 @@ var httpClient = require("./lib/http-client");
|
|
|
85
85
|
httpClient.encrypted = require("./lib/middleware/api-encrypt").httpClient;
|
|
86
86
|
var websocket = require("./lib/websocket");
|
|
87
87
|
var safeUrl = require("./lib/safe-url");
|
|
88
|
+
var ssrfGuard = require("./lib/ssrf-guard");
|
|
88
89
|
var authHeader = require("./lib/auth-header");
|
|
89
90
|
var auth = {
|
|
90
91
|
password: require("./lib/auth/password"),
|
|
@@ -191,6 +192,7 @@ module.exports = {
|
|
|
191
192
|
httpClient: httpClient,
|
|
192
193
|
websocket: websocket,
|
|
193
194
|
safeUrl: safeUrl,
|
|
195
|
+
ssrfGuard: ssrfGuard,
|
|
194
196
|
authHeader: authHeader,
|
|
195
197
|
auth: auth,
|
|
196
198
|
template: template,
|
package/lib/auth/oauth.js
CHANGED
|
@@ -268,7 +268,8 @@ function create(opts) {
|
|
|
268
268
|
? opts.discoveryCacheMs : DEFAULT_DISCOVERY_CACHE_MS;
|
|
269
269
|
var acceptedAlgorithms = Array.isArray(opts.acceptedAlgorithms) && opts.acceptedAlgorithms.length > 0
|
|
270
270
|
? opts.acceptedAlgorithms.slice() : DEFAULT_ACCEPTED_ALGS.slice();
|
|
271
|
-
var allowHttp = !!opts.allowHttp; // localhost dev opt-in
|
|
271
|
+
var allowHttp = !!opts.allowHttp; // localhost dev opt-in (scheme)
|
|
272
|
+
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null; // localhost dev opt-in (SSRF gate)
|
|
272
273
|
var httpClientOpts = opts.httpClient || {};
|
|
273
274
|
var responseMode = opts.responseMode || null;
|
|
274
275
|
|
|
@@ -341,6 +342,7 @@ function create(opts) {
|
|
|
341
342
|
method: "GET",
|
|
342
343
|
}, fetchOpts);
|
|
343
344
|
if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
|
|
345
|
+
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
344
346
|
Object.assign(req, httpClientOpts);
|
|
345
347
|
var res = await hc.request(req);
|
|
346
348
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
@@ -505,6 +507,7 @@ function create(opts) {
|
|
|
505
507
|
body: Buffer.from(body.toString(), "utf8"),
|
|
506
508
|
};
|
|
507
509
|
if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
|
|
510
|
+
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
508
511
|
Object.assign(req, httpClientOpts);
|
|
509
512
|
var res = await hc.request(req);
|
|
510
513
|
// RFC 7009: 200 even if the token was already revoked / unknown.
|
|
@@ -526,6 +529,7 @@ function create(opts) {
|
|
|
526
529
|
body: Buffer.from(body.toString(), "utf8"),
|
|
527
530
|
};
|
|
528
531
|
if (allowHttp) req.allowedProtocols = safeUrl.ALLOW_HTTP_ALL;
|
|
532
|
+
if (allowInternal !== null) req.allowInternal = allowInternal;
|
|
529
533
|
Object.assign(req, httpClientOpts);
|
|
530
534
|
var res = await hc.request(req);
|
|
531
535
|
var text = res.body ? res.body.toString("utf8") : "";
|
package/lib/http-client.js
CHANGED
|
@@ -70,6 +70,7 @@ var pqcAgent = require("./pqc-agent");
|
|
|
70
70
|
var safeAsync = require("./safe-async");
|
|
71
71
|
var safeBuffer = require("./safe-buffer");
|
|
72
72
|
var safeUrl = require("./safe-url");
|
|
73
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
73
74
|
var { FrameworkError } = require("./framework-error");
|
|
74
75
|
|
|
75
76
|
// Per-origin transport cache. Entry is either the resolved transport
|
|
@@ -310,18 +311,26 @@ function request(opts) {
|
|
|
310
311
|
return Promise.reject(e);
|
|
311
312
|
}
|
|
312
313
|
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
314
|
+
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
315
|
+
// / reserved IP destinations by default. Operators on internal mesh
|
|
316
|
+
// pass `allowInternal: true` (or a CIDR list for narrower bypass).
|
|
317
|
+
return ssrfGuard.checkUrl(u, {
|
|
318
|
+
allowInternal: opts.allowInternal,
|
|
319
|
+
errorClass: opts.errorClass,
|
|
320
|
+
}).then(function () {
|
|
321
|
+
// Caller-supplied agent bypasses transport cache (h1 only).
|
|
322
|
+
if (opts.agent) {
|
|
323
|
+
return _requestH1({
|
|
324
|
+
kind: "h1",
|
|
325
|
+
lib: u.protocol === "https:" ? https : http,
|
|
326
|
+
agent: opts.agent,
|
|
327
|
+
}, u, opts);
|
|
328
|
+
}
|
|
321
329
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
330
|
+
return _getTransport(u, opts).then(function (transport) {
|
|
331
|
+
if (transport.kind === "h2") return _requestH2(transport, u, opts);
|
|
332
|
+
return _requestH1(transport, u, opts);
|
|
333
|
+
});
|
|
325
334
|
});
|
|
326
335
|
}
|
|
327
336
|
|
|
@@ -60,7 +60,7 @@ function _authHeaders(config) {
|
|
|
60
60
|
return authHeader.fromConfig(config);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
function _post(url, body, headers, timeoutMs, allowedProtocols) {
|
|
63
|
+
function _post(url, body, headers, timeoutMs, allowedProtocols, allowInternal) {
|
|
64
64
|
return httpClient.request({
|
|
65
65
|
method: "POST",
|
|
66
66
|
url: url,
|
|
@@ -70,6 +70,7 @@ function _post(url, body, headers, timeoutMs, allowedProtocols) {
|
|
|
70
70
|
maxResponseBytes: MAX_RESPONSE_BYTES,
|
|
71
71
|
errorClass: LogStreamError,
|
|
72
72
|
allowedProtocols: allowedProtocols,
|
|
73
|
+
allowInternal: allowInternal,
|
|
73
74
|
});
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -118,7 +119,7 @@ function create(config) {
|
|
|
118
119
|
var body = _serializeBatch(batch, cfg.bodyShape);
|
|
119
120
|
try {
|
|
120
121
|
await retryHelper.withRetry(function () {
|
|
121
|
-
return _post(cfg.url, body, headers, cfg.timeoutMs, cfg.allowedProtocols);
|
|
122
|
+
return _post(cfg.url, body, headers, cfg.timeoutMs, cfg.allowedProtocols, cfg.allowInternal);
|
|
122
123
|
}, cfg.retry);
|
|
123
124
|
} catch {
|
|
124
125
|
// Batch permanently rejected — surface via the dropped counter.
|
package/lib/mail.js
CHANGED
|
@@ -468,6 +468,7 @@ function httpTransport(opts) {
|
|
|
468
468
|
var baseHeaders = opts.headers || {};
|
|
469
469
|
var timeoutMs = opts.timeoutMs || 15000;
|
|
470
470
|
var allowedProtocols = opts.allowedProtocols || null;
|
|
471
|
+
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null;
|
|
471
472
|
var interpret = typeof opts.interpret === "function" ? opts.interpret : null;
|
|
472
473
|
var serialize = opts.serialize;
|
|
473
474
|
var codePrefix = "mail/" + name;
|
|
@@ -505,6 +506,7 @@ function httpTransport(opts) {
|
|
|
505
506
|
errorClass: MailError, // http-client constructs (code, message, permanent, statusCode)
|
|
506
507
|
};
|
|
507
508
|
if (allowedProtocols) reqOpts.allowedProtocols = allowedProtocols;
|
|
509
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
508
510
|
|
|
509
511
|
var res;
|
|
510
512
|
try {
|
|
@@ -568,6 +570,7 @@ function resendTransport(opts) {
|
|
|
568
570
|
method: "POST",
|
|
569
571
|
timeoutMs: opts.timeoutMs || 15000,
|
|
570
572
|
allowedProtocols: opts.allowedProtocols || null,
|
|
573
|
+
allowInternal: opts.allowInternal != null ? opts.allowInternal : null,
|
|
571
574
|
headers: {
|
|
572
575
|
"Authorization": "Bearer " + opts.apiKey,
|
|
573
576
|
"Content-Type": "application/json",
|
|
@@ -512,7 +512,7 @@ function httpClientEncrypted(opts) {
|
|
|
512
512
|
headers["Content-Type"] = "application/json";
|
|
513
513
|
|
|
514
514
|
var passThrough = {};
|
|
515
|
-
var passable = ["allowedProtocols", "idleTimeoutMs", "maxResponseBytes",
|
|
515
|
+
var passable = ["allowedProtocols", "allowInternal", "idleTimeoutMs", "maxResponseBytes",
|
|
516
516
|
"agent", "errorClass"];
|
|
517
517
|
for (var i = 0; i < passable.length; i++) {
|
|
518
518
|
if (reqOpts[passable[i]] !== undefined) passThrough[passable[i]] = reqOpts[passable[i]];
|
package/lib/notify.js
CHANGED
|
@@ -187,6 +187,7 @@ function httpJson(opts) {
|
|
|
187
187
|
throw _err("BAD_OPT", "notify.transports.httpJson: url is required");
|
|
188
188
|
}
|
|
189
189
|
var allowedProtocols = opts.allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS;
|
|
190
|
+
var allowInternal = opts.allowInternal != null ? opts.allowInternal : null;
|
|
190
191
|
// Validate URL at create time so bad URLs surface at boot, not at first send.
|
|
191
192
|
safeUrl.parse(opts.url, { allowedProtocols: allowedProtocols, errorClass: NotifyError });
|
|
192
193
|
|
|
@@ -238,6 +239,7 @@ function httpJson(opts) {
|
|
|
238
239
|
headers: headers,
|
|
239
240
|
body: body,
|
|
240
241
|
allowedProtocols: allowedProtocols,
|
|
242
|
+
allowInternal: allowInternal,
|
|
241
243
|
errorClass: NotifyError,
|
|
242
244
|
});
|
|
243
245
|
var status = (res && (res.statusCode || res.status)) || 0;
|
|
@@ -73,6 +73,7 @@ function _httpRequest(method, urlObj, headers, body, opts) {
|
|
|
73
73
|
maxResponseBytes: opts && opts.maxResponseBytes,
|
|
74
74
|
errorClass: ObjectStoreError,
|
|
75
75
|
allowedProtocols: opts && opts.allowedProtocols,
|
|
76
|
+
...((opts && opts.allowInternal !== undefined) ? { allowInternal: opts.allowInternal } : {}),
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -193,8 +194,10 @@ function create(config) {
|
|
|
193
194
|
// HTTPS-only by default — real Azure is always HTTPS. Operators with
|
|
194
195
|
// an Azurite emulator endpoint opt in via config.allowedProtocols.
|
|
195
196
|
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
197
|
+
var allowInternal = config.allowInternal != null ? config.allowInternal : null;
|
|
196
198
|
safeUrl.parse(endpoint, { allowedProtocols: allowedProtocols, errorClass: ObjectStoreError });
|
|
197
199
|
var reqOpts = { timeoutMs: timeoutMs, allowedProtocols: allowedProtocols };
|
|
200
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
198
201
|
|
|
199
202
|
function _blobUrl(key, params) {
|
|
200
203
|
var u = new URL(endpoint + "/" + config.container + "/" + key);
|
package/lib/object-store/gcs.js
CHANGED
|
@@ -81,6 +81,7 @@ function _httpRequest(method, urlObj, headers, body, opts) {
|
|
|
81
81
|
maxResponseBytes: opts && opts.maxResponseBytes,
|
|
82
82
|
errorClass: ObjectStoreError,
|
|
83
83
|
allowedProtocols: opts && opts.allowedProtocols,
|
|
84
|
+
...((opts && opts.allowInternal !== undefined) ? { allowInternal: opts.allowInternal } : {}),
|
|
84
85
|
});
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -143,9 +144,11 @@ function create(config) {
|
|
|
143
144
|
// HTTPS-only by default — google APIs are always HTTPS. Operators with
|
|
144
145
|
// an emulator / private fake-GCS endpoint opt in via config.allowedProtocols.
|
|
145
146
|
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
147
|
+
var allowInternal = config.allowInternal != null ? config.allowInternal : null;
|
|
146
148
|
safeUrl.parse(endpoint, { allowedProtocols: allowedProtocols, errorClass: ObjectStoreError });
|
|
147
149
|
safeUrl.parse(tokenEndpoint, { allowedProtocols: allowedProtocols, errorClass: ObjectStoreError });
|
|
148
150
|
var reqOpts = { timeoutMs: timeoutMs, allowedProtocols: allowedProtocols };
|
|
151
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
149
152
|
|
|
150
153
|
// ---- Token cache ----
|
|
151
154
|
var cachedToken = null; // { accessToken, expiresAt }
|
|
@@ -44,6 +44,7 @@ function _request(method, url, body, headers, opts) {
|
|
|
44
44
|
idleTimeoutMs: opts && opts.timeoutMs,
|
|
45
45
|
errorClass: ObjectStoreError,
|
|
46
46
|
allowedProtocols: opts && opts.allowedProtocols,
|
|
47
|
+
...((opts && opts.allowInternal !== undefined) ? { allowInternal: opts.allowInternal } : {}),
|
|
47
48
|
});
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -67,6 +68,7 @@ function create(config) {
|
|
|
67
68
|
// at first put(). HTTPS-only by default; cleartext appliances opt in
|
|
68
69
|
// via config.allowedProtocols (safeUrl.ALLOW_HTTP_ALL).
|
|
69
70
|
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
71
|
+
var allowInternal = config.allowInternal != null ? config.allowInternal : null;
|
|
70
72
|
safeUrl.parse(baseUrl, {
|
|
71
73
|
allowedProtocols: allowedProtocols,
|
|
72
74
|
errorClass: ObjectStoreError,
|
|
@@ -74,6 +76,7 @@ function create(config) {
|
|
|
74
76
|
var headers = _authHeaders(config);
|
|
75
77
|
var timeoutMs = config.timeoutMs;
|
|
76
78
|
var reqOpts = { timeoutMs: timeoutMs, allowedProtocols: allowedProtocols };
|
|
79
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
77
80
|
|
|
78
81
|
function put(key, body, _opts) {
|
|
79
82
|
var url = _keyToUrl(baseUrl, key);
|
|
@@ -204,6 +204,7 @@ function _request(method, url, headers, body, opts) {
|
|
|
204
204
|
maxResponseBytes: opts && opts.maxResponseBytes,
|
|
205
205
|
errorClass: ObjectStoreError,
|
|
206
206
|
allowedProtocols: opts && opts.allowedProtocols,
|
|
207
|
+
...((opts && opts.allowInternal !== undefined) ? { allowInternal: opts.allowInternal } : {}),
|
|
207
208
|
});
|
|
208
209
|
}
|
|
209
210
|
|
|
@@ -223,11 +224,13 @@ function create(config) {
|
|
|
223
224
|
// an internal cleartext S3-compatible endpoint (test fixtures, local
|
|
224
225
|
// dev MinIO) opt in via config.allowedProtocols.
|
|
225
226
|
var allowedProtocols = config.allowedProtocols || safeUrl.ALLOW_HTTP_TLS;
|
|
227
|
+
var allowInternal = config.allowInternal != null ? config.allowInternal : null;
|
|
226
228
|
safeUrl.parse(endpoint, {
|
|
227
229
|
allowedProtocols: allowedProtocols,
|
|
228
230
|
errorClass: ObjectStoreError,
|
|
229
231
|
});
|
|
230
232
|
var reqOpts = { timeoutMs: config.timeoutMs, allowedProtocols: allowedProtocols };
|
|
233
|
+
if (allowInternal !== null) reqOpts.allowInternal = allowInternal;
|
|
231
234
|
|
|
232
235
|
function _keyToUrl(key) {
|
|
233
236
|
if (key.indexOf("\0") !== -1) throw _err("INVALID_KEY", "null byte in key", true);
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* ssrf-guard — outbound URL gate against private / loopback / link-local /
|
|
4
|
+
* cloud-metadata / reserved IP ranges.
|
|
5
|
+
*
|
|
6
|
+
* Wired as default-on in b.httpClient.request. Operators with internal
|
|
7
|
+
* mesh calls opt out per call:
|
|
8
|
+
*
|
|
9
|
+
* b.httpClient.request({ url: "http://internal.svc", allowInternal: true });
|
|
10
|
+
* b.httpClient.request({ url: "http://10.0.5.1", allowInternal: ["10.0.0.0/8"] });
|
|
11
|
+
*
|
|
12
|
+
* Standalone use:
|
|
13
|
+
*
|
|
14
|
+
* var ssrf = b.ssrfGuard;
|
|
15
|
+
* await ssrf.checkUrl("https://example.com"); // throws SsrfError on hit
|
|
16
|
+
* ssrf.classify("169.254.169.254"); // → "cloud-metadata"
|
|
17
|
+
* ssrf.cidrContains("10.0.0.0/8", "10.1.2.3"); // → true
|
|
18
|
+
*
|
|
19
|
+
* What's blocked by default:
|
|
20
|
+
* - IPv4 private (RFC 1918): 10/8, 172.16/12, 192.168/16
|
|
21
|
+
* - IPv4 loopback: 127/8
|
|
22
|
+
* - IPv4 link-local: 169.254/16
|
|
23
|
+
* - IPv4 reserved/broadcast: 0/8, 100.64/10 (CGNAT), 224/4 (multicast),
|
|
24
|
+
* 240/4, 255.255.255.255
|
|
25
|
+
* - IPv4 documentation/test: 192.0.2/24, 198.51.100/24, 203.0.113/24, 198.18/15
|
|
26
|
+
* - IPv6 loopback: ::1
|
|
27
|
+
* - IPv6 ULA (private): fc00::/7
|
|
28
|
+
* - IPv6 link-local: fe80::/10
|
|
29
|
+
* - Cloud metadata IPs: 169.254.169.254 (AWS/GCP/Azure),
|
|
30
|
+
* 169.254.170.2 (AWS ECS task role),
|
|
31
|
+
* fd00:ec2::254
|
|
32
|
+
*
|
|
33
|
+
* Hostnames are resolved via dns.lookup before classification, so a
|
|
34
|
+
* malicious hostname pointing at a private IP fails the guard. The
|
|
35
|
+
* resolved IP is the one used for the actual connection — DNS rebinding
|
|
36
|
+
* between this check and the connect is out of scope (operators with
|
|
37
|
+
* that threat model pin to IP literals or use a sealed internal DNS).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
var dns = require("node:dns").promises;
|
|
41
|
+
var net = require("node:net");
|
|
42
|
+
|
|
43
|
+
var safeUrl = require("./safe-url");
|
|
44
|
+
var validateOpts = require("./validate-opts");
|
|
45
|
+
|
|
46
|
+
var { FrameworkError } = require("./framework-error");
|
|
47
|
+
|
|
48
|
+
class SsrfError extends FrameworkError {
|
|
49
|
+
constructor(message, code, ctx) {
|
|
50
|
+
super(message, code);
|
|
51
|
+
this.name = "SsrfError";
|
|
52
|
+
this.permanent = true;
|
|
53
|
+
this.isSsrfError = true;
|
|
54
|
+
if (ctx) {
|
|
55
|
+
this.url = ctx.url || null;
|
|
56
|
+
this.ip = ctx.ip || null;
|
|
57
|
+
this.category = ctx.category || null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- IPv4 ranges (as numeric prefix tables for fast match) ----
|
|
63
|
+
// Each entry: [networkInt, prefixLen]
|
|
64
|
+
var IPV4_PRIVATE = [
|
|
65
|
+
[_ipv4ToInt("10.0.0.0"), 8],
|
|
66
|
+
[_ipv4ToInt("172.16.0.0"), 12],
|
|
67
|
+
[_ipv4ToInt("192.168.0.0"), 16],
|
|
68
|
+
];
|
|
69
|
+
var IPV4_LOOPBACK = [
|
|
70
|
+
[_ipv4ToInt("127.0.0.0"), 8],
|
|
71
|
+
];
|
|
72
|
+
var IPV4_LINK_LOCAL = [
|
|
73
|
+
[_ipv4ToInt("169.254.0.0"), 16],
|
|
74
|
+
];
|
|
75
|
+
var IPV4_RESERVED = [
|
|
76
|
+
[_ipv4ToInt("0.0.0.0"), 8], // "this network"
|
|
77
|
+
[_ipv4ToInt("100.64.0.0"), 10], // CGNAT (RFC 6598)
|
|
78
|
+
[_ipv4ToInt("192.0.0.0"), 24], // IETF protocol assignments
|
|
79
|
+
[_ipv4ToInt("192.0.2.0"), 24], // TEST-NET-1
|
|
80
|
+
[_ipv4ToInt("198.18.0.0"), 15], // network benchmark
|
|
81
|
+
[_ipv4ToInt("198.51.100.0"), 24], // TEST-NET-2
|
|
82
|
+
[_ipv4ToInt("203.0.113.0"), 24], // TEST-NET-3
|
|
83
|
+
[_ipv4ToInt("224.0.0.0"), 4], // multicast
|
|
84
|
+
[_ipv4ToInt("240.0.0.0"), 4], // reserved + 255.255.255.255
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
// ---- IPv6 ranges (as 16-byte prefix tables) ----
|
|
88
|
+
var IPV6_LOOPBACK_BYTES = _ipv6ToBytes("::1");
|
|
89
|
+
var IPV6_UNSPECIFIED_BYTES = _ipv6ToBytes("::");
|
|
90
|
+
var IPV6_PRIVATE_PREFIX = _ipv6ToBytes("fc00::");
|
|
91
|
+
var IPV6_LINK_LOCAL_PREFIX = _ipv6ToBytes("fe80::");
|
|
92
|
+
var IPV6_DOC_PREFIX = _ipv6ToBytes("2001:db8::"); // documentation
|
|
93
|
+
var IPV6_V4_MAPPED_PREFIX = _ipv6ToBytes("::ffff:0:0"); // IPv4-mapped (::ffff:0:0/96)
|
|
94
|
+
|
|
95
|
+
// ---- Cloud metadata addresses (string-equality, exact match) ----
|
|
96
|
+
var CLOUD_METADATA_IPS = [
|
|
97
|
+
"169.254.169.254", // AWS, GCP, Azure, OpenStack, DO
|
|
98
|
+
"169.254.170.2", // AWS ECS task role
|
|
99
|
+
"fd00:ec2::254", // AWS IMDS over IPv6
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// ---- Helpers ----
|
|
103
|
+
|
|
104
|
+
function _ipv4ToInt(ip) {
|
|
105
|
+
var parts = ip.split(".");
|
|
106
|
+
return ((parts[0] | 0) << 24 >>> 0) +
|
|
107
|
+
((parts[1] | 0) << 16) +
|
|
108
|
+
((parts[2] | 0) << 8) +
|
|
109
|
+
(parts[3] | 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function _ipv6ToBytes(ip) {
|
|
113
|
+
// Node's net.isIPv6 returns 6 for valid IPv6; we then expand
|
|
114
|
+
// shorthand via manual parsing. node:net doesn't export an
|
|
115
|
+
// ipv6-to-bytes helper, but the URL constructor + Buffer dance
|
|
116
|
+
// is reliable for canonicalizing.
|
|
117
|
+
var groups = _expandIpv6(ip);
|
|
118
|
+
var out = Buffer.alloc(16);
|
|
119
|
+
for (var i = 0; i < 8; i++) {
|
|
120
|
+
var v = parseInt(groups[i], 16) || 0;
|
|
121
|
+
out[i * 2] = (v >> 8) & 0xff;
|
|
122
|
+
out[i * 2 + 1] = v & 0xff;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _expandIpv6(ip) {
|
|
128
|
+
// Handle "::" zero-elision + IPv4-mapped suffix ("::ffff:1.2.3.4").
|
|
129
|
+
var lower = ip.toLowerCase();
|
|
130
|
+
// IPv4-mapped: convert trailing dotted-quad to two hex groups
|
|
131
|
+
var dot = lower.lastIndexOf(":");
|
|
132
|
+
if (dot !== -1 && lower.indexOf(".", dot) !== -1) {
|
|
133
|
+
var v4 = lower.slice(dot + 1);
|
|
134
|
+
var ipv4Int = _ipv4ToInt(v4);
|
|
135
|
+
var hi = (ipv4Int >>> 16) & 0xffff;
|
|
136
|
+
var lo = ipv4Int & 0xffff;
|
|
137
|
+
lower = lower.slice(0, dot + 1) + hi.toString(16) + ":" + lo.toString(16);
|
|
138
|
+
}
|
|
139
|
+
var doubleColon = lower.indexOf("::");
|
|
140
|
+
var leftStr, rightStr;
|
|
141
|
+
if (doubleColon === -1) {
|
|
142
|
+
leftStr = lower;
|
|
143
|
+
rightStr = "";
|
|
144
|
+
} else {
|
|
145
|
+
leftStr = lower.slice(0, doubleColon);
|
|
146
|
+
rightStr = lower.slice(doubleColon + 2);
|
|
147
|
+
}
|
|
148
|
+
var left = leftStr.length ? leftStr.split(":") : [];
|
|
149
|
+
var right = rightStr.length ? rightStr.split(":") : [];
|
|
150
|
+
var missing = 8 - left.length - right.length;
|
|
151
|
+
var fill = [];
|
|
152
|
+
for (var i = 0; i < missing; i++) fill.push("0");
|
|
153
|
+
return left.concat(fill).concat(right);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function _cidrIpv4Match(cidr, ip) {
|
|
157
|
+
var slash = cidr.indexOf("/");
|
|
158
|
+
if (slash === -1) return false;
|
|
159
|
+
var network = _ipv4ToInt(cidr.slice(0, slash));
|
|
160
|
+
var prefix = parseInt(cidr.slice(slash + 1), 10);
|
|
161
|
+
if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) return false;
|
|
162
|
+
var ipInt = _ipv4ToInt(ip);
|
|
163
|
+
if (prefix === 0) return true;
|
|
164
|
+
var mask = (0xffffffff << (32 - prefix)) >>> 0;
|
|
165
|
+
return (ipInt & mask) === (network & mask);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _cidrIpv6Match(cidr, ip) {
|
|
169
|
+
var slash = cidr.indexOf("/");
|
|
170
|
+
if (slash === -1) return false;
|
|
171
|
+
var network = _ipv6ToBytes(cidr.slice(0, slash));
|
|
172
|
+
var prefix = parseInt(cidr.slice(slash + 1), 10);
|
|
173
|
+
if (!Number.isFinite(prefix) || prefix < 0 || prefix > 128) return false;
|
|
174
|
+
var bytes = _ipv6ToBytes(ip);
|
|
175
|
+
var fullBytes = Math.floor(prefix / 8);
|
|
176
|
+
var remainingBits = prefix % 8;
|
|
177
|
+
for (var i = 0; i < fullBytes; i++) {
|
|
178
|
+
if (bytes[i] !== network[i]) return false;
|
|
179
|
+
}
|
|
180
|
+
if (remainingBits > 0) {
|
|
181
|
+
var mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
182
|
+
if ((bytes[fullBytes] & mask) !== (network[fullBytes] & mask)) return false;
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _ipv4PrefixMatch(prefixTable, ipInt) {
|
|
188
|
+
for (var i = 0; i < prefixTable.length; i++) {
|
|
189
|
+
var net4 = prefixTable[i][0];
|
|
190
|
+
var prefix = prefixTable[i][1];
|
|
191
|
+
var mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
192
|
+
if ((ipInt & mask) === (net4 & mask)) return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _ipv6PrefixMatch(prefixBytes, prefixLen, ipBytes) {
|
|
198
|
+
var fullBytes = Math.floor(prefixLen / 8);
|
|
199
|
+
var remainingBits = prefixLen % 8;
|
|
200
|
+
for (var i = 0; i < fullBytes; i++) {
|
|
201
|
+
if (ipBytes[i] !== prefixBytes[i]) return false;
|
|
202
|
+
}
|
|
203
|
+
if (remainingBits > 0) {
|
|
204
|
+
var mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
205
|
+
if ((ipBytes[fullBytes] & mask) !== (prefixBytes[fullBytes] & mask)) return false;
|
|
206
|
+
}
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- Public classification API ----
|
|
211
|
+
|
|
212
|
+
function classify(ip) {
|
|
213
|
+
if (typeof ip !== "string") return null;
|
|
214
|
+
var family = net.isIP(ip);
|
|
215
|
+
if (family === 0) return null;
|
|
216
|
+
|
|
217
|
+
if (CLOUD_METADATA_IPS.indexOf(ip) !== -1) return "cloud-metadata";
|
|
218
|
+
|
|
219
|
+
if (family === 4) {
|
|
220
|
+
var ipInt = _ipv4ToInt(ip);
|
|
221
|
+
if (_ipv4PrefixMatch(IPV4_LOOPBACK, ipInt)) return "loopback";
|
|
222
|
+
if (_ipv4PrefixMatch(IPV4_LINK_LOCAL, ipInt)) return "link-local";
|
|
223
|
+
if (_ipv4PrefixMatch(IPV4_PRIVATE, ipInt)) return "private";
|
|
224
|
+
if (_ipv4PrefixMatch(IPV4_RESERVED, ipInt)) return "reserved";
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// IPv6
|
|
229
|
+
var bytes = _ipv6ToBytes(ip);
|
|
230
|
+
if (_bufEqual(bytes, IPV6_LOOPBACK_BYTES)) return "loopback";
|
|
231
|
+
if (_bufEqual(bytes, IPV6_UNSPECIFIED_BYTES)) return "reserved";
|
|
232
|
+
if (_ipv6PrefixMatch(IPV6_LINK_LOCAL_PREFIX, 10, bytes)) return "link-local";
|
|
233
|
+
if (_ipv6PrefixMatch(IPV6_PRIVATE_PREFIX, 7, bytes)) return "private";
|
|
234
|
+
if (_ipv6PrefixMatch(IPV6_DOC_PREFIX, 32, bytes)) return "reserved";
|
|
235
|
+
// IPv4-mapped addresses (::ffff:a.b.c.d/96): re-classify the v4 portion.
|
|
236
|
+
if (_ipv6PrefixMatch(IPV6_V4_MAPPED_PREFIX, 96, bytes)) {
|
|
237
|
+
var mappedV4 = bytes[12] + "." + bytes[13] + "." + bytes[14] + "." + bytes[15];
|
|
238
|
+
return classify(mappedV4);
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function _bufEqual(a, b) {
|
|
244
|
+
if (a.length !== b.length) return false;
|
|
245
|
+
for (var i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cidrContains(cidr, ip) {
|
|
250
|
+
if (typeof cidr !== "string" || typeof ip !== "string") return false;
|
|
251
|
+
var slash = cidr.indexOf("/");
|
|
252
|
+
if (slash === -1) return false;
|
|
253
|
+
var network = cidr.slice(0, slash);
|
|
254
|
+
var nFamily = net.isIP(network);
|
|
255
|
+
var iFamily = net.isIP(ip);
|
|
256
|
+
if (nFamily === 0 || iFamily === 0 || nFamily !== iFamily) return false;
|
|
257
|
+
return nFamily === 4 ? _cidrIpv4Match(cidr, ip) : _cidrIpv6Match(cidr, ip);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- URL check (DNS-resolving) ----
|
|
261
|
+
|
|
262
|
+
async function checkUrl(url, opts) {
|
|
263
|
+
opts = opts || {};
|
|
264
|
+
validateOpts(opts, ["allowInternal", "errorClass", "dnsLookup"], "ssrfGuard.checkUrl");
|
|
265
|
+
|
|
266
|
+
var ErrorClass = opts.errorClass || SsrfError;
|
|
267
|
+
var allowInternal = opts.allowInternal === true ? true :
|
|
268
|
+
Array.isArray(opts.allowInternal) ? opts.allowInternal :
|
|
269
|
+
false;
|
|
270
|
+
|
|
271
|
+
var parsed = url instanceof URL ? url : safeUrl.parse(String(url), {
|
|
272
|
+
allowedProtocols: safeUrl.ALLOW_HTTP_ALL,
|
|
273
|
+
errorClass: ErrorClass,
|
|
274
|
+
});
|
|
275
|
+
if (!parsed.hostname) {
|
|
276
|
+
throw new ErrorClass("URL '" + parsed.toString() + "' has no hostname",
|
|
277
|
+
"ssrf-guard/no-hostname", { url: parsed.toString() });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Strip IPv6 brackets — net.isIP doesn't accept "[::1]" form, only "::1"
|
|
281
|
+
var hostForCheck = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
282
|
+
|
|
283
|
+
var ips;
|
|
284
|
+
if (net.isIP(hostForCheck)) {
|
|
285
|
+
ips = [{ address: hostForCheck, family: net.isIP(hostForCheck) }];
|
|
286
|
+
} else {
|
|
287
|
+
var lookup = opts.dnsLookup || function (host) {
|
|
288
|
+
return dns.lookup(host, { all: true });
|
|
289
|
+
};
|
|
290
|
+
ips = await lookup(hostForCheck);
|
|
291
|
+
if (!Array.isArray(ips)) ips = [ips];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
for (var i = 0; i < ips.length; i++) {
|
|
295
|
+
var addr = ips[i].address;
|
|
296
|
+
var category = classify(addr);
|
|
297
|
+
if (!category) continue;
|
|
298
|
+
|
|
299
|
+
if (allowInternal === true) continue;
|
|
300
|
+
if (Array.isArray(allowInternal) && allowInternal.some(function (cidr) {
|
|
301
|
+
return cidrContains(cidr, addr);
|
|
302
|
+
})) continue;
|
|
303
|
+
|
|
304
|
+
throw new ErrorClass(
|
|
305
|
+
"URL '" + parsed.toString() + "' resolves to " + addr +
|
|
306
|
+
" in the " + category + " range — pass allowInternal:true to override",
|
|
307
|
+
"ssrf-guard/blocked-" + category,
|
|
308
|
+
{ url: parsed.toString(), ip: addr, category: category }
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return { url: parsed, ips: ips };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
classify: classify,
|
|
316
|
+
cidrContains: cidrContains,
|
|
317
|
+
checkUrl: checkUrl,
|
|
318
|
+
isPrivate: function (ip) { return classify(ip) === "private"; },
|
|
319
|
+
isLoopback: function (ip) { return classify(ip) === "loopback"; },
|
|
320
|
+
isLinkLocal: function (ip) { return classify(ip) === "link-local"; },
|
|
321
|
+
isCloudMetadata: function (ip) { return classify(ip) === "cloud-metadata"; },
|
|
322
|
+
isReserved: function (ip) { return classify(ip) === "reserved"; },
|
|
323
|
+
SsrfError: SsrfError,
|
|
324
|
+
};
|