@blamejs/core 0.18.7 → 0.18.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/CHANGELOG.md +2 -0
- package/README.md +3 -1
- package/index.js +4 -0
- package/lib/auth/ciba.js +4 -1
- package/lib/auth/oauth.js +313 -19
- package/lib/crypto-field.js +1 -1
- package/lib/crypto.js +55 -24
- package/lib/db-collection.js +7 -0
- package/lib/local-http.js +347 -0
- package/lib/request-helpers.js +126 -4
- package/lib/webhook-hmac.js +206 -0
- package/lib/webhook.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/crypto.js
CHANGED
|
@@ -98,8 +98,56 @@ function hash(data, algorithm, outputLength) {
|
|
|
98
98
|
return nodeCrypto.createHash(algorithm, opts).update(data).digest();
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// HMAC hash allowlist — SHA-2 + SHA-3 families. SHA-1 / MD5 are refused: they
|
|
102
|
+
// are collision-weak and no legitimate scheme needs a new one. External wire
|
|
103
|
+
// schemes (Stripe / Tailscale webhooks require HMAC-SHA256) fix the algorithm,
|
|
104
|
+
// so SHA-2 is admitted for interop even though the framework's own default is
|
|
105
|
+
// the PQC-first SHA3-512.
|
|
106
|
+
var HMAC_ALGS = {
|
|
107
|
+
"sha256": 1, "sha384": 1, "sha512": 1,
|
|
108
|
+
"sha3-256": 1, "sha3-384": 1, "sha3-512": 1,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @primitive b.crypto.hmac
|
|
113
|
+
* @signature b.crypto.hmac(key, data, algorithm?)
|
|
114
|
+
* @since 0.18.8
|
|
115
|
+
* @status stable
|
|
116
|
+
* @related b.crypto.timingSafeEqual, b.crypto.sha3Hash
|
|
117
|
+
*
|
|
118
|
+
* Lowercase-hex HMAC of `data` keyed by `key`. The `algorithm` defaults to the
|
|
119
|
+
* framework's PQC-first SHA3-512 — call `b.crypto.hmac(key, data)` for keyed
|
|
120
|
+
* integrity (webhook signatures, request-auth tags, audit-chain links) and it
|
|
121
|
+
* is strong by default. Pass an explicit weaker algorithm ONLY to interop with
|
|
122
|
+
* an external scheme that fixes it — e.g. Stripe / Tailscale webhook signatures
|
|
123
|
+
* require `"sha256"`. The algorithm is validated against a SHA-2 / SHA-3
|
|
124
|
+
* allowlist, so a typo or a broken choice (SHA-1 / MD5) throws at the entry tier
|
|
125
|
+
* rather than silently signing under a surprise hash. `key` and `data` accept a
|
|
126
|
+
* Buffer or string. Compare tags with `b.crypto.timingSafeEqual`, never `==`.
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* var tag = b.crypto.hmac("shared-secret", "POST /webhook|123");
|
|
130
|
+
* // → SHA3-512 HMAC (128 hex chars) — the PQC-first default
|
|
131
|
+
* var stripe = b.crypto.hmac(process.env.WHSEC, ts + "." + rawBody, "sha256");
|
|
132
|
+
* // → HMAC-SHA256 (64 hex chars) — explicit opt-down for external interop
|
|
133
|
+
*/
|
|
101
134
|
function hmac(key, data, algorithm) {
|
|
102
|
-
|
|
135
|
+
var alg = algorithm === undefined ? "sha3-512" : algorithm;
|
|
136
|
+
if (typeof alg !== "string" || !Object.prototype.hasOwnProperty.call(HMAC_ALGS, alg)) {
|
|
137
|
+
throw new TypeError(
|
|
138
|
+
"crypto.hmac: algorithm must be one of " + Object.keys(HMAC_ALGS).join(", ") +
|
|
139
|
+
" (SHA-1 / MD5 are refused), got " +
|
|
140
|
+
(alg === null ? "null" : JSON.stringify(alg)));
|
|
141
|
+
}
|
|
142
|
+
if (typeof key !== "string" && !Buffer.isBuffer(key)) {
|
|
143
|
+
throw new TypeError("crypto.hmac: key must be a Buffer or string, got " +
|
|
144
|
+
(key === null ? "null" : typeof key));
|
|
145
|
+
}
|
|
146
|
+
if (typeof data !== "string" && !Buffer.isBuffer(data)) {
|
|
147
|
+
throw new TypeError("crypto.hmac: data must be a Buffer or string, got " +
|
|
148
|
+
(data === null ? "null" : typeof data));
|
|
149
|
+
}
|
|
150
|
+
return nodeCrypto.createHmac(alg, key).update(data).digest("hex");
|
|
103
151
|
}
|
|
104
152
|
|
|
105
153
|
/**
|
|
@@ -480,7 +528,7 @@ function generateKeyPair(algorithm, options) {
|
|
|
480
528
|
* @primitive b.crypto.timingSafeEqual
|
|
481
529
|
* @signature b.crypto.timingSafeEqual(a, b)
|
|
482
530
|
* @since 0.1.0
|
|
483
|
-
* @related b.crypto.
|
|
531
|
+
* @related b.crypto.hmac
|
|
484
532
|
*
|
|
485
533
|
* Constant-time equality comparison. Accepts only Buffer or string
|
|
486
534
|
* inputs — non-string non-Buffer arguments throw at the entry tier so
|
|
@@ -493,7 +541,7 @@ function generateKeyPair(algorithm, options) {
|
|
|
493
541
|
* where a timing oracle would leak bits.
|
|
494
542
|
*
|
|
495
543
|
* @example
|
|
496
|
-
* var expected = b.crypto.
|
|
544
|
+
* var expected = b.crypto.hmac("server-key", "payload");
|
|
497
545
|
* var supplied = "ab12...e9"; // from request header / body
|
|
498
546
|
* var ok = b.crypto.timingSafeEqual(supplied, expected);
|
|
499
547
|
* // → true when bytes match, false otherwise (no early exit on mismatch)
|
|
@@ -531,7 +579,7 @@ function timingSafeEqual(a, b) {
|
|
|
531
579
|
* @primitive b.crypto.sha3Hash
|
|
532
580
|
* @signature b.crypto.sha3Hash(data)
|
|
533
581
|
* @since 0.1.0
|
|
534
|
-
* @related b.crypto.
|
|
582
|
+
* @related b.crypto.hmac, b.crypto.kdf, b.crypto.hashFile
|
|
535
583
|
*
|
|
536
584
|
* Returns the lowercase-hex SHA3-512 digest of the input. SHA3-512 is
|
|
537
585
|
* the framework's default hash — collision-resistant, sponge-based,
|
|
@@ -545,23 +593,6 @@ function timingSafeEqual(a, b) {
|
|
|
545
593
|
*/
|
|
546
594
|
function sha3Hash(data) { return hash(data, "sha3-512").toString("hex"); }
|
|
547
595
|
|
|
548
|
-
/**
|
|
549
|
-
* @primitive b.crypto.hmacSha3
|
|
550
|
-
* @signature b.crypto.hmacSha3(key, data)
|
|
551
|
-
* @since 0.1.0
|
|
552
|
-
* @related b.crypto.sha3Hash, b.crypto.timingSafeEqual
|
|
553
|
-
*
|
|
554
|
-
* Returns the lowercase-hex HMAC-SHA3-512 of `data` keyed by `key`.
|
|
555
|
-
* Use for keyed integrity checks (webhook signatures, request
|
|
556
|
-
* authentication tags, audit-chain links). Pair with
|
|
557
|
-
* `b.crypto.timingSafeEqual` when comparing supplied vs computed tags.
|
|
558
|
-
*
|
|
559
|
-
* @example
|
|
560
|
-
* var tag = b.crypto.hmacSha3("shared-secret", "POST /webhook|123");
|
|
561
|
-
* // → "8f1c...d4e2" (128 hex chars, HMAC-SHA3-512 = 64 bytes)
|
|
562
|
-
*/
|
|
563
|
-
function hmacSha3(key, data) { return hmac(key, data, "sha3-512"); }
|
|
564
|
-
|
|
565
596
|
// (SHA-1 is intentionally NOT exported from b.crypto. The framework's
|
|
566
597
|
// only legitimate SHA-1 use is the HaveIBeenPwned k-anonymity API in
|
|
567
598
|
// lib/auth/password.js, which imports lib/framework-sha1-hibp.js
|
|
@@ -2364,8 +2395,8 @@ function selfTest(opts) {
|
|
|
2364
2395
|
});
|
|
2365
2396
|
record("HMAC-SHA3-512 determinism", function () {
|
|
2366
2397
|
var k = Buffer.from("self-test-hmac-key", "utf8");
|
|
2367
|
-
assert(timingSafeEqual(
|
|
2368
|
-
assert(!timingSafeEqual(
|
|
2398
|
+
assert(timingSafeEqual(hmac(k, "abc"), hmac(k, "abc")), "HMAC-SHA3-512 is not deterministic");
|
|
2399
|
+
assert(!timingSafeEqual(hmac(k, "abc"), hmac(k, "abd")), "HMAC-SHA3-512 collided on distinct inputs");
|
|
2369
2400
|
});
|
|
2370
2401
|
record("XChaCha20-Poly1305 round-trip + tamper-detect", function () {
|
|
2371
2402
|
var key = generateBytes(C.BYTES.bytes(32));
|
|
@@ -2422,7 +2453,7 @@ module.exports = {
|
|
|
2422
2453
|
selfTest: selfTest,
|
|
2423
2454
|
// Hashing
|
|
2424
2455
|
sha3Hash: sha3Hash,
|
|
2425
|
-
|
|
2456
|
+
hmac: hmac,
|
|
2426
2457
|
hashFile: hashFile,
|
|
2427
2458
|
hashFilesParallel: hashFilesParallel,
|
|
2428
2459
|
hashStream: hashStream,
|
package/lib/db-collection.js
CHANGED
|
@@ -523,6 +523,13 @@ function collection(name, opts) {
|
|
|
523
523
|
// write back. Single-row default; many-row when opts.many.
|
|
524
524
|
var qFetch = db().from(name);
|
|
525
525
|
_applyQuery(qFetch, query || {});
|
|
526
|
+
// The per-row writes below carry a WHERE (by _id), so they slip past
|
|
527
|
+
// db-query's unconditional-write guard. Re-assert it on the READ: an
|
|
528
|
+
// overflow update with no filter conditions is refused exactly like a
|
|
529
|
+
// real-column one, rather than silently rewriting every row.
|
|
530
|
+
if (typeof qFetch._hasConditions === "function" && !qFetch._hasConditions()) {
|
|
531
|
+
throw new Error("refusing unconditional update — call where(...) first");
|
|
532
|
+
}
|
|
526
533
|
if (single) qFetch.limit(1);
|
|
527
534
|
var rows = qFetch.all();
|
|
528
535
|
for (var ri = 0; ri < rows.length; ri += 1) {
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module b.localHttp
|
|
6
|
+
* @nav HTTP
|
|
7
|
+
* @title Local-daemon HTTP
|
|
8
|
+
* @order 118
|
|
9
|
+
*
|
|
10
|
+
* @intro
|
|
11
|
+
* An HTTP client for a LOCAL daemon reached over a non-network transport —
|
|
12
|
+
* a Unix domain socket (Docker <code>/var/run/docker.sock</code>, systemd,
|
|
13
|
+
* containerd, <code>tailscaled</code>), a Windows named pipe, or a
|
|
14
|
+
* loopback TCP port + bearer token (the sandboxed-macOS shape). Distinct
|
|
15
|
+
* from <code>b.httpClient</code>, which does DNS + TCP/TLS + the SSRF gate.
|
|
16
|
+
*
|
|
17
|
+
* This client is <strong>SSRF-safe by construction</strong>: a socket-path
|
|
18
|
+
* request never resolves DNS and never touches an IP, so it cannot be
|
|
19
|
+
* steered at an internal address; the loopback-TCP mode refuses any host
|
|
20
|
+
* that is not a loopback address. It always sets the caller-chosen
|
|
21
|
+
* <code>Host</code> header (many local APIs require an exact value such as
|
|
22
|
+
* <code>local-tailscaled.sock</code>) and NEVER sends <code>Origin</code> or
|
|
23
|
+
* <code>Referer</code> — the two headers a local daemon uses to reject
|
|
24
|
+
* drive-by / DNS-rebinding requests from a browser.
|
|
25
|
+
*
|
|
26
|
+
* Responses are size-bounded and typed: <code>{ statusCode, headers, body,
|
|
27
|
+
* text(), json() }</code>. The vendor glue (a tailscaled Host value,
|
|
28
|
+
* <code>.whois/.status</code> wrappers) belongs in the consumer, not here.
|
|
29
|
+
*
|
|
30
|
+
* @card
|
|
31
|
+
* SSRF-safe HTTP over a local Unix socket / Windows named pipe / loopback-TCP+token — caller-set Host, no Origin/Referer, bounded typed responses. For Docker / systemd / tailscaled-style local daemons.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
var http = require("node:http");
|
|
35
|
+
var net = require("node:net");
|
|
36
|
+
var safeBuffer = require("./safe-buffer");
|
|
37
|
+
var safeJson = require("./safe-json");
|
|
38
|
+
var numericBounds = require("./numeric-bounds");
|
|
39
|
+
var validateOpts = require("./validate-opts");
|
|
40
|
+
var C = require("./constants");
|
|
41
|
+
var { defineClass } = require("./framework-error");
|
|
42
|
+
|
|
43
|
+
// Transport / timeout / response-stream failures are TRANSIENT — the local
|
|
44
|
+
// daemon may be briefly down, restarting, or slow, so a caller's retry policy
|
|
45
|
+
// should be free to retry them. Config / input / size-cap errors are permanent
|
|
46
|
+
// (a retry cannot fix a bad path, a non-loopback host, or an over-cap response).
|
|
47
|
+
var LocalHttpError = defineClass("LocalHttpError", {
|
|
48
|
+
permanentClassifier: function (code) {
|
|
49
|
+
return !(code === "local-http/request-error" ||
|
|
50
|
+
code === "local-http/timeout" ||
|
|
51
|
+
code === "local-http/response-error");
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
var DEFAULT_TIMEOUT_MS = C.TIME.seconds(10);
|
|
56
|
+
var DEFAULT_MAX_RESPONSE = C.BYTES.mib(8);
|
|
57
|
+
var DEFAULT_HOST_HEADER = "localhost";
|
|
58
|
+
|
|
59
|
+
// A local daemon must never see these — they are the browser-origin signals a
|
|
60
|
+
// daemon relies on to reject cross-site / DNS-rebinding requests, so a client
|
|
61
|
+
// that quietly forwarded them would defeat the daemon's own CSRF defense.
|
|
62
|
+
var FORBIDDEN_HEADERS = ["origin", "referer"];
|
|
63
|
+
|
|
64
|
+
// Canonicalize a host for both the loopback check and the actual connect:
|
|
65
|
+
// lowercase, drop a single trailing root-zone dot ("localhost." → "localhost"),
|
|
66
|
+
// and strip a surrounding IPv6 bracket pair ("[::1]" → "::1", the form
|
|
67
|
+
// http.request's `host` option expects — a bracketed literal would otherwise be
|
|
68
|
+
// resolved as a hostname).
|
|
69
|
+
function _canonHost(host) {
|
|
70
|
+
var h = String(host).toLowerCase().replace(/\.$/, "");
|
|
71
|
+
if (h.length >= 2 && h.charAt(0) === "[" && h.charAt(h.length - 1) === "]") h = h.slice(1, -1);
|
|
72
|
+
return h;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Loopback-only guard for the TCP mode: keeps the "SSRF-safe by construction"
|
|
76
|
+
// property. The host MUST be a loopback IP LITERAL — net.isIP rejects every
|
|
77
|
+
// spelling http.request would instead send through name resolution: a hostname
|
|
78
|
+
// like "localhost", or a non-canonical form such as "127.001.002.003" (net.isIP
|
|
79
|
+
// returns 0 for it), either of which a poisoned resolver / hosts file could
|
|
80
|
+
// steer off-loopback. Only a canonical 127.0.0.0/8 IPv4 or ::1 is accepted.
|
|
81
|
+
function _isLoopbackHost(host) {
|
|
82
|
+
if (typeof host !== "string") return false;
|
|
83
|
+
var h = _canonHost(host);
|
|
84
|
+
var fam = net.isIP(h);
|
|
85
|
+
if (fam === 6) return h === "::1";
|
|
86
|
+
if (fam === 4) return /^127\./.test(h); // net.isIP already validated octets 0..255, no leading zeros
|
|
87
|
+
return false; // not an IP literal (hostnames, non-canonical spellings) → refuse
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function _lowerHeaderKeys(headers) {
|
|
91
|
+
var out = {};
|
|
92
|
+
if (headers && typeof headers === "object") {
|
|
93
|
+
var keys = Object.keys(headers);
|
|
94
|
+
for (var i = 0; i < keys.length; i += 1) out[keys[i].toLowerCase()] = headers[keys[i]];
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @primitive b.localHttp.create
|
|
101
|
+
* @signature b.localHttp.create(opts)
|
|
102
|
+
* @since 0.18.8
|
|
103
|
+
* @status stable
|
|
104
|
+
* @related b.localHttp.request, b.httpClient
|
|
105
|
+
*
|
|
106
|
+
* Build a client bound to ONE local transport. Provide EITHER
|
|
107
|
+
* <code>socketPath</code> (a Unix socket path or a Windows named pipe like
|
|
108
|
+
* <code>\\.\pipe\name</code>) OR <code>host</code> + <code>port</code> (which
|
|
109
|
+
* must be a loopback address). Returns a client with <code>request</code>,
|
|
110
|
+
* <code>get</code>, and <code>postJson</code> — every call sends the configured
|
|
111
|
+
* <code>hostHeader</code> and omits <code>Origin</code>/<code>Referer</code>.
|
|
112
|
+
*
|
|
113
|
+
* @opts
|
|
114
|
+
* socketPath: string, // Unix socket path OR Windows named pipe (exclusive with host/port)
|
|
115
|
+
* host: string, // loopback IP LITERAL for the TCP+token mode (127.0.0.0/8 or ::1 — a hostname like "localhost" is refused)
|
|
116
|
+
* port: number, // TCP port (with host)
|
|
117
|
+
* hostHeader: string, // the Host header to send (default: "localhost")
|
|
118
|
+
* bearerToken: string, // Authorization: Bearer <token> on every request
|
|
119
|
+
* defaultHeaders: object, // headers merged into every request (Origin/Referer stripped)
|
|
120
|
+
* timeoutMs: number, // per-request timeout (default: 10s)
|
|
121
|
+
* maxResponseBytes: number, // response body cap; over-cap aborts (default: 8 MiB)
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* var d = b.localHttp.create({ socketPath: "/run/tailscale/tailscaled.sock",
|
|
125
|
+
* hostHeader: "local-tailscaled.sock" });
|
|
126
|
+
* var r = await d.get("/localapi/v0/status");
|
|
127
|
+
* // → { statusCode: 200, headers, body, text(), json() }
|
|
128
|
+
*/
|
|
129
|
+
function create(opts) {
|
|
130
|
+
opts = validateOpts.requireObject(opts, "localHttp.create", LocalHttpError, "local-http/bad-opts");
|
|
131
|
+
validateOpts(opts,
|
|
132
|
+
["socketPath", "host", "port", "hostHeader", "bearerToken", "defaultHeaders", "timeoutMs", "maxResponseBytes"],
|
|
133
|
+
"localHttp.create");
|
|
134
|
+
|
|
135
|
+
var hasSocket = opts.socketPath !== undefined && opts.socketPath !== null;
|
|
136
|
+
var hasTcp = opts.host !== undefined && opts.host !== null;
|
|
137
|
+
if (hasSocket === hasTcp) {
|
|
138
|
+
throw new LocalHttpError("local-http/bad-transport",
|
|
139
|
+
"create: provide EXACTLY one of opts.socketPath OR opts.host+opts.port");
|
|
140
|
+
}
|
|
141
|
+
var socketPath = null;
|
|
142
|
+
var host = null;
|
|
143
|
+
var port = null;
|
|
144
|
+
if (hasSocket) {
|
|
145
|
+
validateOpts.requireNonEmptyString(opts.socketPath, "localHttp.create: opts.socketPath",
|
|
146
|
+
LocalHttpError, "local-http/bad-socket-path");
|
|
147
|
+
socketPath = opts.socketPath;
|
|
148
|
+
} else {
|
|
149
|
+
if (!_isLoopbackHost(opts.host)) {
|
|
150
|
+
throw new LocalHttpError("local-http/non-loopback-host",
|
|
151
|
+
"create: opts.host must be a loopback IP literal (127.0.0.0/8 or ::1; a hostname like 'localhost' is refused) — a non-loopback " +
|
|
152
|
+
"host would defeat the SSRF-safe-by-construction guarantee; use b.httpClient for network hosts");
|
|
153
|
+
}
|
|
154
|
+
numericBounds.requirePositiveFiniteInt(opts.port, "port", LocalHttpError, "local-http/bad-port");
|
|
155
|
+
if (opts.port > 65535) {
|
|
156
|
+
throw new LocalHttpError("local-http/bad-port", "create: opts.port must be 1..65535");
|
|
157
|
+
}
|
|
158
|
+
// Connect to the canonical host (unbracketed IPv6 / no trailing dot) — the
|
|
159
|
+
// form http.request's `host` option expects.
|
|
160
|
+
host = _canonHost(opts.host);
|
|
161
|
+
port = opts.port;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
var hostHeader = opts.hostHeader !== undefined ? opts.hostHeader : DEFAULT_HOST_HEADER;
|
|
165
|
+
validateOpts.requireNonEmptyString(hostHeader, "localHttp.create: opts.hostHeader",
|
|
166
|
+
LocalHttpError, "local-http/bad-host-header");
|
|
167
|
+
validateOpts.optionalNonEmptyString(opts.bearerToken, "localHttp.create: opts.bearerToken",
|
|
168
|
+
LocalHttpError, "local-http/bad-token");
|
|
169
|
+
if (opts.defaultHeaders !== undefined && (opts.defaultHeaders === null || typeof opts.defaultHeaders !== "object")) {
|
|
170
|
+
throw new LocalHttpError("local-http/bad-default-headers", "create: opts.defaultHeaders must be an object");
|
|
171
|
+
}
|
|
172
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.timeoutMs, "timeoutMs", LocalHttpError, "local-http/bad-timeout");
|
|
173
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.maxResponseBytes, "maxResponseBytes", LocalHttpError, "local-http/bad-max-response");
|
|
174
|
+
var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
175
|
+
var maxResponseBytes = typeof opts.maxResponseBytes === "number" ? opts.maxResponseBytes : DEFAULT_MAX_RESPONSE;
|
|
176
|
+
var defaultHeaders = _lowerHeaderKeys(opts.defaultHeaders);
|
|
177
|
+
FORBIDDEN_HEADERS.forEach(function (h) { delete defaultHeaders[h]; });
|
|
178
|
+
|
|
179
|
+
function request(ropts) {
|
|
180
|
+
return new Promise(function (resolve, reject) {
|
|
181
|
+
var r = ropts || {};
|
|
182
|
+
if (typeof r.path !== "string" || r.path.length === 0 || r.path.charAt(0) !== "/") {
|
|
183
|
+
reject(new LocalHttpError("local-http/bad-path", "request: opts.path must be a string beginning with '/'"));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Reject a raw space / control char in the path up front, as a typed error
|
|
187
|
+
// — http.request would otherwise throw a synchronous ERR_UNESCAPED_CHARACTERS
|
|
188
|
+
// inside the executor. Callers percent-encode such characters.
|
|
189
|
+
var _pathBad = false;
|
|
190
|
+
for (var _pi = 0; _pi < r.path.length; _pi += 1) {
|
|
191
|
+
var _pc = r.path.charCodeAt(_pi);
|
|
192
|
+
if (_pc <= 0x20 || _pc === 0x7f) { _pathBad = true; break; }
|
|
193
|
+
}
|
|
194
|
+
if (_pathBad) {
|
|
195
|
+
reject(new LocalHttpError("local-http/bad-path", "request: opts.path contains an unescaped space or control character"));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
var method = typeof r.method === "string" ? r.method.toUpperCase() : "GET";
|
|
199
|
+
// Merge headers: defaults, then per-request, then FORCE Host + strip
|
|
200
|
+
// Origin/Referer + apply the bearer token. setHost:false keeps Node from
|
|
201
|
+
// synthesising a Host we don't control.
|
|
202
|
+
var headers = Object.assign({}, defaultHeaders, _lowerHeaderKeys(r.headers));
|
|
203
|
+
FORBIDDEN_HEADERS.forEach(function (h) { delete headers[h]; });
|
|
204
|
+
headers.host = hostHeader;
|
|
205
|
+
if (opts.bearerToken !== undefined && headers.authorization === undefined) {
|
|
206
|
+
headers.authorization = "Bearer " + opts.bearerToken;
|
|
207
|
+
}
|
|
208
|
+
var bodyBuf = null;
|
|
209
|
+
if (r.body !== undefined && r.body !== null) {
|
|
210
|
+
bodyBuf = Buffer.isBuffer(r.body) ? r.body
|
|
211
|
+
: typeof r.body === "string" ? Buffer.from(r.body, "utf8")
|
|
212
|
+
: null;
|
|
213
|
+
if (!bodyBuf) { reject(new LocalHttpError("local-http/bad-body", "request: opts.body must be a Buffer or string")); return; }
|
|
214
|
+
headers["content-length"] = String(bodyBuf.length);
|
|
215
|
+
}
|
|
216
|
+
var reqOpts = {
|
|
217
|
+
method: method,
|
|
218
|
+
path: r.path,
|
|
219
|
+
headers: headers,
|
|
220
|
+
setHost: false, // we own the Host header
|
|
221
|
+
};
|
|
222
|
+
if (socketPath !== null) reqOpts.socketPath = socketPath;
|
|
223
|
+
else { reqOpts.host = host; reqOpts.port = port; }
|
|
224
|
+
|
|
225
|
+
var settled = false;
|
|
226
|
+
var deadline = null;
|
|
227
|
+
function fail(err) {
|
|
228
|
+
/* c8 ignore next -- re-entry guard: fail() settles once; a second failure source racing the first (a destroy-induced socket error vs. a size/timeout abort) is not deterministically forceable */
|
|
229
|
+
if (!settled) {
|
|
230
|
+
settled = true;
|
|
231
|
+
clearTimeout(deadline);
|
|
232
|
+
/* c8 ignore next -- defensive: req.destroy() on an already-closed socket does not throw in practice */
|
|
233
|
+
try { req.destroy(); } catch (_d) { /* already gone */ }
|
|
234
|
+
reject(err);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// This is the LOCAL-transport client — a socketPath (unix socket / named
|
|
239
|
+
// pipe) or a loopback TCP peer, never a network host. It deliberately does
|
|
240
|
+
// NOT route through b.httpClient (DNS + TCP/TLS + the SSRF gate): that is the
|
|
241
|
+
// exact surface it exists to bypass safely (a socket-path request can't be
|
|
242
|
+
// steered at an IP).
|
|
243
|
+
var req = http.request(reqOpts, function (res) { // allow:raw-outbound-http-framework-internal — local socket / loopback transport, never a network host
|
|
244
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
245
|
+
maxBytes: maxResponseBytes,
|
|
246
|
+
errorClass: LocalHttpError,
|
|
247
|
+
sizeCode: "local-http/response-too-large",
|
|
248
|
+
sizeMessage: "response body exceeded maxResponseBytes (" + maxResponseBytes + ")",
|
|
249
|
+
});
|
|
250
|
+
res.on("data", function (chunk) {
|
|
251
|
+
/* c8 ignore next -- data-after-settled race guard: once fail()/end has settled + destroyed the socket, a late buffered 'data' is not deterministically forceable */
|
|
252
|
+
if (settled) return;
|
|
253
|
+
try { collector.push(chunk); } catch (e) { fail(e); }
|
|
254
|
+
});
|
|
255
|
+
res.on("end", function () {
|
|
256
|
+
if (settled) return;
|
|
257
|
+
settled = true;
|
|
258
|
+
clearTimeout(deadline);
|
|
259
|
+
var body = collector.result();
|
|
260
|
+
resolve({
|
|
261
|
+
statusCode: res.statusCode,
|
|
262
|
+
headers: res.headers,
|
|
263
|
+
body: body,
|
|
264
|
+
text: function () { return body.toString("utf8"); },
|
|
265
|
+
json: function () { return safeJson.parse(body.toString("utf8"), { maxBytes: maxResponseBytes }); },
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
/* c8 ignore next -- response-stream 'error' after headers (mid-body transport fault) is not deterministically forceable; the connect/transport path is covered by req.on('error') below */
|
|
269
|
+
res.on("error", function (e) { fail(new LocalHttpError("local-http/response-error", "request: response stream error: " + ((e && e.message) || String(e)))); });
|
|
270
|
+
});
|
|
271
|
+
req.on("error", function (e) {
|
|
272
|
+
fail(new LocalHttpError("local-http/request-error",
|
|
273
|
+
/* c8 ignore next -- String(e) fallback: a transport error always carries a message */
|
|
274
|
+
"request: transport error: " + ((e && e.message) || String(e))));
|
|
275
|
+
});
|
|
276
|
+
// End-to-end deadline: a single timer measured from request start bounds
|
|
277
|
+
// the TOTAL duration. A socket-idle timeout (http's req.setTimeout) would
|
|
278
|
+
// reset on every byte, so a slow-drip response — a byte every few ms, never
|
|
279
|
+
// finishing — would never trip it; the fixed deadline always fires first.
|
|
280
|
+
deadline = setTimeout(function () {
|
|
281
|
+
fail(new LocalHttpError("local-http/timeout", "request: exceeded the " + timeoutMs + "ms deadline"));
|
|
282
|
+
}, timeoutMs);
|
|
283
|
+
if (bodyBuf) req.write(bodyBuf);
|
|
284
|
+
req.end();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function get(path, gopts) {
|
|
289
|
+
return request(Object.assign({}, gopts, { method: "GET", path: path }));
|
|
290
|
+
}
|
|
291
|
+
// async so a non-serializable obj (circular / BigInt) surfaces as a rejected
|
|
292
|
+
// promise the caller can .catch(), not a synchronous throw from JSON.stringify.
|
|
293
|
+
async function postJson(path, obj, popts) {
|
|
294
|
+
var headers = Object.assign({}, (popts && popts.headers) || {}, { "content-type": "application/json" });
|
|
295
|
+
return request(Object.assign({}, popts, { method: "POST", path: path, headers: headers, body: JSON.stringify(obj) }));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { request: request, get: get, postJson: postJson };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @primitive b.localHttp.request
|
|
303
|
+
* @signature b.localHttp.request(opts)
|
|
304
|
+
* @since 0.18.8
|
|
305
|
+
* @status stable
|
|
306
|
+
* @related b.localHttp.create
|
|
307
|
+
*
|
|
308
|
+
* One-shot convenience: build a client from the transport fields and issue a
|
|
309
|
+
* single request. `opts` carries both the `create` transport fields
|
|
310
|
+
* (socketPath / host+port / hostHeader / bearerToken / timeoutMs /
|
|
311
|
+
* maxResponseBytes) and the per-request fields (method / path / headers /
|
|
312
|
+
* body). Resolves the same typed response as the client's `request`.
|
|
313
|
+
*
|
|
314
|
+
* @opts
|
|
315
|
+
* socketPath: string, // Unix socket path OR Windows named pipe (exclusive with host/port)
|
|
316
|
+
* host: string, // loopback IP LITERAL for the TCP+token mode (127.0.0.0/8 or ::1 — a hostname like "localhost" is refused)
|
|
317
|
+
* port: number, // TCP port (with host)
|
|
318
|
+
* hostHeader: string, // the Host header to send (default: "localhost")
|
|
319
|
+
* bearerToken: string, // Authorization: Bearer <token> on the request
|
|
320
|
+
* defaultHeaders: object, // headers merged into the request (Origin/Referer stripped)
|
|
321
|
+
* timeoutMs: number, // request timeout (default: 10s)
|
|
322
|
+
* maxResponseBytes: number, // response body cap; over-cap aborts (default: 8 MiB)
|
|
323
|
+
* method: string, // HTTP method (default: "GET")
|
|
324
|
+
* path: string, // request path (must start with "/")
|
|
325
|
+
* headers: object, // per-request headers (merged over defaultHeaders)
|
|
326
|
+
* body: Buffer | string, // request body
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* var r = await b.localHttp.request({
|
|
330
|
+
* socketPath: "/var/run/docker.sock", hostHeader: "localhost",
|
|
331
|
+
* path: "/v1.44/containers/json",
|
|
332
|
+
* });
|
|
333
|
+
*/
|
|
334
|
+
var TRANSPORT_FIELDS = ["socketPath", "host", "port", "hostHeader", "bearerToken", "defaultHeaders", "timeoutMs", "maxResponseBytes"];
|
|
335
|
+
|
|
336
|
+
function request(opts) {
|
|
337
|
+
opts = validateOpts.requireObject(opts, "localHttp.request", LocalHttpError, "local-http/bad-opts");
|
|
338
|
+
var createOpts = {};
|
|
339
|
+
TRANSPORT_FIELDS.forEach(function (k) { if (opts[k] !== undefined) createOpts[k] = opts[k]; });
|
|
340
|
+
return create(createOpts).request({ method: opts.method, path: opts.path, headers: opts.headers, body: opts.body });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
module.exports = {
|
|
344
|
+
create: create,
|
|
345
|
+
request: request,
|
|
346
|
+
LocalHttpError: LocalHttpError,
|
|
347
|
+
};
|
package/lib/request-helpers.js
CHANGED
|
@@ -289,9 +289,12 @@ function clientIp(req, opts) {
|
|
|
289
289
|
// socket peer is not a trusted proxy — cannot forge the result: the
|
|
290
290
|
// forgeable header is ignored and we fall through to the socket
|
|
291
291
|
// address. This is the only form safe for an access-control decision.
|
|
292
|
-
|
|
292
|
+
// Require an EXACT boolean true from the operator predicate — an async
|
|
293
|
+
// (Promise) or truthy-non-boolean result must NOT trust a hop (it would
|
|
294
|
+
// otherwise fall through to the forgeable hops[0] for access control).
|
|
295
|
+
if (socketAddr && trust(socketAddr) === true) {
|
|
293
296
|
for (var i = hops.length - 1; i >= 0; i--) {
|
|
294
|
-
if (
|
|
297
|
+
if (trust(hops[i]) !== true) return hops[i];
|
|
295
298
|
}
|
|
296
299
|
return hops[0]; // entire chain trusted — earliest claimed client
|
|
297
300
|
}
|
|
@@ -394,6 +397,124 @@ function trustedClientIp(opts) {
|
|
|
394
397
|
};
|
|
395
398
|
}
|
|
396
399
|
|
|
400
|
+
function _socketAddr(req) {
|
|
401
|
+
return (req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
402
|
+
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
403
|
+
: null;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* @primitive b.requestHelpers.trustedIdentityHeaders
|
|
408
|
+
* @signature b.requestHelpers.trustedIdentityHeaders(opts)
|
|
409
|
+
* @since 0.18.8
|
|
410
|
+
* @status stable
|
|
411
|
+
* @related b.requestHelpers.trustedClientIp, b.requestHelpers.clientIp
|
|
412
|
+
*
|
|
413
|
+
* Resolve an identity-injecting reverse proxy's headers under the SAME
|
|
414
|
+
* peer-gate as `trustedClientIp` — the mirror of the `X-Forwarded-For`
|
|
415
|
+
* discipline for identity-header families (Cloudflare Access `Cf-Access-*`,
|
|
416
|
+
* oauth2-proxy `X-Forwarded-User`, Tailscale Serve `Tailscale-User-*`). A
|
|
417
|
+
* configured header family is trusted ONLY when the immediate socket peer is a
|
|
418
|
+
* trusted proxy; from every OTHER peer the family is defensively stripped from
|
|
419
|
+
* `req.headers` so downstream code cannot read a forged value. A naive trust of
|
|
420
|
+
* these headers is a full impersonation bypass — so this reuses the
|
|
421
|
+
* `trustedProxies` gate rather than opening a second, looser trust path.
|
|
422
|
+
*
|
|
423
|
+
* Returns `{ resolve(req), middleware, headerNames, peerGated }`. `resolve(req)`
|
|
424
|
+
* → `{ trusted, identity }` (`identity` is `{}` unless the peer is trusted).
|
|
425
|
+
* `middleware(req, res, next)` sets `req[as]` to the identity when trusted and
|
|
426
|
+
* DELETES every family header from `req.headers` when not. With no
|
|
427
|
+
* `trustedProxies`/`peerTrust` the peer is never trusted (fail-closed: the
|
|
428
|
+
* family is always stripped and `peerGated` is false).
|
|
429
|
+
*
|
|
430
|
+
* Header VALUES are surfaced raw — RFC 2047 name decoding and capability-JSON
|
|
431
|
+
* parsing are the consumer's job, not the trust boundary's.
|
|
432
|
+
*
|
|
433
|
+
* @opts
|
|
434
|
+
* headers: object, // { field: "Header-Name", ... } — the family to trust (required)
|
|
435
|
+
* trustedProxies: string | string[], // CIDRs of the reverse proxies — peer-gate the family
|
|
436
|
+
* peerTrust: function(req): boolean, // own the peer-trust decision entirely (instead of trustedProxies)
|
|
437
|
+
* as: string, // req property to set the identity on (default: "proxyIdentity")
|
|
438
|
+
*
|
|
439
|
+
* @example
|
|
440
|
+
* var ident = b.requestHelpers.trustedIdentityHeaders({
|
|
441
|
+
* trustedProxies: ["127.0.0.1/32"],
|
|
442
|
+
* headers: { login: "Tailscale-User-Login", name: "Tailscale-User-Name" },
|
|
443
|
+
* });
|
|
444
|
+
* app.use(ident.middleware);
|
|
445
|
+
* // req.proxyIdentity = { login, name } from the trusted sidecar; a forged
|
|
446
|
+
* // Tailscale-User-Login from a direct client is stripped, never trusted.
|
|
447
|
+
*/
|
|
448
|
+
function trustedIdentityHeaders(opts) {
|
|
449
|
+
opts = opts || {};
|
|
450
|
+
if (!opts.headers || typeof opts.headers !== "object" || Array.isArray(opts.headers)) {
|
|
451
|
+
throw new TypeError("trustedIdentityHeaders: opts.headers must be an object mapping field → header name");
|
|
452
|
+
}
|
|
453
|
+
var fieldNames = Object.keys(opts.headers);
|
|
454
|
+
if (fieldNames.length === 0) {
|
|
455
|
+
throw new TypeError("trustedIdentityHeaders: opts.headers must map at least one field");
|
|
456
|
+
}
|
|
457
|
+
var map = {}; // field → lowercased header name
|
|
458
|
+
var headerNames = []; // lowercased header names (the family to strip)
|
|
459
|
+
for (var i = 0; i < fieldNames.length; i++) {
|
|
460
|
+
var hn = opts.headers[fieldNames[i]];
|
|
461
|
+
if (typeof hn !== "string" || hn.length === 0) {
|
|
462
|
+
throw new TypeError("trustedIdentityHeaders: header name for field '" + fieldNames[i] + "' must be a non-empty string");
|
|
463
|
+
}
|
|
464
|
+
var lhn = hn.toLowerCase();
|
|
465
|
+
map[fieldNames[i]] = lhn;
|
|
466
|
+
headerNames.push(lhn);
|
|
467
|
+
}
|
|
468
|
+
var peerTrust = opts.peerTrust;
|
|
469
|
+
if (peerTrust != null && typeof peerTrust !== "function") {
|
|
470
|
+
throw new TypeError("trustedIdentityHeaders: peerTrust must be a function(req) => boolean");
|
|
471
|
+
}
|
|
472
|
+
var predicate = _trustedProxyPredicate(_normTrustedProxies(opts), "trustedIdentityHeaders");
|
|
473
|
+
var asProp = (typeof opts.as === "string" && opts.as.length) ? opts.as : "proxyIdentity";
|
|
474
|
+
|
|
475
|
+
function _peerTrusted(req) {
|
|
476
|
+
// Require an EXACT synchronous `true`. A `!!` would treat a Promise (an async
|
|
477
|
+
// predicate) — or any truthy non-boolean — as trusted, so an untrusted peer
|
|
478
|
+
// could be impersonated; a non-true / thenable result fails closed.
|
|
479
|
+
if (peerTrust) return peerTrust(req) === true;
|
|
480
|
+
if (!predicate) return false; // no gate configured → never trust (fail-closed)
|
|
481
|
+
var addr = _socketAddr(req);
|
|
482
|
+
return !!(addr && predicate(addr));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function resolve(req) {
|
|
486
|
+
if (!req || !req.headers || !_peerTrusted(req)) return { trusted: false, identity: {} };
|
|
487
|
+
var identity = {};
|
|
488
|
+
for (var f = 0; f < fieldNames.length; f++) {
|
|
489
|
+
var v = req.headers[map[fieldNames[f]]];
|
|
490
|
+
if (typeof v === "string") identity[fieldNames[f]] = v;
|
|
491
|
+
}
|
|
492
|
+
return { trusted: true, identity: identity };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function middleware(req, res, next) {
|
|
496
|
+
var r = resolve(req);
|
|
497
|
+
if (r.trusted) {
|
|
498
|
+
req[asProp] = r.identity;
|
|
499
|
+
} else {
|
|
500
|
+
// Defensive strip — a non-trusted peer must not deliver a family header
|
|
501
|
+
// that downstream reads as trusted identity.
|
|
502
|
+
if (req && req.headers) {
|
|
503
|
+
for (var h = 0; h < headerNames.length; h++) delete req.headers[headerNames[h]];
|
|
504
|
+
}
|
|
505
|
+
if (req) req[asProp] = null;
|
|
506
|
+
}
|
|
507
|
+
if (typeof next === "function") next();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
resolve: resolve,
|
|
512
|
+
middleware: middleware,
|
|
513
|
+
headerNames: headerNames.slice(),
|
|
514
|
+
peerGated: !!(peerTrust || predicate),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
397
518
|
// IP-prefix masking constants — named so the bit-arithmetic stays readable.
|
|
398
519
|
// /24 IPv4 is the original IP-geolocation bucket and matches the legacy
|
|
399
520
|
// carrier-NAT pool stride; /64 IPv6 is the customer LAN every RIR allocates
|
|
@@ -682,7 +803,7 @@ function requestProtocol(req, opts) {
|
|
|
682
803
|
(req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
683
804
|
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
684
805
|
: null;
|
|
685
|
-
if (peer && trust(peer)) return hops[0];
|
|
806
|
+
if (peer && trust(peer) === true) return hops[0]; // require an exact boolean true (no async/truthy trust)
|
|
686
807
|
// peer not a trusted proxy → ignore forgeable header, fall through
|
|
687
808
|
} else {
|
|
688
809
|
return hops[0]; // legacy true/number — spoofable, see docstring
|
|
@@ -772,7 +893,7 @@ function requestHost(req, opts) {
|
|
|
772
893
|
(req.socket && typeof req.socket.remoteAddress === "string" && req.socket.remoteAddress) ? req.socket.remoteAddress
|
|
773
894
|
: (req.connection && typeof req.connection.remoteAddress === "string" && req.connection.remoteAddress) ? req.connection.remoteAddress
|
|
774
895
|
: null;
|
|
775
|
-
if (peer && trust(peer)) return hops[0];
|
|
896
|
+
if (peer && trust(peer) === true) return hops[0]; // require an exact boolean true (no async/truthy trust)
|
|
776
897
|
// peer not a trusted proxy → ignore forgeable header, fall through
|
|
777
898
|
} else {
|
|
778
899
|
return hops[0]; // legacy true — spoofable, see docstring
|
|
@@ -1371,6 +1492,7 @@ module.exports = {
|
|
|
1371
1492
|
// proxy-trust primitives (default refuses forwarded headers)
|
|
1372
1493
|
clientIp: clientIp,
|
|
1373
1494
|
trustedClientIp: trustedClientIp,
|
|
1495
|
+
trustedIdentityHeaders: trustedIdentityHeaders,
|
|
1374
1496
|
ipPrefix: ipPrefix,
|
|
1375
1497
|
ipKey: ipKey,
|
|
1376
1498
|
requestProtocol: requestProtocol,
|