@blamejs/core 0.7.4 → 0.7.18
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 +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/http-client.js
CHANGED
|
@@ -1,1195 +1,1195 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* HTTP client primitive — Promise-returning, AbortSignal-aware,
|
|
4
|
-
* connection-pooled, streaming-capable, HTTP/2-capable.
|
|
5
|
-
*
|
|
6
|
-
* Built on node:http, node:https, and node:http2. Zero npm runtime
|
|
7
|
-
* dependency. Same caller surface for h1 and h2; the protocol version
|
|
8
|
-
* is negotiated per-origin via ALPN (h2 preferred, h1 fallback).
|
|
9
|
-
*
|
|
10
|
-
* Single entry point:
|
|
11
|
-
*
|
|
12
|
-
* await httpClient.request({
|
|
13
|
-
* method, // string, default GET
|
|
14
|
-
* url, // string or URL
|
|
15
|
-
* headers, // object, default {}
|
|
16
|
-
* body, // Buffer | string | Readable | undefined
|
|
17
|
-
* timeoutMs, // wall-clock cap (caller-chosen, no default)
|
|
18
|
-
* idleTimeoutMs, // zero-progress idle cap (default 30s)
|
|
19
|
-
* responseMode, // "buffer" (default) | "stream"
|
|
20
|
-
* maxResponseBytes, // for buffer mode (default 16 MiB control,
|
|
21
|
-
* // 1 GiB GET — operators with > 1 GiB
|
|
22
|
-
* // stored objects must use stream mode)
|
|
23
|
-
* signal, // AbortSignal — propagated to req/stream
|
|
24
|
-
* errorClass, // FrameworkError subclass
|
|
25
|
-
* observer, // optional (stage, info) => void hook
|
|
26
|
-
* agent, // override per-origin pool (h1 only)
|
|
27
|
-
* preferH2, // bool — for cleartext h2 (h2c). HTTPS origins
|
|
28
|
-
* // already attempt h2 via ALPN; this flag is
|
|
29
|
-
* // for HTTP origins (internal services, tests)
|
|
30
|
-
* // that explicitly speak h2c.
|
|
31
|
-
* })
|
|
32
|
-
* → { statusCode, headers, body }
|
|
33
|
-
*
|
|
34
|
-
* Protocol selection:
|
|
35
|
-
*
|
|
36
|
-
* - HTTPS origin: TLS handshake with ALPN ['h2', 'http/1.1']. If
|
|
37
|
-
* server picks 'h2', subsequent requests to that origin multiplex
|
|
38
|
-
* over the same h2 session. If server picks 'h1', the cached
|
|
39
|
-
* transport is an https.Agent with keepAlive.
|
|
40
|
-
*
|
|
41
|
-
* - HTTP origin without preferH2: h1 only.
|
|
42
|
-
* - HTTP origin with preferH2: h2c (cleartext h2). No ALPN — caller
|
|
43
|
-
* attests the server speaks h2c. Used by internal services and
|
|
44
|
-
* test fixtures (mock h2 server).
|
|
45
|
-
*
|
|
46
|
-
* Per-origin transport cache:
|
|
47
|
-
*
|
|
48
|
-
* key = "<protocol>//<hostname>:<port>"
|
|
49
|
-
* value = { kind: 'h1', lib, agent } | { kind: 'h2', session }
|
|
50
|
-
*
|
|
51
|
-
* While a transport is being negotiated (TLS handshake / h2 connect)
|
|
52
|
-
* the cache holds the in-flight Promise so concurrent calls to a
|
|
53
|
-
* new origin coalesce onto the same connection.
|
|
54
|
-
*
|
|
55
|
-
* Resiliency:
|
|
56
|
-
* - Wall-clock + idle timeouts (split — slow-progress vs zero-progress)
|
|
57
|
-
* - AbortSignal propagated to req.destroy / stream.close
|
|
58
|
-
* - TLS 1.3 minimum + PQC ecdhCurve preference
|
|
59
|
-
* - h2 session GOAWAY / error → cache eviction; next request reconnects
|
|
60
|
-
* - h2 stream cancellation via NGHTTP2_CANCEL on abort (clean, not destroy)
|
|
61
|
-
* - Request-body stream errors propagated to Promise rejection
|
|
62
|
-
*/
|
|
63
|
-
|
|
64
|
-
var http = require("http");
|
|
65
|
-
var https = require("https");
|
|
66
|
-
var http2 = require("http2");
|
|
67
|
-
var nodeStream = require("node:stream");
|
|
68
|
-
var { URL } = require("url");
|
|
69
|
-
var C = require("./constants");
|
|
70
|
-
var crypto = require("./crypto");
|
|
71
|
-
var pqcAgent = require("./pqc-agent");
|
|
72
|
-
var safeAsync = require("./safe-async");
|
|
73
|
-
var safeBuffer = require("./safe-buffer");
|
|
74
|
-
var safeUrl = require("./safe-url");
|
|
75
|
-
var ssrfGuard = require("./ssrf-guard");
|
|
76
|
-
var networkProxy = require("./network-proxy");
|
|
77
|
-
var { FrameworkError } = require("./framework-error");
|
|
78
|
-
|
|
79
|
-
// Per-origin transport cache. Entry is either the resolved transport
|
|
80
|
-
// object or a pending Promise that resolves to one. The Promise form
|
|
81
|
-
// lets concurrent calls to a new origin coalesce on the same connect.
|
|
82
|
-
//
|
|
83
|
-
// Transport shapes the cache currently holds:
|
|
84
|
-
//
|
|
85
|
-
// { kind: "h1", lib, agent } — node:http(s) + keepAlive Agent
|
|
86
|
-
// { kind: "h2", session } — node:http2 ClientHttp2Session
|
|
87
|
-
//
|
|
88
|
-
// Reserved for the future (when node:http3 ships stable — currently
|
|
89
|
-
// behind --experimental-quic, no http3 module yet):
|
|
90
|
-
//
|
|
91
|
-
// { kind: "h3", session } — node:http3 ClientHttp3Session
|
|
92
|
-
//
|
|
93
|
-
// Adding the h3 case won't change the caller-facing surface: ALPN
|
|
94
|
-
// negotiation gains "h3" as the highest-preference protocol over QUIC,
|
|
95
|
-
// _getTransport branches on the resolved transport, and a new
|
|
96
|
-
// _requestH3 mirrors _requestH2's stream-based shape. h3's design
|
|
97
|
-
// gives 0-RTT first-class (vs. h1/h2 where 0-RTT is opaque under
|
|
98
|
-
// node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
|
|
99
|
-
var _transports = new Map();
|
|
100
|
-
|
|
101
|
-
// TLS session resumption notes — what's automatic vs. what's not
|
|
102
|
-
// exposed by Node's public API:
|
|
103
|
-
//
|
|
104
|
-
// keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
|
|
105
|
-
// the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
|
|
106
|
-
// We pay the TLS handshake once per origin, then amortize.
|
|
107
|
-
//
|
|
108
|
-
// When a pool socket is recycled, node's tls layer caches session
|
|
109
|
-
// tickets and does 1-RTT resumption automatically. We don't
|
|
110
|
-
// expose 0-RTT (early_data) — node's https.Agent has no clean API
|
|
111
|
-
// for it, and 0-RTT is REPLAY-RISKY for non-idempotent requests
|
|
112
|
-
// (server can't distinguish original from replay until the
|
|
113
|
-
// handshake completes). Operators who need 0-RTT for a specific
|
|
114
|
-
// idempotent path can pass their own agent via opts.agent.
|
|
115
|
-
//
|
|
116
|
-
// QUIC/h3 changes this calculus: 0-RTT is a first-class feature
|
|
117
|
-
// built into the protocol, with replay protection at the QUIC
|
|
118
|
-
// layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
|
|
119
|
-
// operators wanting h3 wire their own client.
|
|
120
|
-
|
|
121
|
-
// Pool tuning for the HTTP-client transport cache. Keep-alive is
|
|
122
|
-
// shorter than the standalone pqc-agent default (1s vs 30s) because
|
|
123
|
-
// the cache layer manages its own warm-connection reuse and we want
|
|
124
|
-
// idle sockets reaped quickly between bursts. ecdhCurve / minVersion
|
|
125
|
-
// come from pqc-agent and cannot be set here — the framework's
|
|
126
|
-
// PQC-only TLS posture is one place, in lib/pqc-agent.js.
|
|
127
|
-
//
|
|
128
|
-
// Operators tune at boot via `b.httpClient.configurePool({...})`.
|
|
129
|
-
// Existing transports stay on whichever values were active when they
|
|
130
|
-
// were created — reconfigure runs before any outbound request to take
|
|
131
|
-
// effect on the per-origin cache.
|
|
132
|
-
var DEFAULT_AGENT_OPTS = Object.freeze({
|
|
133
|
-
keepAlive: true,
|
|
134
|
-
keepAliveMsecs: C.TIME.seconds(1),
|
|
135
|
-
maxSockets: C.BYTES.bytes(16),
|
|
136
|
-
maxFreeSockets: C.BYTES.bytes(8),
|
|
137
|
-
scheduling: "lifo",
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
|
|
141
|
-
|
|
142
|
-
function configurePool(opts) {
|
|
143
|
-
if (!opts || typeof opts !== "object") {
|
|
144
|
-
throw new Error("httpClient.configurePool: opts must be an object");
|
|
145
|
-
}
|
|
146
|
-
var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
|
|
147
|
-
for (var k in opts) {
|
|
148
|
-
if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
|
|
149
|
-
if (allowed.indexOf(k) === -1) {
|
|
150
|
-
throw new Error("httpClient.configurePool: unknown option '" + k +
|
|
151
|
-
"'. Allowed: " + allowed.join(", "));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
function _requirePositiveInt(name, value) {
|
|
155
|
-
if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
156
|
-
throw new Error("httpClient.configurePool: " + name +
|
|
157
|
-
" must be a positive integer, got " + JSON.stringify(value));
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
|
|
161
|
-
if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
|
|
162
|
-
if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
|
|
163
|
-
if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
|
|
164
|
-
throw new Error("httpClient.configurePool: keepAlive must be a boolean");
|
|
165
|
-
}
|
|
166
|
-
if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
|
|
167
|
-
throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
|
|
168
|
-
}
|
|
169
|
-
Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
|
|
170
|
-
// Existing transports keep their old values (Agent constructor
|
|
171
|
-
// copies). Drop the per-origin cache + tear down idle sockets so
|
|
172
|
-
// subsequent requests build fresh transports with the new opts.
|
|
173
|
-
_transports.forEach(function (t) {
|
|
174
|
-
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
175
|
-
try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
|
|
176
|
-
}
|
|
177
|
-
if (t && t.kind === "h2" && t.session) {
|
|
178
|
-
_tearDownH2Session(t.session);
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
_transports.clear();
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// HTTP/2 session teardown — see lib/http2-teardown.js for the full
|
|
185
|
-
// rationale. Centralised so any future sink / pool teardown gets the
|
|
186
|
-
// same close()-then-destroy() discipline.
|
|
187
|
-
var _tearDownH2Session = require("./http2-teardown").tearDownH2Session;
|
|
188
|
-
|
|
189
|
-
// h2 session connect options. Same TLS posture as h1 Agent.
|
|
190
|
-
var DEFAULT_H2_TLS_OPTS = {
|
|
191
|
-
ALPNProtocols: ["h2", "http/1.1"],
|
|
192
|
-
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
193
|
-
minVersion: "TLSv1.3",
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
var DEFAULT_CONTROL_PLANE_CAP = C.BYTES.mib(16);
|
|
197
|
-
var DEFAULT_GET_CAP = C.BYTES.gib(1);
|
|
198
|
-
var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.seconds(30);
|
|
199
|
-
|
|
200
|
-
// h2 session idle close. After this much idle time with no streams,
|
|
201
|
-
// close the session — long-running processes don't pin one TLS
|
|
202
|
-
// connection forever.
|
|
203
|
-
var H2_SESSION_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
|
|
204
|
-
|
|
205
|
-
// IANA-assigned default ports per RFC 9110 §4.2.
|
|
206
|
-
var DEFAULT_HTTPS_PORT = 443;
|
|
207
|
-
var DEFAULT_HTTP_PORT = C.BYTES.bytes(80);
|
|
208
|
-
|
|
209
|
-
function _defaultPortFor(u) {
|
|
210
|
-
return u.protocol === "https:" ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function _originKey(u) {
|
|
214
|
-
return u.protocol + "//" + u.hostname + ":" + (u.port || _defaultPortFor(u));
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function _makeH1Transport(u, ips) {
|
|
218
|
-
var lib = u.protocol === "https:" ? https : http;
|
|
219
|
-
// HTTPS path goes through pqcAgent.create so the framework's PQC-only
|
|
220
|
-
// posture is enforced via the single primitive. Cleartext HTTP stays
|
|
221
|
-
// on http.Agent because there's no TLS posture to enforce.
|
|
222
|
-
var agent = u.protocol === "https:"
|
|
223
|
-
? pqcAgent.create(HTTP_CLIENT_AGENT_OPTS)
|
|
224
|
-
: new lib.Agent(HTTP_CLIENT_AGENT_OPTS);
|
|
225
|
-
return { kind: "h1", lib: lib, agent: agent, lookup: _pinnedLookupFor(ips) };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Build a `lookup` callback that pins outbound connections to IPs the
|
|
229
|
-
// SSRF guard already validated. Closes the TOCTOU window between
|
|
230
|
-
// SSRF resolution and the kernel's connect — without this, a hostile
|
|
231
|
-
// (or compromised) DNS could rotate the answer between guard-check
|
|
232
|
-
// and connect-time and route the request to a private / metadata IP
|
|
233
|
-
// that bypassed the gate. ips comes from `ssrfGuard.checkUrl` — its
|
|
234
|
-
// classification ran on these exact addresses.
|
|
235
|
-
function _pinnedLookupFor(ips) {
|
|
236
|
-
if (!Array.isArray(ips) || ips.length === 0) return undefined;
|
|
237
|
-
var families = ips.map(function (i) { return { address: i.address, family: i.family || 4 }; });
|
|
238
|
-
return function pinnedLookup(hostname, options, callback) {
|
|
239
|
-
if (typeof options === "function") { callback = options; options = {}; }
|
|
240
|
-
options = options || {};
|
|
241
|
-
if (options.all) {
|
|
242
|
-
callback(null, families);
|
|
243
|
-
} else {
|
|
244
|
-
callback(null, families[0].address, families[0].family);
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Connect an h2 session to an HTTPS origin via ALPN. If the server picks
|
|
250
|
-
// http/1.1, fall back to an h1 transport for that origin.
|
|
251
|
-
function _connectHttpsWithAlpn(u, ips) {
|
|
252
|
-
return new Promise(function (resolve, reject) {
|
|
253
|
-
var connectOpts = Object.assign({}, DEFAULT_H2_TLS_OPTS);
|
|
254
|
-
var pinned = _pinnedLookupFor(ips);
|
|
255
|
-
if (pinned) connectOpts.lookup = pinned;
|
|
256
|
-
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
257
|
-
var settled = false;
|
|
258
|
-
function _done(t) { if (!settled) { settled = true; resolve(t); } }
|
|
259
|
-
function _fail(err) { if (!settled) { settled = true; reject(err); } }
|
|
260
|
-
|
|
261
|
-
session.once("connect", function () {
|
|
262
|
-
var alpn = session.alpnProtocol;
|
|
263
|
-
if (alpn === "h2") {
|
|
264
|
-
_wireH2Session(session, _originKey(u));
|
|
265
|
-
_done({ kind: "h2", session: session });
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
// Server picked http/1.1 — close the h2 session, return h1 transport.
|
|
269
|
-
_tearDownH2Session(session);
|
|
270
|
-
_done(_makeH1Transport(u, ips));
|
|
271
|
-
});
|
|
272
|
-
session.once("error", function (err) {
|
|
273
|
-
_tearDownH2Session(session);
|
|
274
|
-
_fail(err);
|
|
275
|
-
});
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Connect an h2c session (cleartext h2). No ALPN, no fallback — caller
|
|
280
|
-
// has attested via preferH2 that the server speaks h2c.
|
|
281
|
-
function _connectH2c(u, ips) {
|
|
282
|
-
return new Promise(function (resolve, reject) {
|
|
283
|
-
var connectOpts = {};
|
|
284
|
-
var pinned = _pinnedLookupFor(ips);
|
|
285
|
-
if (pinned) connectOpts.lookup = pinned;
|
|
286
|
-
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
287
|
-
session.once("connect", function () {
|
|
288
|
-
_wireH2Session(session, _originKey(u));
|
|
289
|
-
resolve({ kind: "h2", session: session });
|
|
290
|
-
});
|
|
291
|
-
session.once("error", function (err) {
|
|
292
|
-
_tearDownH2Session(session);
|
|
293
|
-
reject(err);
|
|
294
|
-
});
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Common h2 session wiring — idle close + cache eviction on error/close.
|
|
299
|
-
function _wireH2Session(session, key) {
|
|
300
|
-
session.setTimeout(H2_SESSION_IDLE_TIMEOUT_MS, function () {
|
|
301
|
-
_tearDownH2Session(session);
|
|
302
|
-
});
|
|
303
|
-
session.once("close", function () { _transports.delete(key); });
|
|
304
|
-
session.once("error", function () { _transports.delete(key); });
|
|
305
|
-
session.once("goaway", function () {
|
|
306
|
-
// Server signalling 'no new streams' — let in-flight finish, evict cache.
|
|
307
|
-
_transports.delete(key);
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// Async transport selection. Returns Promise<transport>. `ips` is the
|
|
312
|
-
// validated address list returned by `ssrfGuard.checkUrl`; the transport
|
|
313
|
-
// uses it to pin connections so a hostile DNS rebind can't redirect
|
|
314
|
-
// the actual TCP connect to a private / metadata IP.
|
|
315
|
-
function _getTransport(u, opts, ips) {
|
|
316
|
-
var key = _originKey(u);
|
|
317
|
-
var cached = _transports.get(key);
|
|
318
|
-
if (cached) {
|
|
319
|
-
// Could be a resolved transport OR a pending Promise. Cached
|
|
320
|
-
// transports keep whatever IP pinning was set when they were
|
|
321
|
-
// first created — subsequent SSRF checks still gate the request,
|
|
322
|
-
// and the transport's TCP socket is bound to its original IP.
|
|
323
|
-
return Promise.resolve(cached);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
var promise;
|
|
327
|
-
if (u.protocol === "https:") {
|
|
328
|
-
promise = _connectHttpsWithAlpn(u, ips);
|
|
329
|
-
} else if (opts && opts.preferH2) {
|
|
330
|
-
promise = _connectH2c(u, ips);
|
|
331
|
-
} else {
|
|
332
|
-
// HTTP without preferH2 → h1 only.
|
|
333
|
-
promise = Promise.resolve(_makeH1Transport(u, ips));
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Cache the in-flight Promise immediately so concurrent calls
|
|
337
|
-
// coalesce. On resolve, replace with the transport. On reject, evict.
|
|
338
|
-
_transports.set(key, promise);
|
|
339
|
-
promise.then(
|
|
340
|
-
function (t) { _transports.set(key, t); },
|
|
341
|
-
function (_err) { _transports.delete(key); }
|
|
342
|
-
);
|
|
343
|
-
|
|
344
|
-
return promise;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function _makeError(errorClass, code, message, permanent, statusCode) {
|
|
348
|
-
if (!errorClass) return new FrameworkError(message, code);
|
|
349
|
-
return new errorClass(code, message, permanent, statusCode);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// RFC 9110 §15.5 4xx codes that are NOT permanent (request-timeout,
|
|
353
|
-
// too-early, too-many-requests — operator should retry).
|
|
354
|
-
var STATUS_REQUEST_TIMEOUT = C.BYTES.bytes(408);
|
|
355
|
-
var STATUS_TOO_EARLY = 425;
|
|
356
|
-
var STATUS_TOO_MANY_REQUESTS = 429;
|
|
357
|
-
|
|
358
|
-
function _isPermanentStatus(statusCode) {
|
|
359
|
-
if (statusCode >= 400 && statusCode < 500) {
|
|
360
|
-
return statusCode !== STATUS_REQUEST_TIMEOUT &&
|
|
361
|
-
statusCode !== STATUS_TOO_EARLY &&
|
|
362
|
-
statusCode !== STATUS_TOO_MANY_REQUESTS;
|
|
363
|
-
}
|
|
364
|
-
return false;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// h2 sends headers as lowercased keys plus :method / :path / :scheme /
|
|
368
|
-
// :authority pseudo-headers. Convert from h1-shaped headers.
|
|
369
|
-
function _toH2Headers(method, u, headers) {
|
|
370
|
-
var h2Headers = Object.create(null);
|
|
371
|
-
h2Headers[":method"] = method;
|
|
372
|
-
h2Headers[":path"] = u.pathname + (u.search || "");
|
|
373
|
-
h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
|
|
374
|
-
h2Headers[":authority"] = u.host;
|
|
375
|
-
for (var k in headers) {
|
|
376
|
-
if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
|
|
377
|
-
var lk = k.toLowerCase();
|
|
378
|
-
// h2 forbids the connection-specific headers
|
|
379
|
-
if (lk === "connection" || lk === "host" ||
|
|
380
|
-
lk === "keep-alive" || lk === "transfer-encoding" ||
|
|
381
|
-
lk === "upgrade" || lk === "proxy-connection") continue;
|
|
382
|
-
h2Headers[lk] = headers[k];
|
|
383
|
-
}
|
|
384
|
-
return h2Headers;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function _fromH2Headers(h2Headers) {
|
|
388
|
-
// Strip pseudo-headers from the response — caller doesn't want them
|
|
389
|
-
// mixed with normal headers.
|
|
390
|
-
var out = {};
|
|
391
|
-
for (var k in h2Headers) {
|
|
392
|
-
if (!Object.prototype.hasOwnProperty.call(h2Headers, k)) continue;
|
|
393
|
-
if (k.charAt(0) === ":") continue;
|
|
394
|
-
out[k] = h2Headers[k];
|
|
395
|
-
}
|
|
396
|
-
return out;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// ---- request() ----
|
|
400
|
-
|
|
401
|
-
var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
402
|
-
|
|
403
|
-
// http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
|
|
404
|
-
// http-client itself only consumes the jar shape passed by operators via
|
|
405
|
-
// the request opt; no direct require is needed here.
|
|
406
|
-
|
|
407
|
-
// Merge a jar-derived Cookie header with any caller-supplied Cookie
|
|
408
|
-
// header. Operators who override Cookie explicitly always win — the jar
|
|
409
|
-
// supplements rather than replaces.
|
|
410
|
-
function _attachJarCookie(headers, jar, url) {
|
|
411
|
-
if (!jar) return headers;
|
|
412
|
-
var jarHeader = jar.cookieHeaderFor(url);
|
|
413
|
-
if (!jarHeader) return headers;
|
|
414
|
-
var merged = Object.assign({}, headers || {});
|
|
415
|
-
var existing = null;
|
|
416
|
-
var keys = Object.keys(merged);
|
|
417
|
-
for (var i = 0; i < keys.length; i++) {
|
|
418
|
-
if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
|
|
419
|
-
}
|
|
420
|
-
if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
|
|
421
|
-
else merged.Cookie = jarHeader;
|
|
422
|
-
return merged;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// Build a multipart/form-data body from { fields, files } shape.
|
|
426
|
-
// Mirrors the wire format that lib/middleware/body-parser.js's multipart
|
|
427
|
-
// parser accepts so round-trip from one blamejs app's outbound to
|
|
428
|
-
// another's inbound is exact.
|
|
429
|
-
function _buildMultipartBody(spec) {
|
|
430
|
-
var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
|
|
431
|
-
var CRLF = "\r\n";
|
|
432
|
-
var parts = [];
|
|
433
|
-
|
|
434
|
-
function _pushField(name, value) {
|
|
435
|
-
if (typeof name !== "string" || name.length === 0) {
|
|
436
|
-
throw new Error("multipart: field name must be a non-empty string");
|
|
437
|
-
}
|
|
438
|
-
var head = "--" + boundary + CRLF +
|
|
439
|
-
'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
|
|
440
|
-
parts.push(Buffer.from(head, "utf8"));
|
|
441
|
-
parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
|
|
442
|
-
parts.push(Buffer.from(CRLF, "utf8"));
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
function _pushFile(file) {
|
|
446
|
-
if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
|
|
447
|
-
if (typeof file.field !== "string" || file.field.length === 0) {
|
|
448
|
-
throw new Error("multipart: file.field must be a non-empty string");
|
|
449
|
-
}
|
|
450
|
-
var filename = typeof file.filename === "string" && file.filename.length > 0
|
|
451
|
-
? file.filename : "blob";
|
|
452
|
-
var mimeType = file.contentType || file.mimeType || "application/octet-stream";
|
|
453
|
-
var content = file.content;
|
|
454
|
-
if (typeof content === "string") content = Buffer.from(content, "utf8");
|
|
455
|
-
if (!Buffer.isBuffer(content)) {
|
|
456
|
-
throw new Error("multipart: file.content must be a Buffer or string");
|
|
457
|
-
}
|
|
458
|
-
var head = "--" + boundary + CRLF +
|
|
459
|
-
'Content-Disposition: form-data; name="' + file.field + '"' +
|
|
460
|
-
'; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
|
|
461
|
-
"Content-Type: " + mimeType + CRLF + CRLF;
|
|
462
|
-
parts.push(Buffer.from(head, "utf8"));
|
|
463
|
-
parts.push(content);
|
|
464
|
-
parts.push(Buffer.from(CRLF, "utf8"));
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
if (spec && spec.fields && typeof spec.fields === "object") {
|
|
468
|
-
var keys = Object.keys(spec.fields);
|
|
469
|
-
for (var i = 0; i < keys.length; i++) {
|
|
470
|
-
var k = keys[i];
|
|
471
|
-
var v = spec.fields[k];
|
|
472
|
-
if (Array.isArray(v)) {
|
|
473
|
-
for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
|
|
474
|
-
} else {
|
|
475
|
-
_pushField(k, v);
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
if (spec && Array.isArray(spec.files)) {
|
|
480
|
-
for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
|
|
481
|
-
}
|
|
482
|
-
parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
|
|
483
|
-
return { boundary: boundary, body: Buffer.concat(parts) };
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Headers stripped on cross-origin redirect to defend against accidental
|
|
487
|
-
// credential exfiltration. Lower-case for header-map comparison.
|
|
488
|
-
var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
|
|
489
|
-
|
|
490
|
-
function _stripCrossOriginAuth(headers) {
|
|
491
|
-
var out = {};
|
|
492
|
-
var keys = Object.keys(headers);
|
|
493
|
-
for (var i = 0; i < keys.length; i++) {
|
|
494
|
-
if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
|
|
495
|
-
out[keys[i]] = headers[keys[i]];
|
|
496
|
-
}
|
|
497
|
-
return out;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function request(opts) {
|
|
501
|
-
if (!opts || !opts.url) {
|
|
502
|
-
return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Validate before/after shapes early — throw at call site if the
|
|
506
|
-
// operator passed something un-callable so the bug surfaces here
|
|
507
|
-
// rather than inside the request loop.
|
|
508
|
-
if (opts.before !== undefined) {
|
|
509
|
-
if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
|
|
510
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
511
|
-
"before must be an array of functions", true));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
if (opts.after !== undefined) {
|
|
515
|
-
if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
|
|
516
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
517
|
-
"after must be an array of functions", true));
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
|
|
521
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
522
|
-
"onUploadProgress must be a function", true));
|
|
523
|
-
}
|
|
524
|
-
if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
|
|
525
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
526
|
-
"onDownloadProgress must be a function", true));
|
|
527
|
-
}
|
|
528
|
-
if (opts.jar !== undefined && opts.jar !== null) {
|
|
529
|
-
if (typeof opts.jar !== "object" ||
|
|
530
|
-
typeof opts.jar.cookieHeaderFor !== "function" ||
|
|
531
|
-
typeof opts.jar.setFromResponse !== "function") {
|
|
532
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
533
|
-
"jar must be a b.httpClient.cookieJar.create() instance", true));
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// before interceptors — run in array order. Each may return a modified
|
|
538
|
-
// opts object (or return nothing to leave the running opts as-is).
|
|
539
|
-
// Caller-set defaults / observability / auth header injection lands
|
|
540
|
-
// here. Synchronous to keep the request hot path simple; async
|
|
541
|
-
// pre-flight work (e.g. token refresh) belongs in the route handler
|
|
542
|
-
// before httpClient.request is even called.
|
|
543
|
-
if (Array.isArray(opts.before) && opts.before.length > 0) {
|
|
544
|
-
var working = opts;
|
|
545
|
-
for (var bi = 0; bi < opts.before.length; bi++) {
|
|
546
|
-
var ret;
|
|
547
|
-
try { ret = opts.before[bi](working); }
|
|
548
|
-
catch (e) {
|
|
549
|
-
return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
|
|
550
|
-
"before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
|
|
551
|
-
}
|
|
552
|
-
if (ret && typeof ret === "object") working = ret;
|
|
553
|
-
}
|
|
554
|
-
opts = working;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Multipart shorthand: { multipart: { fields, files } } expands to
|
|
558
|
-
// body + Content-Type with the boundary parameter. Mutually exclusive
|
|
559
|
-
// with caller-supplied body / Content-Type.
|
|
560
|
-
if (opts.multipart) {
|
|
561
|
-
if (opts.body !== undefined) {
|
|
562
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
563
|
-
"request: pass either { body } or { multipart }, not both", true));
|
|
564
|
-
}
|
|
565
|
-
var built;
|
|
566
|
-
try { built = _buildMultipartBody(opts.multipart); }
|
|
567
|
-
catch (e) {
|
|
568
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
|
|
569
|
-
}
|
|
570
|
-
opts = Object.assign({}, opts, {
|
|
571
|
-
method: opts.method || "POST",
|
|
572
|
-
body: built.body,
|
|
573
|
-
headers: Object.assign({}, opts.headers || {}, {
|
|
574
|
-
"Content-Type": "multipart/form-data; boundary=" + built.boundary,
|
|
575
|
-
"Content-Length": String(built.body.length),
|
|
576
|
-
}),
|
|
577
|
-
multipart: undefined,
|
|
578
|
-
});
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// maxRedirects:
|
|
582
|
-
// undefined → today's behavior (no follow). Caller inspects 3xx.
|
|
583
|
-
// null → today's behavior (explicit). Same as undefined.
|
|
584
|
-
// 0 → no follow, but 3xx returned to caller (alias of null).
|
|
585
|
-
// N → follow up to N hops; 3xx with no Location returned as-is.
|
|
586
|
-
var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
|
|
587
|
-
? null : opts.maxRedirects;
|
|
588
|
-
if (maxRedirects !== null) {
|
|
589
|
-
if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
|
|
590
|
-
Math.floor(maxRedirects) !== maxRedirects) {
|
|
591
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
592
|
-
"maxRedirects must be a non-negative integer or null", true));
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
|
|
596
|
-
function _runAfter(finalOpts, res) {
|
|
597
|
-
if (!afterChain) return res;
|
|
598
|
-
for (var ai = 0; ai < afterChain.length; ai++) {
|
|
599
|
-
try { afterChain[ai](finalOpts, res); }
|
|
600
|
-
catch (_e) { /* after hooks are best-effort — never break the response */ }
|
|
601
|
-
}
|
|
602
|
-
return res;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (maxRedirects === null || maxRedirects === 0) {
|
|
606
|
-
return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
|
|
610
|
-
return _runAfter(boxed.finalOpts, boxed.res);
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function _requestWithRedirects(opts, hopsLeft) {
|
|
615
|
-
var originalUrl = opts.url;
|
|
616
|
-
var originalOrigin = null;
|
|
617
|
-
try {
|
|
618
|
-
var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
619
|
-
originalOrigin = u0.protocol + "//" + u0.host;
|
|
620
|
-
} catch (_e) { /* request() will reject on next hop's parse */ }
|
|
621
|
-
|
|
622
|
-
var current = Object.assign({}, opts, { _resolveOnRedirect: true });
|
|
623
|
-
function _follow() {
|
|
624
|
-
return _requestSingle(current).then(function (res) {
|
|
625
|
-
if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
|
|
626
|
-
return { finalOpts: current, res: res };
|
|
627
|
-
}
|
|
628
|
-
var loc = res.headers && (res.headers.location || res.headers.Location);
|
|
629
|
-
if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
|
|
630
|
-
hopsLeft -= 1;
|
|
631
|
-
|
|
632
|
-
// Resolve relative Location against the just-fetched URL (the URL
|
|
633
|
-
// of the request that produced the redirect, which may itself be a
|
|
634
|
-
// post-redirect URL).
|
|
635
|
-
var nextUrl;
|
|
636
|
-
try {
|
|
637
|
-
// Resolve relative Location against current URL using Node's URL
|
|
638
|
-
// base-URL form. Re-validate the resolved absolute through safeUrl
|
|
639
|
-
// immediately below.
|
|
640
|
-
nextUrl = Reflect.construct(URL, [loc, current.url]).toString();
|
|
641
|
-
}
|
|
642
|
-
catch (_e) {
|
|
643
|
-
return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
|
|
644
|
-
"Location header invalid URL: " + loc, true));
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Cross-origin auth-header strip.
|
|
648
|
-
var nextHeaders = current.headers || {};
|
|
649
|
-
var nextOrigin;
|
|
650
|
-
try {
|
|
651
|
-
var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
652
|
-
nextOrigin = nu.protocol + "//" + nu.host;
|
|
653
|
-
} catch (_e) { /* request() will reject when it tries to parse */ }
|
|
654
|
-
if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
|
|
655
|
-
nextHeaders = _stripCrossOriginAuth(nextHeaders);
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// 303 → always GET; body dropped. 301/302 → historical clients
|
|
659
|
-
// also coerce non-GET bodies (we follow that convention). 307/308
|
|
660
|
-
// → preserve method + body.
|
|
661
|
-
var nextMethod = current.method || "GET";
|
|
662
|
-
var nextBody = current.body;
|
|
663
|
-
if (res.statusCode === 303 ||
|
|
664
|
-
((res.statusCode === 301 || res.statusCode === 302) &&
|
|
665
|
-
nextMethod !== "GET" && nextMethod !== "HEAD")) {
|
|
666
|
-
nextMethod = "GET";
|
|
667
|
-
nextBody = undefined;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
current = Object.assign({}, current, {
|
|
671
|
-
url: nextUrl,
|
|
672
|
-
method: nextMethod,
|
|
673
|
-
body: nextBody,
|
|
674
|
-
headers: nextHeaders,
|
|
675
|
-
_resolveOnRedirect: true,
|
|
676
|
-
});
|
|
677
|
-
return _follow();
|
|
678
|
-
});
|
|
679
|
-
}
|
|
680
|
-
void originalUrl;
|
|
681
|
-
return _follow();
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function _requestSingle(opts) {
|
|
685
|
-
// Validate scheme + shape via url-safe. Default is HTTPS-only — the
|
|
686
|
-
// framework refuses to silently drop bytes on the wire as cleartext.
|
|
687
|
-
// Callers with cleartext endpoints (h2c, internal services, test
|
|
688
|
-
// fixtures) explicitly opt in via opts.allowedProtocols
|
|
689
|
-
// (safeUrl.ALLOW_HTTP_ALL accepts both http: and https:).
|
|
690
|
-
var u;
|
|
691
|
-
try {
|
|
692
|
-
u = safeUrl.parse(opts.url, {
|
|
693
|
-
allowedProtocols: opts.allowedProtocols || safeUrl.ALLOW_HTTP_TLS,
|
|
694
|
-
errorClass: opts.errorClass,
|
|
695
|
-
});
|
|
696
|
-
} catch (e) {
|
|
697
|
-
return Promise.reject(e);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Optional outbound destination allowlist. When opts.allowedHosts
|
|
701
|
-
// is set, only URLs whose hostname is on the list are permitted.
|
|
702
|
-
// Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
|
|
703
|
-
// (IP-class gate) — operators with strict egress policies pin the
|
|
704
|
-
// outbound destinations the app is allowed to talk to so a
|
|
705
|
-
// compromised process can't reach arbitrary upstreams.
|
|
706
|
-
//
|
|
707
|
-
// Entry forms (each entry is a string OR an object):
|
|
708
|
-
// "api.partner.com" — exact host match
|
|
709
|
-
// ".partner.com" — suffix match: "api.partner.com" yes,
|
|
710
|
-
// "evilpartner.com" no
|
|
711
|
-
// "*.partner.com" — same as ".partner.com" (DNS-glob shape
|
|
712
|
-
// operators expect from firewall configs)
|
|
713
|
-
// { host: "api.x.com", methods: ["GET","HEAD"] }
|
|
714
|
-
// — method-restricted entry; methods omitted
|
|
715
|
-
// = any method
|
|
716
|
-
//
|
|
717
|
-
// A disallowed call rejects with HOST_DISALLOWED AND emits an
|
|
718
|
-
// audit event when opts.audit is wired (operator gets a structured
|
|
719
|
-
// signal that the application tried to reach somewhere it shouldn't).
|
|
720
|
-
if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
|
|
721
|
-
var host = u.hostname.toLowerCase();
|
|
722
|
-
var method = (opts.method || "GET").toUpperCase();
|
|
723
|
-
var ok = false;
|
|
724
|
-
for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
|
|
725
|
-
var entry = opts.allowedHosts[ai];
|
|
726
|
-
var allow, allowedMethods = null;
|
|
727
|
-
if (typeof entry === "object" && entry !== null) {
|
|
728
|
-
allow = String(entry.host || "").toLowerCase();
|
|
729
|
-
if (Array.isArray(entry.methods) && entry.methods.length > 0) {
|
|
730
|
-
allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
|
|
731
|
-
}
|
|
732
|
-
} else {
|
|
733
|
-
allow = String(entry || "").toLowerCase();
|
|
734
|
-
}
|
|
735
|
-
if (allow.length === 0) continue;
|
|
736
|
-
// Normalise "*.x.com" to ".x.com" for the suffix match path.
|
|
737
|
-
if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
|
|
738
|
-
var matched = false;
|
|
739
|
-
if (allow.charAt(0) === ".") {
|
|
740
|
-
if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
|
|
741
|
-
} else if (host === allow) {
|
|
742
|
-
matched = true;
|
|
743
|
-
}
|
|
744
|
-
if (!matched) continue;
|
|
745
|
-
if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
|
|
746
|
-
ok = true;
|
|
747
|
-
break;
|
|
748
|
-
}
|
|
749
|
-
if (!ok) {
|
|
750
|
-
if (opts.audit && typeof opts.audit.safeEmit === "function") {
|
|
751
|
-
try {
|
|
752
|
-
opts.audit.safeEmit({
|
|
753
|
-
action: "system.httpclient.host_denied",
|
|
754
|
-
outcome: "denied",
|
|
755
|
-
resource: { kind: "outbound.http", id: host },
|
|
756
|
-
metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
|
|
757
|
-
});
|
|
758
|
-
} catch (_e) { /* audit best-effort */ }
|
|
759
|
-
}
|
|
760
|
-
return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
|
|
761
|
-
"host '" + host + "' not in allowedHosts (method=" + method + ")", true));
|
|
762
|
-
}
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
// Attach jar-derived Cookie header BEFORE the request fires; record
|
|
766
|
-
// Set-Cookie response headers AFTER. Both halves run when opts.jar
|
|
767
|
-
// is set; redirect-following naturally re-runs both paths per hop
|
|
768
|
-
// because each hop calls _requestSingle.
|
|
769
|
-
if (opts.jar) {
|
|
770
|
-
var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
|
|
771
|
-
if (headersWithJar !== opts.headers) {
|
|
772
|
-
opts = Object.assign({}, opts, { headers: headersWithJar });
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
777
|
-
// / reserved IP destinations by default. The returned `ips` are
|
|
778
|
-
// threaded into transport creation so the actual TCP connect pins
|
|
779
|
-
// to those exact addresses, closing the DNS-rebinding TOCTOU window.
|
|
780
|
-
return ssrfGuard.checkUrl(u, {
|
|
781
|
-
allowInternal: opts.allowInternal,
|
|
782
|
-
errorClass: opts.errorClass,
|
|
783
|
-
}).then(function (ssrfResult) {
|
|
784
|
-
var ips = ssrfResult && ssrfResult.ips;
|
|
785
|
-
// Caller-supplied agent bypasses transport cache (h1 only). The
|
|
786
|
-
// operator owns the agent's connection pool — we still pass the
|
|
787
|
-
// pinned lookup through per-request so the SSRF check's IPs win.
|
|
788
|
-
if (opts.agent) {
|
|
789
|
-
return _requestH1({
|
|
790
|
-
kind: "h1",
|
|
791
|
-
lib: u.protocol === "https:" ? https : http,
|
|
792
|
-
agent: opts.agent,
|
|
793
|
-
lookup: _pinnedLookupFor(ips),
|
|
794
|
-
}, u, opts);
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
var proxyAgent = null;
|
|
798
|
-
try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
|
|
799
|
-
if (proxyAgent) {
|
|
800
|
-
return _requestH1({
|
|
801
|
-
kind: "h1",
|
|
802
|
-
lib: u.protocol === "https:" ? https : http,
|
|
803
|
-
agent: proxyAgent,
|
|
804
|
-
lookup: undefined,
|
|
805
|
-
}, u, opts);
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
return _getTransport(u, opts, ips).then(function (transport) {
|
|
809
|
-
if (transport.kind === "h2") return _requestH2(transport, u, opts);
|
|
810
|
-
return _requestH1(transport, u, opts);
|
|
811
|
-
});
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
// ---- _requestH1: existing node:http(s) path ----
|
|
816
|
-
|
|
817
|
-
function _requestH1(transport, u, opts) {
|
|
818
|
-
return new Promise(function (resolve, reject) {
|
|
819
|
-
var method = (opts.method || "GET").toUpperCase();
|
|
820
|
-
var headers = Object.assign({}, opts.headers || {});
|
|
821
|
-
var responseMode = opts.responseMode || "buffer";
|
|
822
|
-
var maxResponseBytes = opts.maxResponseBytes ||
|
|
823
|
-
(method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
|
|
824
|
-
var observer = typeof opts.observer === "function" ? opts.observer : null;
|
|
825
|
-
var startedAt = Date.now();
|
|
826
|
-
|
|
827
|
-
var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
|
|
828
|
-
if (signal && signal.aborted) {
|
|
829
|
-
var r0 = signal.reason;
|
|
830
|
-
var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
831
|
-
reject(_makeError(opts.errorClass, code0,
|
|
832
|
-
(r0 && r0.message) || "request aborted before start", false));
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
if (Buffer.isBuffer(opts.body)) {
|
|
837
|
-
headers["Content-Length"] = opts.body.length;
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
var reqOpts = {
|
|
841
|
-
method: method,
|
|
842
|
-
hostname: u.hostname,
|
|
843
|
-
port: u.port || _defaultPortFor(u),
|
|
844
|
-
path: u.pathname + (u.search || ""),
|
|
845
|
-
headers: headers,
|
|
846
|
-
agent: transport.agent,
|
|
847
|
-
timeout: typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS,
|
|
848
|
-
};
|
|
849
|
-
// Pin DNS to the IPs the SSRF guard validated. Closes the
|
|
850
|
-
// rebinding TOCTOU between guard-check and actual TCP connect.
|
|
851
|
-
if (transport.lookup) reqOpts.lookup = transport.lookup;
|
|
852
|
-
|
|
853
|
-
if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h1" });
|
|
854
|
-
|
|
855
|
-
var settled = false;
|
|
856
|
-
function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
|
|
857
|
-
function _reject(err) { if (!settled) { settled = true; reject(err); } }
|
|
858
|
-
|
|
859
|
-
var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
|
|
860
|
-
var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
|
|
861
|
-
|
|
862
|
-
var req = transport.lib.request(reqOpts, function (res) {
|
|
863
|
-
if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
|
|
864
|
-
|
|
865
|
-
// Save Set-Cookie into the jar (if wired) BEFORE delivering the
|
|
866
|
-
// response object — operator inspecting the response can already
|
|
867
|
-
// count on the jar carrying the new state.
|
|
868
|
-
if (opts.jar && res.headers && res.headers["set-cookie"]) {
|
|
869
|
-
try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
|
|
870
|
-
catch (_e) { /* jar is best-effort — never break the response */ }
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
// Download total: Content-Length when present, null otherwise.
|
|
874
|
-
var dlTotal = null;
|
|
875
|
-
if (res.headers && typeof res.headers["content-length"] === "string") {
|
|
876
|
-
var cl = parseInt(res.headers["content-length"], 10);
|
|
877
|
-
if (!isNaN(cl) && cl >= 0) dlTotal = cl;
|
|
878
|
-
}
|
|
879
|
-
var dlLoaded = 0;
|
|
880
|
-
function _emitDownload(chunkBytes) {
|
|
881
|
-
if (!onDownloadProgress) return;
|
|
882
|
-
dlLoaded += chunkBytes;
|
|
883
|
-
try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
|
|
884
|
-
catch (_e) { /* progress hooks are best-effort */ }
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
if (responseMode === "stream") {
|
|
888
|
-
if (res.statusCode >= 400) {
|
|
889
|
-
res.resume();
|
|
890
|
-
return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
|
|
891
|
-
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
892
|
-
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
893
|
-
}
|
|
894
|
-
if (onDownloadProgress) {
|
|
895
|
-
// Wrap the stream so chunks emit progress to the operator.
|
|
896
|
-
// The framework's contract is to hand back the response stream
|
|
897
|
-
// unmodified; fix-up via a passthrough keeps that contract while
|
|
898
|
-
// observing the chunk sizes.
|
|
899
|
-
var passthrough = new nodeStream.PassThrough();
|
|
900
|
-
res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
|
|
901
|
-
res.on("end", function () { passthrough.end(); });
|
|
902
|
-
res.on("error", function (e) { passthrough.destroy(e); });
|
|
903
|
-
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
|
|
904
|
-
}
|
|
905
|
-
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
|
|
909
|
-
var capExceeded = false;
|
|
910
|
-
|
|
911
|
-
res.on("data", function (chunk) {
|
|
912
|
-
if (capExceeded) return;
|
|
913
|
-
try { collector.push(chunk); }
|
|
914
|
-
catch (_e) {
|
|
915
|
-
capExceeded = true;
|
|
916
|
-
req.destroy();
|
|
917
|
-
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
918
|
-
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
_emitDownload(chunk.length);
|
|
922
|
-
});
|
|
923
|
-
res.on("end", function () {
|
|
924
|
-
if (capExceeded) return;
|
|
925
|
-
var buf = collector.result();
|
|
926
|
-
if (observer) observer("response:end", {
|
|
927
|
-
statusCode: res.statusCode,
|
|
928
|
-
durationMs: Date.now() - startedAt,
|
|
929
|
-
bytes: buf.length,
|
|
930
|
-
});
|
|
931
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
932
|
-
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
933
|
-
} else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
|
|
934
|
-
// Redirect-following layer needs the response object intact so
|
|
935
|
-
// it can inspect Location and re-issue. The caller-facing
|
|
936
|
-
// request() never sets _resolveOnRedirect — operator code that
|
|
937
|
-
// didn't ask for redirect-following keeps seeing 3xx as errors.
|
|
938
|
-
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
939
|
-
} else {
|
|
940
|
-
var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
|
|
941
|
-
_reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
|
|
942
|
-
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
943
|
-
}
|
|
944
|
-
});
|
|
945
|
-
res.on("error", function (e) {
|
|
946
|
-
if (capExceeded) return;
|
|
947
|
-
if (observer) observer("error", { phase: "response", message: e.message });
|
|
948
|
-
_reject(_makeError(opts.errorClass, e.code || "RES_ERROR", e.message, false));
|
|
949
|
-
});
|
|
950
|
-
});
|
|
951
|
-
|
|
952
|
-
req.on("timeout", function () {
|
|
953
|
-
req.destroy();
|
|
954
|
-
_reject(_makeError(opts.errorClass, "ETIMEDOUT",
|
|
955
|
-
"request idle timeout (no data for " + reqOpts.timeout + "ms)", false));
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
req.on("error", function (e) {
|
|
959
|
-
if (observer) observer("error", { phase: "request", message: e.message });
|
|
960
|
-
_reject(_makeError(opts.errorClass, e.code || "REQ_ERROR", e.message, false));
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
if (signal) {
|
|
964
|
-
var onAbort = function () {
|
|
965
|
-
var r = signal.reason;
|
|
966
|
-
var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
967
|
-
var msg = (r && r.message) || "request aborted";
|
|
968
|
-
try { req.destroy(r || new Error(msg)); } catch (_e) { /* best-effort req teardown */ }
|
|
969
|
-
_reject(_makeError(opts.errorClass, code, msg, false));
|
|
970
|
-
};
|
|
971
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// Upload progress: emit { loaded, total } as body bytes go to the
|
|
975
|
-
// wire. Buffer / string bodies are sliced into chunks ourselves so
|
|
976
|
-
// operators see incremental progress; Readable bodies emit on each
|
|
977
|
-
// 'data' event from the source stream.
|
|
978
|
-
var ulTotal = null;
|
|
979
|
-
if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
|
|
980
|
-
else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
|
|
981
|
-
var ulLoaded = 0;
|
|
982
|
-
function _emitUpload(chunkBytes) {
|
|
983
|
-
if (!onUploadProgress) return;
|
|
984
|
-
ulLoaded += chunkBytes;
|
|
985
|
-
try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
|
|
986
|
-
catch (_e) { /* progress hooks are best-effort */ }
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
if (opts.body && typeof opts.body.pipe === "function") {
|
|
990
|
-
if (onUploadProgress) {
|
|
991
|
-
opts.body.on("data", function (c) { _emitUpload(c.length); });
|
|
992
|
-
}
|
|
993
|
-
opts.body.on("error", function (e) {
|
|
994
|
-
try { req.destroy(); } catch (_) { /* best-effort req teardown */ }
|
|
995
|
-
_reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
|
|
996
|
-
"request body stream error: " + e.message, false));
|
|
997
|
-
});
|
|
998
|
-
opts.body.pipe(req);
|
|
999
|
-
} else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
|
|
1000
|
-
var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
|
|
1001
|
-
if (onUploadProgress) {
|
|
1002
|
-
// Chunked write so progress reports land before req.end().
|
|
1003
|
-
var CHUNK = C.BYTES.kib(64);
|
|
1004
|
-
var off = 0;
|
|
1005
|
-
while (off < bodyBuf.length) {
|
|
1006
|
-
var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
|
|
1007
|
-
req.write(slice);
|
|
1008
|
-
_emitUpload(slice.length);
|
|
1009
|
-
off += slice.length;
|
|
1010
|
-
}
|
|
1011
|
-
req.end();
|
|
1012
|
-
} else {
|
|
1013
|
-
req.end(bodyBuf);
|
|
1014
|
-
}
|
|
1015
|
-
} else {
|
|
1016
|
-
req.end();
|
|
1017
|
-
}
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
// ---- _requestH2: node:http2 path ----
|
|
1022
|
-
|
|
1023
|
-
function _requestH2(transport, u, opts) {
|
|
1024
|
-
return new Promise(function (resolve, reject) {
|
|
1025
|
-
var method = (opts.method || "GET").toUpperCase();
|
|
1026
|
-
var responseMode = opts.responseMode || "buffer";
|
|
1027
|
-
var maxResponseBytes = opts.maxResponseBytes ||
|
|
1028
|
-
(method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
|
|
1029
|
-
var observer = typeof opts.observer === "function" ? opts.observer : null;
|
|
1030
|
-
var startedAt = Date.now();
|
|
1031
|
-
|
|
1032
|
-
var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
|
|
1033
|
-
if (signal && signal.aborted) {
|
|
1034
|
-
var r0 = signal.reason;
|
|
1035
|
-
var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
1036
|
-
reject(_makeError(opts.errorClass, code0,
|
|
1037
|
-
(r0 && r0.message) || "request aborted before start", false));
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
var headers = _toH2Headers(method, u, opts.headers || {});
|
|
1042
|
-
if (Buffer.isBuffer(opts.body)) headers["content-length"] = String(opts.body.length);
|
|
1043
|
-
|
|
1044
|
-
if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h2" });
|
|
1045
|
-
|
|
1046
|
-
var stream;
|
|
1047
|
-
try {
|
|
1048
|
-
stream = transport.session.request(headers, {
|
|
1049
|
-
endStream: opts.body == null,
|
|
1050
|
-
});
|
|
1051
|
-
} catch (e) {
|
|
1052
|
-
reject(_makeError(opts.errorClass, e.code || "H2_REQUEST_ERROR", e.message, false));
|
|
1053
|
-
return;
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
var settled = false;
|
|
1057
|
-
function _resolve(v) { if (!settled) { settled = true; resolve(v); } }
|
|
1058
|
-
function _reject(e) { if (!settled) { settled = true; reject(e); } }
|
|
1059
|
-
|
|
1060
|
-
// Idle timeout for the stream itself (zero-progress detector).
|
|
1061
|
-
var idleMs = typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
|
|
1062
|
-
stream.setTimeout(idleMs, function () {
|
|
1063
|
-
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
|
|
1064
|
-
_reject(_makeError(opts.errorClass, "ETIMEDOUT",
|
|
1065
|
-
"h2 stream idle timeout (no data for " + idleMs + "ms)", false));
|
|
1066
|
-
});
|
|
1067
|
-
|
|
1068
|
-
stream.on("response", function (resHeaders) {
|
|
1069
|
-
var statusCode = resHeaders[":status"];
|
|
1070
|
-
var responseHeaders = _fromH2Headers(resHeaders);
|
|
1071
|
-
|
|
1072
|
-
if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
|
|
1073
|
-
|
|
1074
|
-
// Save Set-Cookie to the jar (h2 set-cookie comes through as
|
|
1075
|
-
// either a single string or array, same shape as h1).
|
|
1076
|
-
if (opts.jar && responseHeaders["set-cookie"]) {
|
|
1077
|
-
try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
|
|
1078
|
-
catch (_e) { /* jar best-effort */ }
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
if (responseMode === "stream") {
|
|
1082
|
-
if (statusCode >= 400) {
|
|
1083
|
-
stream.resume();
|
|
1084
|
-
return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
|
|
1085
|
-
"HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
|
|
1086
|
-
}
|
|
1087
|
-
return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
|
|
1091
|
-
var capExceeded = false;
|
|
1092
|
-
|
|
1093
|
-
stream.on("data", function (chunk) {
|
|
1094
|
-
if (capExceeded) return;
|
|
1095
|
-
try { collector.push(chunk); }
|
|
1096
|
-
catch (_e) {
|
|
1097
|
-
capExceeded = true;
|
|
1098
|
-
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
|
|
1099
|
-
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
1100
|
-
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
1101
|
-
}
|
|
1102
|
-
});
|
|
1103
|
-
stream.on("end", function () {
|
|
1104
|
-
if (capExceeded) return;
|
|
1105
|
-
var buf = collector.result();
|
|
1106
|
-
if (observer) observer("response:end", {
|
|
1107
|
-
statusCode: statusCode,
|
|
1108
|
-
durationMs: Date.now() - startedAt,
|
|
1109
|
-
bytes: buf.length,
|
|
1110
|
-
});
|
|
1111
|
-
if (statusCode >= 200 && statusCode < 300) {
|
|
1112
|
-
_resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
|
|
1113
|
-
} else {
|
|
1114
|
-
var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
|
|
1115
|
-
_reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
|
|
1116
|
-
_isPermanentStatus(statusCode), statusCode));
|
|
1117
|
-
}
|
|
1118
|
-
});
|
|
1119
|
-
});
|
|
1120
|
-
|
|
1121
|
-
stream.on("error", function (e) {
|
|
1122
|
-
if (observer) observer("error", { phase: "stream", message: e.message });
|
|
1123
|
-
_reject(_makeError(opts.errorClass, e.code || "H2_STREAM_ERROR", e.message, false));
|
|
1124
|
-
});
|
|
1125
|
-
|
|
1126
|
-
if (signal) {
|
|
1127
|
-
var onAbort = function () {
|
|
1128
|
-
var r = signal.reason;
|
|
1129
|
-
var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
1130
|
-
var msg = (r && r.message) || "request aborted";
|
|
1131
|
-
// NGHTTP2_CANCEL is the protocol-level "I gave up" signal —
|
|
1132
|
-
// cleaner than destroying the stream.
|
|
1133
|
-
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
|
|
1134
|
-
_reject(_makeError(opts.errorClass, code, msg, false));
|
|
1135
|
-
};
|
|
1136
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
if (opts.body && typeof opts.body.pipe === "function") {
|
|
1140
|
-
opts.body.on("error", function (e) {
|
|
1141
|
-
try { stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR); } catch (_) { /* best-effort h2 stream cancel */ }
|
|
1142
|
-
_reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
|
|
1143
|
-
"request body stream error: " + e.message, false));
|
|
1144
|
-
});
|
|
1145
|
-
opts.body.pipe(stream);
|
|
1146
|
-
} else if (Buffer.isBuffer(opts.body)) {
|
|
1147
|
-
stream.end(opts.body);
|
|
1148
|
-
} else if (typeof opts.body === "string") {
|
|
1149
|
-
stream.end(Buffer.from(opts.body, "utf8"));
|
|
1150
|
-
}
|
|
1151
|
-
// If body is null/undefined, endStream:true was set in session.request()
|
|
1152
|
-
});
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
// ---- Test helpers ----
|
|
1156
|
-
|
|
1157
|
-
function _resetForTest() {
|
|
1158
|
-
_transports.forEach(function (t) {
|
|
1159
|
-
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
1160
|
-
try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
|
|
1161
|
-
}
|
|
1162
|
-
if (t && t.kind === "h2" && t.session) {
|
|
1163
|
-
_tearDownH2Session(t.session);
|
|
1164
|
-
}
|
|
1165
|
-
});
|
|
1166
|
-
_transports.clear();
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
function _getCachedTransportCount() {
|
|
1170
|
-
return _transports.size;
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
// Diagnostic — returns 'h1' | 'h2' | null for a given URL's cached transport.
|
|
1174
|
-
function _getCachedTransportKind(url) {
|
|
1175
|
-
var u = url instanceof URL ? url : safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
1176
|
-
var t = _transports.get(_originKey(u));
|
|
1177
|
-
if (!t) return null;
|
|
1178
|
-
if (t.then) return "pending";
|
|
1179
|
-
return t.kind;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
module.exports = {
|
|
1183
|
-
request: request,
|
|
1184
|
-
configurePool: configurePool,
|
|
1185
|
-
DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
|
|
1186
|
-
DEFAULT_GET_CAP: DEFAULT_GET_CAP,
|
|
1187
|
-
DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
|
|
1188
|
-
_resetForTest: _resetForTest,
|
|
1189
|
-
_getCachedTransportCount: _getCachedTransportCount,
|
|
1190
|
-
_getCachedTransportKind: _getCachedTransportKind,
|
|
1191
|
-
// Test-only — exposes the SSRF-pinned DNS lookup builder so unit
|
|
1192
|
-
// tests can confirm the callback shape matches Node's documented
|
|
1193
|
-
// `lookup(hostname, options, callback)` contract.
|
|
1194
|
-
_pinnedLookupForTest: _pinnedLookupFor,
|
|
1195
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* HTTP client primitive — Promise-returning, AbortSignal-aware,
|
|
4
|
+
* connection-pooled, streaming-capable, HTTP/2-capable.
|
|
5
|
+
*
|
|
6
|
+
* Built on node:http, node:https, and node:http2. Zero npm runtime
|
|
7
|
+
* dependency. Same caller surface for h1 and h2; the protocol version
|
|
8
|
+
* is negotiated per-origin via ALPN (h2 preferred, h1 fallback).
|
|
9
|
+
*
|
|
10
|
+
* Single entry point:
|
|
11
|
+
*
|
|
12
|
+
* await httpClient.request({
|
|
13
|
+
* method, // string, default GET
|
|
14
|
+
* url, // string or URL
|
|
15
|
+
* headers, // object, default {}
|
|
16
|
+
* body, // Buffer | string | Readable | undefined
|
|
17
|
+
* timeoutMs, // wall-clock cap (caller-chosen, no default)
|
|
18
|
+
* idleTimeoutMs, // zero-progress idle cap (default 30s)
|
|
19
|
+
* responseMode, // "buffer" (default) | "stream"
|
|
20
|
+
* maxResponseBytes, // for buffer mode (default 16 MiB control,
|
|
21
|
+
* // 1 GiB GET — operators with > 1 GiB
|
|
22
|
+
* // stored objects must use stream mode)
|
|
23
|
+
* signal, // AbortSignal — propagated to req/stream
|
|
24
|
+
* errorClass, // FrameworkError subclass
|
|
25
|
+
* observer, // optional (stage, info) => void hook
|
|
26
|
+
* agent, // override per-origin pool (h1 only)
|
|
27
|
+
* preferH2, // bool — for cleartext h2 (h2c). HTTPS origins
|
|
28
|
+
* // already attempt h2 via ALPN; this flag is
|
|
29
|
+
* // for HTTP origins (internal services, tests)
|
|
30
|
+
* // that explicitly speak h2c.
|
|
31
|
+
* })
|
|
32
|
+
* → { statusCode, headers, body }
|
|
33
|
+
*
|
|
34
|
+
* Protocol selection:
|
|
35
|
+
*
|
|
36
|
+
* - HTTPS origin: TLS handshake with ALPN ['h2', 'http/1.1']. If
|
|
37
|
+
* server picks 'h2', subsequent requests to that origin multiplex
|
|
38
|
+
* over the same h2 session. If server picks 'h1', the cached
|
|
39
|
+
* transport is an https.Agent with keepAlive.
|
|
40
|
+
*
|
|
41
|
+
* - HTTP origin without preferH2: h1 only.
|
|
42
|
+
* - HTTP origin with preferH2: h2c (cleartext h2). No ALPN — caller
|
|
43
|
+
* attests the server speaks h2c. Used by internal services and
|
|
44
|
+
* test fixtures (mock h2 server).
|
|
45
|
+
*
|
|
46
|
+
* Per-origin transport cache:
|
|
47
|
+
*
|
|
48
|
+
* key = "<protocol>//<hostname>:<port>"
|
|
49
|
+
* value = { kind: 'h1', lib, agent } | { kind: 'h2', session }
|
|
50
|
+
*
|
|
51
|
+
* While a transport is being negotiated (TLS handshake / h2 connect)
|
|
52
|
+
* the cache holds the in-flight Promise so concurrent calls to a
|
|
53
|
+
* new origin coalesce onto the same connection.
|
|
54
|
+
*
|
|
55
|
+
* Resiliency:
|
|
56
|
+
* - Wall-clock + idle timeouts (split — slow-progress vs zero-progress)
|
|
57
|
+
* - AbortSignal propagated to req.destroy / stream.close
|
|
58
|
+
* - TLS 1.3 minimum + PQC ecdhCurve preference
|
|
59
|
+
* - h2 session GOAWAY / error → cache eviction; next request reconnects
|
|
60
|
+
* - h2 stream cancellation via NGHTTP2_CANCEL on abort (clean, not destroy)
|
|
61
|
+
* - Request-body stream errors propagated to Promise rejection
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
var http = require("http");
|
|
65
|
+
var https = require("https");
|
|
66
|
+
var http2 = require("http2");
|
|
67
|
+
var nodeStream = require("node:stream");
|
|
68
|
+
var { URL } = require("url");
|
|
69
|
+
var C = require("./constants");
|
|
70
|
+
var crypto = require("./crypto");
|
|
71
|
+
var pqcAgent = require("./pqc-agent");
|
|
72
|
+
var safeAsync = require("./safe-async");
|
|
73
|
+
var safeBuffer = require("./safe-buffer");
|
|
74
|
+
var safeUrl = require("./safe-url");
|
|
75
|
+
var ssrfGuard = require("./ssrf-guard");
|
|
76
|
+
var networkProxy = require("./network-proxy");
|
|
77
|
+
var { FrameworkError } = require("./framework-error");
|
|
78
|
+
|
|
79
|
+
// Per-origin transport cache. Entry is either the resolved transport
|
|
80
|
+
// object or a pending Promise that resolves to one. The Promise form
|
|
81
|
+
// lets concurrent calls to a new origin coalesce on the same connect.
|
|
82
|
+
//
|
|
83
|
+
// Transport shapes the cache currently holds:
|
|
84
|
+
//
|
|
85
|
+
// { kind: "h1", lib, agent } — node:http(s) + keepAlive Agent
|
|
86
|
+
// { kind: "h2", session } — node:http2 ClientHttp2Session
|
|
87
|
+
//
|
|
88
|
+
// Reserved for the future (when node:http3 ships stable — currently
|
|
89
|
+
// behind --experimental-quic, no http3 module yet):
|
|
90
|
+
//
|
|
91
|
+
// { kind: "h3", session } — node:http3 ClientHttp3Session
|
|
92
|
+
//
|
|
93
|
+
// Adding the h3 case won't change the caller-facing surface: ALPN
|
|
94
|
+
// negotiation gains "h3" as the highest-preference protocol over QUIC,
|
|
95
|
+
// _getTransport branches on the resolved transport, and a new
|
|
96
|
+
// _requestH3 mirrors _requestH2's stream-based shape. h3's design
|
|
97
|
+
// gives 0-RTT first-class (vs. h1/h2 where 0-RTT is opaque under
|
|
98
|
+
// node's TLS layer — see TLS_SESSION_RESUMPTION_NOTES below).
|
|
99
|
+
var _transports = new Map();
|
|
100
|
+
|
|
101
|
+
// TLS session resumption notes — what's automatic vs. what's not
|
|
102
|
+
// exposed by Node's public API:
|
|
103
|
+
//
|
|
104
|
+
// keepAlive Agent (h1) / long-lived ClientHttp2Session (h2) means
|
|
105
|
+
// the WARM-CONNECTION case is zero-handshake — better than 0-RTT.
|
|
106
|
+
// We pay the TLS handshake once per origin, then amortize.
|
|
107
|
+
//
|
|
108
|
+
// When a pool socket is recycled, node's tls layer caches session
|
|
109
|
+
// tickets and does 1-RTT resumption automatically. We don't
|
|
110
|
+
// expose 0-RTT (early_data) — node's https.Agent has no clean API
|
|
111
|
+
// for it, and 0-RTT is REPLAY-RISKY for non-idempotent requests
|
|
112
|
+
// (server can't distinguish original from replay until the
|
|
113
|
+
// handshake completes). Operators who need 0-RTT for a specific
|
|
114
|
+
// idempotent path can pass their own agent via opts.agent.
|
|
115
|
+
//
|
|
116
|
+
// QUIC/h3 changes this calculus: 0-RTT is a first-class feature
|
|
117
|
+
// built into the protocol, with replay protection at the QUIC
|
|
118
|
+
// layer. The framework's `b.httpClient` is HTTP/1.1 + HTTP/2 only;
|
|
119
|
+
// operators wanting h3 wire their own client.
|
|
120
|
+
|
|
121
|
+
// Pool tuning for the HTTP-client transport cache. Keep-alive is
|
|
122
|
+
// shorter than the standalone pqc-agent default (1s vs 30s) because
|
|
123
|
+
// the cache layer manages its own warm-connection reuse and we want
|
|
124
|
+
// idle sockets reaped quickly between bursts. ecdhCurve / minVersion
|
|
125
|
+
// come from pqc-agent and cannot be set here — the framework's
|
|
126
|
+
// PQC-only TLS posture is one place, in lib/pqc-agent.js.
|
|
127
|
+
//
|
|
128
|
+
// Operators tune at boot via `b.httpClient.configurePool({...})`.
|
|
129
|
+
// Existing transports stay on whichever values were active when they
|
|
130
|
+
// were created — reconfigure runs before any outbound request to take
|
|
131
|
+
// effect on the per-origin cache.
|
|
132
|
+
var DEFAULT_AGENT_OPTS = Object.freeze({
|
|
133
|
+
keepAlive: true,
|
|
134
|
+
keepAliveMsecs: C.TIME.seconds(1),
|
|
135
|
+
maxSockets: C.BYTES.bytes(16),
|
|
136
|
+
maxFreeSockets: C.BYTES.bytes(8),
|
|
137
|
+
scheduling: "lifo",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
var HTTP_CLIENT_AGENT_OPTS = Object.assign({}, DEFAULT_AGENT_OPTS);
|
|
141
|
+
|
|
142
|
+
function configurePool(opts) {
|
|
143
|
+
if (!opts || typeof opts !== "object") {
|
|
144
|
+
throw new Error("httpClient.configurePool: opts must be an object");
|
|
145
|
+
}
|
|
146
|
+
var allowed = ["keepAlive", "keepAliveMsecs", "maxSockets", "maxFreeSockets", "scheduling"];
|
|
147
|
+
for (var k in opts) {
|
|
148
|
+
if (!Object.prototype.hasOwnProperty.call(opts, k)) continue;
|
|
149
|
+
if (allowed.indexOf(k) === -1) {
|
|
150
|
+
throw new Error("httpClient.configurePool: unknown option '" + k +
|
|
151
|
+
"'. Allowed: " + allowed.join(", "));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function _requirePositiveInt(name, value) {
|
|
155
|
+
if (typeof value !== "number" || !isFinite(value) || value <= 0 || Math.floor(value) !== value) {
|
|
156
|
+
throw new Error("httpClient.configurePool: " + name +
|
|
157
|
+
" must be a positive integer, got " + JSON.stringify(value));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (opts.maxSockets !== undefined) _requirePositiveInt("maxSockets", opts.maxSockets);
|
|
161
|
+
if (opts.maxFreeSockets !== undefined) _requirePositiveInt("maxFreeSockets", opts.maxFreeSockets);
|
|
162
|
+
if (opts.keepAliveMsecs !== undefined) _requirePositiveInt("keepAliveMsecs", opts.keepAliveMsecs);
|
|
163
|
+
if (opts.keepAlive !== undefined && typeof opts.keepAlive !== "boolean") {
|
|
164
|
+
throw new Error("httpClient.configurePool: keepAlive must be a boolean");
|
|
165
|
+
}
|
|
166
|
+
if (opts.scheduling !== undefined && opts.scheduling !== "lifo" && opts.scheduling !== "fifo") {
|
|
167
|
+
throw new Error("httpClient.configurePool: scheduling must be 'lifo' or 'fifo'");
|
|
168
|
+
}
|
|
169
|
+
Object.assign(HTTP_CLIENT_AGENT_OPTS, opts);
|
|
170
|
+
// Existing transports keep their old values (Agent constructor
|
|
171
|
+
// copies). Drop the per-origin cache + tear down idle sockets so
|
|
172
|
+
// subsequent requests build fresh transports with the new opts.
|
|
173
|
+
_transports.forEach(function (t) {
|
|
174
|
+
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
175
|
+
try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
|
|
176
|
+
}
|
|
177
|
+
if (t && t.kind === "h2" && t.session) {
|
|
178
|
+
_tearDownH2Session(t.session);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
_transports.clear();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// HTTP/2 session teardown — see lib/http2-teardown.js for the full
|
|
185
|
+
// rationale. Centralised so any future sink / pool teardown gets the
|
|
186
|
+
// same close()-then-destroy() discipline.
|
|
187
|
+
var _tearDownH2Session = require("./http2-teardown").tearDownH2Session;
|
|
188
|
+
|
|
189
|
+
// h2 session connect options. Same TLS posture as h1 Agent.
|
|
190
|
+
var DEFAULT_H2_TLS_OPTS = {
|
|
191
|
+
ALPNProtocols: ["h2", "http/1.1"],
|
|
192
|
+
ecdhCurve: C.TLS_GROUP_CURVE_STR,
|
|
193
|
+
minVersion: "TLSv1.3",
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
var DEFAULT_CONTROL_PLANE_CAP = C.BYTES.mib(16);
|
|
197
|
+
var DEFAULT_GET_CAP = C.BYTES.gib(1);
|
|
198
|
+
var DEFAULT_IDLE_TIMEOUT_MS = C.TIME.seconds(30);
|
|
199
|
+
|
|
200
|
+
// h2 session idle close. After this much idle time with no streams,
|
|
201
|
+
// close the session — long-running processes don't pin one TLS
|
|
202
|
+
// connection forever.
|
|
203
|
+
var H2_SESSION_IDLE_TIMEOUT_MS = C.TIME.minutes(5);
|
|
204
|
+
|
|
205
|
+
// IANA-assigned default ports per RFC 9110 §4.2.
|
|
206
|
+
var DEFAULT_HTTPS_PORT = 443;
|
|
207
|
+
var DEFAULT_HTTP_PORT = C.BYTES.bytes(80);
|
|
208
|
+
|
|
209
|
+
function _defaultPortFor(u) {
|
|
210
|
+
return u.protocol === "https:" ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function _originKey(u) {
|
|
214
|
+
return u.protocol + "//" + u.hostname + ":" + (u.port || _defaultPortFor(u));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _makeH1Transport(u, ips) {
|
|
218
|
+
var lib = u.protocol === "https:" ? https : http;
|
|
219
|
+
// HTTPS path goes through pqcAgent.create so the framework's PQC-only
|
|
220
|
+
// posture is enforced via the single primitive. Cleartext HTTP stays
|
|
221
|
+
// on http.Agent because there's no TLS posture to enforce.
|
|
222
|
+
var agent = u.protocol === "https:"
|
|
223
|
+
? pqcAgent.create(HTTP_CLIENT_AGENT_OPTS)
|
|
224
|
+
: new lib.Agent(HTTP_CLIENT_AGENT_OPTS);
|
|
225
|
+
return { kind: "h1", lib: lib, agent: agent, lookup: _pinnedLookupFor(ips) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Build a `lookup` callback that pins outbound connections to IPs the
|
|
229
|
+
// SSRF guard already validated. Closes the TOCTOU window between
|
|
230
|
+
// SSRF resolution and the kernel's connect — without this, a hostile
|
|
231
|
+
// (or compromised) DNS could rotate the answer between guard-check
|
|
232
|
+
// and connect-time and route the request to a private / metadata IP
|
|
233
|
+
// that bypassed the gate. ips comes from `ssrfGuard.checkUrl` — its
|
|
234
|
+
// classification ran on these exact addresses.
|
|
235
|
+
function _pinnedLookupFor(ips) {
|
|
236
|
+
if (!Array.isArray(ips) || ips.length === 0) return undefined;
|
|
237
|
+
var families = ips.map(function (i) { return { address: i.address, family: i.family || 4 }; });
|
|
238
|
+
return function pinnedLookup(hostname, options, callback) {
|
|
239
|
+
if (typeof options === "function") { callback = options; options = {}; }
|
|
240
|
+
options = options || {};
|
|
241
|
+
if (options.all) {
|
|
242
|
+
callback(null, families);
|
|
243
|
+
} else {
|
|
244
|
+
callback(null, families[0].address, families[0].family);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Connect an h2 session to an HTTPS origin via ALPN. If the server picks
|
|
250
|
+
// http/1.1, fall back to an h1 transport for that origin.
|
|
251
|
+
function _connectHttpsWithAlpn(u, ips) {
|
|
252
|
+
return new Promise(function (resolve, reject) {
|
|
253
|
+
var connectOpts = Object.assign({}, DEFAULT_H2_TLS_OPTS);
|
|
254
|
+
var pinned = _pinnedLookupFor(ips);
|
|
255
|
+
if (pinned) connectOpts.lookup = pinned;
|
|
256
|
+
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
257
|
+
var settled = false;
|
|
258
|
+
function _done(t) { if (!settled) { settled = true; resolve(t); } }
|
|
259
|
+
function _fail(err) { if (!settled) { settled = true; reject(err); } }
|
|
260
|
+
|
|
261
|
+
session.once("connect", function () {
|
|
262
|
+
var alpn = session.alpnProtocol;
|
|
263
|
+
if (alpn === "h2") {
|
|
264
|
+
_wireH2Session(session, _originKey(u));
|
|
265
|
+
_done({ kind: "h2", session: session });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Server picked http/1.1 — close the h2 session, return h1 transport.
|
|
269
|
+
_tearDownH2Session(session);
|
|
270
|
+
_done(_makeH1Transport(u, ips));
|
|
271
|
+
});
|
|
272
|
+
session.once("error", function (err) {
|
|
273
|
+
_tearDownH2Session(session);
|
|
274
|
+
_fail(err);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Connect an h2c session (cleartext h2). No ALPN, no fallback — caller
|
|
280
|
+
// has attested via preferH2 that the server speaks h2c.
|
|
281
|
+
function _connectH2c(u, ips) {
|
|
282
|
+
return new Promise(function (resolve, reject) {
|
|
283
|
+
var connectOpts = {};
|
|
284
|
+
var pinned = _pinnedLookupFor(ips);
|
|
285
|
+
if (pinned) connectOpts.lookup = pinned;
|
|
286
|
+
var session = http2.connect(u.protocol + "//" + u.host, connectOpts);
|
|
287
|
+
session.once("connect", function () {
|
|
288
|
+
_wireH2Session(session, _originKey(u));
|
|
289
|
+
resolve({ kind: "h2", session: session });
|
|
290
|
+
});
|
|
291
|
+
session.once("error", function (err) {
|
|
292
|
+
_tearDownH2Session(session);
|
|
293
|
+
reject(err);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Common h2 session wiring — idle close + cache eviction on error/close.
|
|
299
|
+
function _wireH2Session(session, key) {
|
|
300
|
+
session.setTimeout(H2_SESSION_IDLE_TIMEOUT_MS, function () {
|
|
301
|
+
_tearDownH2Session(session);
|
|
302
|
+
});
|
|
303
|
+
session.once("close", function () { _transports.delete(key); });
|
|
304
|
+
session.once("error", function () { _transports.delete(key); });
|
|
305
|
+
session.once("goaway", function () {
|
|
306
|
+
// Server signalling 'no new streams' — let in-flight finish, evict cache.
|
|
307
|
+
_transports.delete(key);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Async transport selection. Returns Promise<transport>. `ips` is the
|
|
312
|
+
// validated address list returned by `ssrfGuard.checkUrl`; the transport
|
|
313
|
+
// uses it to pin connections so a hostile DNS rebind can't redirect
|
|
314
|
+
// the actual TCP connect to a private / metadata IP.
|
|
315
|
+
function _getTransport(u, opts, ips) {
|
|
316
|
+
var key = _originKey(u);
|
|
317
|
+
var cached = _transports.get(key);
|
|
318
|
+
if (cached) {
|
|
319
|
+
// Could be a resolved transport OR a pending Promise. Cached
|
|
320
|
+
// transports keep whatever IP pinning was set when they were
|
|
321
|
+
// first created — subsequent SSRF checks still gate the request,
|
|
322
|
+
// and the transport's TCP socket is bound to its original IP.
|
|
323
|
+
return Promise.resolve(cached);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
var promise;
|
|
327
|
+
if (u.protocol === "https:") {
|
|
328
|
+
promise = _connectHttpsWithAlpn(u, ips);
|
|
329
|
+
} else if (opts && opts.preferH2) {
|
|
330
|
+
promise = _connectH2c(u, ips);
|
|
331
|
+
} else {
|
|
332
|
+
// HTTP without preferH2 → h1 only.
|
|
333
|
+
promise = Promise.resolve(_makeH1Transport(u, ips));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Cache the in-flight Promise immediately so concurrent calls
|
|
337
|
+
// coalesce. On resolve, replace with the transport. On reject, evict.
|
|
338
|
+
_transports.set(key, promise);
|
|
339
|
+
promise.then(
|
|
340
|
+
function (t) { _transports.set(key, t); },
|
|
341
|
+
function (_err) { _transports.delete(key); }
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
return promise;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _makeError(errorClass, code, message, permanent, statusCode) {
|
|
348
|
+
if (!errorClass) return new FrameworkError(message, code);
|
|
349
|
+
return new errorClass(code, message, permanent, statusCode);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// RFC 9110 §15.5 4xx codes that are NOT permanent (request-timeout,
|
|
353
|
+
// too-early, too-many-requests — operator should retry).
|
|
354
|
+
var STATUS_REQUEST_TIMEOUT = C.BYTES.bytes(408);
|
|
355
|
+
var STATUS_TOO_EARLY = 425;
|
|
356
|
+
var STATUS_TOO_MANY_REQUESTS = 429;
|
|
357
|
+
|
|
358
|
+
function _isPermanentStatus(statusCode) {
|
|
359
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
360
|
+
return statusCode !== STATUS_REQUEST_TIMEOUT &&
|
|
361
|
+
statusCode !== STATUS_TOO_EARLY &&
|
|
362
|
+
statusCode !== STATUS_TOO_MANY_REQUESTS;
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// h2 sends headers as lowercased keys plus :method / :path / :scheme /
|
|
368
|
+
// :authority pseudo-headers. Convert from h1-shaped headers.
|
|
369
|
+
function _toH2Headers(method, u, headers) {
|
|
370
|
+
var h2Headers = Object.create(null);
|
|
371
|
+
h2Headers[":method"] = method;
|
|
372
|
+
h2Headers[":path"] = u.pathname + (u.search || "");
|
|
373
|
+
h2Headers[":scheme"] = u.protocol === "https:" ? "https" : "http";
|
|
374
|
+
h2Headers[":authority"] = u.host;
|
|
375
|
+
for (var k in headers) {
|
|
376
|
+
if (!Object.prototype.hasOwnProperty.call(headers, k)) continue;
|
|
377
|
+
var lk = k.toLowerCase();
|
|
378
|
+
// h2 forbids the connection-specific headers
|
|
379
|
+
if (lk === "connection" || lk === "host" ||
|
|
380
|
+
lk === "keep-alive" || lk === "transfer-encoding" ||
|
|
381
|
+
lk === "upgrade" || lk === "proxy-connection") continue;
|
|
382
|
+
h2Headers[lk] = headers[k];
|
|
383
|
+
}
|
|
384
|
+
return h2Headers;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function _fromH2Headers(h2Headers) {
|
|
388
|
+
// Strip pseudo-headers from the response — caller doesn't want them
|
|
389
|
+
// mixed with normal headers.
|
|
390
|
+
var out = {};
|
|
391
|
+
for (var k in h2Headers) {
|
|
392
|
+
if (!Object.prototype.hasOwnProperty.call(h2Headers, k)) continue;
|
|
393
|
+
if (k.charAt(0) === ":") continue;
|
|
394
|
+
out[k] = h2Headers[k];
|
|
395
|
+
}
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- request() ----
|
|
400
|
+
|
|
401
|
+
var REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
402
|
+
|
|
403
|
+
// http-client-cookie-jar is exposed on b.httpClient.cookieJar via index.js.
|
|
404
|
+
// http-client itself only consumes the jar shape passed by operators via
|
|
405
|
+
// the request opt; no direct require is needed here.
|
|
406
|
+
|
|
407
|
+
// Merge a jar-derived Cookie header with any caller-supplied Cookie
|
|
408
|
+
// header. Operators who override Cookie explicitly always win — the jar
|
|
409
|
+
// supplements rather than replaces.
|
|
410
|
+
function _attachJarCookie(headers, jar, url) {
|
|
411
|
+
if (!jar) return headers;
|
|
412
|
+
var jarHeader = jar.cookieHeaderFor(url);
|
|
413
|
+
if (!jarHeader) return headers;
|
|
414
|
+
var merged = Object.assign({}, headers || {});
|
|
415
|
+
var existing = null;
|
|
416
|
+
var keys = Object.keys(merged);
|
|
417
|
+
for (var i = 0; i < keys.length; i++) {
|
|
418
|
+
if (keys[i].toLowerCase() === "cookie") { existing = keys[i]; break; }
|
|
419
|
+
}
|
|
420
|
+
if (existing) merged[existing] = merged[existing] + "; " + jarHeader;
|
|
421
|
+
else merged.Cookie = jarHeader;
|
|
422
|
+
return merged;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Build a multipart/form-data body from { fields, files } shape.
|
|
426
|
+
// Mirrors the wire format that lib/middleware/body-parser.js's multipart
|
|
427
|
+
// parser accepts so round-trip from one blamejs app's outbound to
|
|
428
|
+
// another's inbound is exact.
|
|
429
|
+
function _buildMultipartBody(spec) {
|
|
430
|
+
var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
|
|
431
|
+
var CRLF = "\r\n";
|
|
432
|
+
var parts = [];
|
|
433
|
+
|
|
434
|
+
function _pushField(name, value) {
|
|
435
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
436
|
+
throw new Error("multipart: field name must be a non-empty string");
|
|
437
|
+
}
|
|
438
|
+
var head = "--" + boundary + CRLF +
|
|
439
|
+
'Content-Disposition: form-data; name="' + name + '"' + CRLF + CRLF;
|
|
440
|
+
parts.push(Buffer.from(head, "utf8"));
|
|
441
|
+
parts.push(Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8"));
|
|
442
|
+
parts.push(Buffer.from(CRLF, "utf8"));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function _pushFile(file) {
|
|
446
|
+
if (!file || typeof file !== "object") throw new Error("multipart: file entries must be objects");
|
|
447
|
+
if (typeof file.field !== "string" || file.field.length === 0) {
|
|
448
|
+
throw new Error("multipart: file.field must be a non-empty string");
|
|
449
|
+
}
|
|
450
|
+
var filename = typeof file.filename === "string" && file.filename.length > 0
|
|
451
|
+
? file.filename : "blob";
|
|
452
|
+
var mimeType = file.contentType || file.mimeType || "application/octet-stream";
|
|
453
|
+
var content = file.content;
|
|
454
|
+
if (typeof content === "string") content = Buffer.from(content, "utf8");
|
|
455
|
+
if (!Buffer.isBuffer(content)) {
|
|
456
|
+
throw new Error("multipart: file.content must be a Buffer or string");
|
|
457
|
+
}
|
|
458
|
+
var head = "--" + boundary + CRLF +
|
|
459
|
+
'Content-Disposition: form-data; name="' + file.field + '"' +
|
|
460
|
+
'; filename="' + filename.replace(/"/g, "%22") + '"' + CRLF +
|
|
461
|
+
"Content-Type: " + mimeType + CRLF + CRLF;
|
|
462
|
+
parts.push(Buffer.from(head, "utf8"));
|
|
463
|
+
parts.push(content);
|
|
464
|
+
parts.push(Buffer.from(CRLF, "utf8"));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (spec && spec.fields && typeof spec.fields === "object") {
|
|
468
|
+
var keys = Object.keys(spec.fields);
|
|
469
|
+
for (var i = 0; i < keys.length; i++) {
|
|
470
|
+
var k = keys[i];
|
|
471
|
+
var v = spec.fields[k];
|
|
472
|
+
if (Array.isArray(v)) {
|
|
473
|
+
for (var j = 0; j < v.length; j++) _pushField(k, v[j]);
|
|
474
|
+
} else {
|
|
475
|
+
_pushField(k, v);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (spec && Array.isArray(spec.files)) {
|
|
480
|
+
for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
|
|
481
|
+
}
|
|
482
|
+
parts.push(Buffer.from("--" + boundary + "--" + CRLF, "utf8"));
|
|
483
|
+
return { boundary: boundary, body: Buffer.concat(parts) };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Headers stripped on cross-origin redirect to defend against accidental
|
|
487
|
+
// credential exfiltration. Lower-case for header-map comparison.
|
|
488
|
+
var SENSITIVE_HEADERS_LC = ["authorization", "cookie", "proxy-authorization"];
|
|
489
|
+
|
|
490
|
+
function _stripCrossOriginAuth(headers) {
|
|
491
|
+
var out = {};
|
|
492
|
+
var keys = Object.keys(headers);
|
|
493
|
+
for (var i = 0; i < keys.length; i++) {
|
|
494
|
+
if (SENSITIVE_HEADERS_LC.indexOf(keys[i].toLowerCase()) !== -1) continue;
|
|
495
|
+
out[keys[i]] = headers[keys[i]];
|
|
496
|
+
}
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function request(opts) {
|
|
501
|
+
if (!opts || !opts.url) {
|
|
502
|
+
return Promise.reject(_makeError(opts && opts.errorClass, "BAD_ARG", "url is required", true));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Validate before/after shapes early — throw at call site if the
|
|
506
|
+
// operator passed something un-callable so the bug surfaces here
|
|
507
|
+
// rather than inside the request loop.
|
|
508
|
+
if (opts.before !== undefined) {
|
|
509
|
+
if (!Array.isArray(opts.before) || !opts.before.every(function (f) { return typeof f === "function"; })) {
|
|
510
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
511
|
+
"before must be an array of functions", true));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (opts.after !== undefined) {
|
|
515
|
+
if (!Array.isArray(opts.after) || !opts.after.every(function (f) { return typeof f === "function"; })) {
|
|
516
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
517
|
+
"after must be an array of functions", true));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (opts.onUploadProgress !== undefined && typeof opts.onUploadProgress !== "function") {
|
|
521
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
522
|
+
"onUploadProgress must be a function", true));
|
|
523
|
+
}
|
|
524
|
+
if (opts.onDownloadProgress !== undefined && typeof opts.onDownloadProgress !== "function") {
|
|
525
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
526
|
+
"onDownloadProgress must be a function", true));
|
|
527
|
+
}
|
|
528
|
+
if (opts.jar !== undefined && opts.jar !== null) {
|
|
529
|
+
if (typeof opts.jar !== "object" ||
|
|
530
|
+
typeof opts.jar.cookieHeaderFor !== "function" ||
|
|
531
|
+
typeof opts.jar.setFromResponse !== "function") {
|
|
532
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
533
|
+
"jar must be a b.httpClient.cookieJar.create() instance", true));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// before interceptors — run in array order. Each may return a modified
|
|
538
|
+
// opts object (or return nothing to leave the running opts as-is).
|
|
539
|
+
// Caller-set defaults / observability / auth header injection lands
|
|
540
|
+
// here. Synchronous to keep the request hot path simple; async
|
|
541
|
+
// pre-flight work (e.g. token refresh) belongs in the route handler
|
|
542
|
+
// before httpClient.request is even called.
|
|
543
|
+
if (Array.isArray(opts.before) && opts.before.length > 0) {
|
|
544
|
+
var working = opts;
|
|
545
|
+
for (var bi = 0; bi < opts.before.length; bi++) {
|
|
546
|
+
var ret;
|
|
547
|
+
try { ret = opts.before[bi](working); }
|
|
548
|
+
catch (e) {
|
|
549
|
+
return Promise.reject(_makeError(opts.errorClass, "BEFORE_THREW",
|
|
550
|
+
"before[" + bi + "] threw: " + ((e && e.message) || String(e)), true));
|
|
551
|
+
}
|
|
552
|
+
if (ret && typeof ret === "object") working = ret;
|
|
553
|
+
}
|
|
554
|
+
opts = working;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Multipart shorthand: { multipart: { fields, files } } expands to
|
|
558
|
+
// body + Content-Type with the boundary parameter. Mutually exclusive
|
|
559
|
+
// with caller-supplied body / Content-Type.
|
|
560
|
+
if (opts.multipart) {
|
|
561
|
+
if (opts.body !== undefined) {
|
|
562
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
563
|
+
"request: pass either { body } or { multipart }, not both", true));
|
|
564
|
+
}
|
|
565
|
+
var built;
|
|
566
|
+
try { built = _buildMultipartBody(opts.multipart); }
|
|
567
|
+
catch (e) {
|
|
568
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
|
|
569
|
+
}
|
|
570
|
+
opts = Object.assign({}, opts, {
|
|
571
|
+
method: opts.method || "POST",
|
|
572
|
+
body: built.body,
|
|
573
|
+
headers: Object.assign({}, opts.headers || {}, {
|
|
574
|
+
"Content-Type": "multipart/form-data; boundary=" + built.boundary,
|
|
575
|
+
"Content-Length": String(built.body.length),
|
|
576
|
+
}),
|
|
577
|
+
multipart: undefined,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// maxRedirects:
|
|
582
|
+
// undefined → today's behavior (no follow). Caller inspects 3xx.
|
|
583
|
+
// null → today's behavior (explicit). Same as undefined.
|
|
584
|
+
// 0 → no follow, but 3xx returned to caller (alias of null).
|
|
585
|
+
// N → follow up to N hops; 3xx with no Location returned as-is.
|
|
586
|
+
var maxRedirects = (opts.maxRedirects === undefined || opts.maxRedirects === null)
|
|
587
|
+
? null : opts.maxRedirects;
|
|
588
|
+
if (maxRedirects !== null) {
|
|
589
|
+
if (typeof maxRedirects !== "number" || !isFinite(maxRedirects) || maxRedirects < 0 ||
|
|
590
|
+
Math.floor(maxRedirects) !== maxRedirects) {
|
|
591
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
592
|
+
"maxRedirects must be a non-negative integer or null", true));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
var afterChain = (Array.isArray(opts.after) && opts.after.length > 0) ? opts.after : null;
|
|
596
|
+
function _runAfter(finalOpts, res) {
|
|
597
|
+
if (!afterChain) return res;
|
|
598
|
+
for (var ai = 0; ai < afterChain.length; ai++) {
|
|
599
|
+
try { afterChain[ai](finalOpts, res); }
|
|
600
|
+
catch (_e) { /* after hooks are best-effort — never break the response */ }
|
|
601
|
+
}
|
|
602
|
+
return res;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (maxRedirects === null || maxRedirects === 0) {
|
|
606
|
+
return _requestSingle(opts).then(function (res) { return _runAfter(opts, res); });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
return _requestWithRedirects(opts, maxRedirects).then(function (boxed) {
|
|
610
|
+
return _runAfter(boxed.finalOpts, boxed.res);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function _requestWithRedirects(opts, hopsLeft) {
|
|
615
|
+
var originalUrl = opts.url;
|
|
616
|
+
var originalOrigin = null;
|
|
617
|
+
try {
|
|
618
|
+
var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
619
|
+
originalOrigin = u0.protocol + "//" + u0.host;
|
|
620
|
+
} catch (_e) { /* request() will reject on next hop's parse */ }
|
|
621
|
+
|
|
622
|
+
var current = Object.assign({}, opts, { _resolveOnRedirect: true });
|
|
623
|
+
function _follow() {
|
|
624
|
+
return _requestSingle(current).then(function (res) {
|
|
625
|
+
if (!REDIRECT_STATUSES.has(res.statusCode) || hopsLeft <= 0) {
|
|
626
|
+
return { finalOpts: current, res: res };
|
|
627
|
+
}
|
|
628
|
+
var loc = res.headers && (res.headers.location || res.headers.Location);
|
|
629
|
+
if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
|
|
630
|
+
hopsLeft -= 1;
|
|
631
|
+
|
|
632
|
+
// Resolve relative Location against the just-fetched URL (the URL
|
|
633
|
+
// of the request that produced the redirect, which may itself be a
|
|
634
|
+
// post-redirect URL).
|
|
635
|
+
var nextUrl;
|
|
636
|
+
try {
|
|
637
|
+
// Resolve relative Location against current URL using Node's URL
|
|
638
|
+
// base-URL form. Re-validate the resolved absolute through safeUrl
|
|
639
|
+
// immediately below.
|
|
640
|
+
nextUrl = Reflect.construct(URL, [loc, current.url]).toString();
|
|
641
|
+
}
|
|
642
|
+
catch (_e) {
|
|
643
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_REDIRECT",
|
|
644
|
+
"Location header invalid URL: " + loc, true));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Cross-origin auth-header strip.
|
|
648
|
+
var nextHeaders = current.headers || {};
|
|
649
|
+
var nextOrigin;
|
|
650
|
+
try {
|
|
651
|
+
var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
652
|
+
nextOrigin = nu.protocol + "//" + nu.host;
|
|
653
|
+
} catch (_e) { /* request() will reject when it tries to parse */ }
|
|
654
|
+
if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
|
|
655
|
+
nextHeaders = _stripCrossOriginAuth(nextHeaders);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// 303 → always GET; body dropped. 301/302 → historical clients
|
|
659
|
+
// also coerce non-GET bodies (we follow that convention). 307/308
|
|
660
|
+
// → preserve method + body.
|
|
661
|
+
var nextMethod = current.method || "GET";
|
|
662
|
+
var nextBody = current.body;
|
|
663
|
+
if (res.statusCode === 303 ||
|
|
664
|
+
((res.statusCode === 301 || res.statusCode === 302) &&
|
|
665
|
+
nextMethod !== "GET" && nextMethod !== "HEAD")) {
|
|
666
|
+
nextMethod = "GET";
|
|
667
|
+
nextBody = undefined;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
current = Object.assign({}, current, {
|
|
671
|
+
url: nextUrl,
|
|
672
|
+
method: nextMethod,
|
|
673
|
+
body: nextBody,
|
|
674
|
+
headers: nextHeaders,
|
|
675
|
+
_resolveOnRedirect: true,
|
|
676
|
+
});
|
|
677
|
+
return _follow();
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
void originalUrl;
|
|
681
|
+
return _follow();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function _requestSingle(opts) {
|
|
685
|
+
// Validate scheme + shape via url-safe. Default is HTTPS-only — the
|
|
686
|
+
// framework refuses to silently drop bytes on the wire as cleartext.
|
|
687
|
+
// Callers with cleartext endpoints (h2c, internal services, test
|
|
688
|
+
// fixtures) explicitly opt in via opts.allowedProtocols
|
|
689
|
+
// (safeUrl.ALLOW_HTTP_ALL accepts both http: and https:).
|
|
690
|
+
var u;
|
|
691
|
+
try {
|
|
692
|
+
u = safeUrl.parse(opts.url, {
|
|
693
|
+
allowedProtocols: opts.allowedProtocols || safeUrl.ALLOW_HTTP_TLS,
|
|
694
|
+
errorClass: opts.errorClass,
|
|
695
|
+
});
|
|
696
|
+
} catch (e) {
|
|
697
|
+
return Promise.reject(e);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Optional outbound destination allowlist. When opts.allowedHosts
|
|
701
|
+
// is set, only URLs whose hostname is on the list are permitted.
|
|
702
|
+
// Layer above safeUrl (scheme/userinfo gate) and above ssrfGuard
|
|
703
|
+
// (IP-class gate) — operators with strict egress policies pin the
|
|
704
|
+
// outbound destinations the app is allowed to talk to so a
|
|
705
|
+
// compromised process can't reach arbitrary upstreams.
|
|
706
|
+
//
|
|
707
|
+
// Entry forms (each entry is a string OR an object):
|
|
708
|
+
// "api.partner.com" — exact host match
|
|
709
|
+
// ".partner.com" — suffix match: "api.partner.com" yes,
|
|
710
|
+
// "evilpartner.com" no
|
|
711
|
+
// "*.partner.com" — same as ".partner.com" (DNS-glob shape
|
|
712
|
+
// operators expect from firewall configs)
|
|
713
|
+
// { host: "api.x.com", methods: ["GET","HEAD"] }
|
|
714
|
+
// — method-restricted entry; methods omitted
|
|
715
|
+
// = any method
|
|
716
|
+
//
|
|
717
|
+
// A disallowed call rejects with HOST_DISALLOWED AND emits an
|
|
718
|
+
// audit event when opts.audit is wired (operator gets a structured
|
|
719
|
+
// signal that the application tried to reach somewhere it shouldn't).
|
|
720
|
+
if (Array.isArray(opts.allowedHosts) && opts.allowedHosts.length > 0) {
|
|
721
|
+
var host = u.hostname.toLowerCase();
|
|
722
|
+
var method = (opts.method || "GET").toUpperCase();
|
|
723
|
+
var ok = false;
|
|
724
|
+
for (var ai = 0; ai < opts.allowedHosts.length; ai++) {
|
|
725
|
+
var entry = opts.allowedHosts[ai];
|
|
726
|
+
var allow, allowedMethods = null;
|
|
727
|
+
if (typeof entry === "object" && entry !== null) {
|
|
728
|
+
allow = String(entry.host || "").toLowerCase();
|
|
729
|
+
if (Array.isArray(entry.methods) && entry.methods.length > 0) {
|
|
730
|
+
allowedMethods = entry.methods.map(function (m) { return String(m).toUpperCase(); });
|
|
731
|
+
}
|
|
732
|
+
} else {
|
|
733
|
+
allow = String(entry || "").toLowerCase();
|
|
734
|
+
}
|
|
735
|
+
if (allow.length === 0) continue;
|
|
736
|
+
// Normalise "*.x.com" to ".x.com" for the suffix match path.
|
|
737
|
+
if (allow.charAt(0) === "*" && allow.charAt(1) === ".") allow = allow.slice(1);
|
|
738
|
+
var matched = false;
|
|
739
|
+
if (allow.charAt(0) === ".") {
|
|
740
|
+
if (host === allow.slice(1) || host.endsWith(allow)) matched = true;
|
|
741
|
+
} else if (host === allow) {
|
|
742
|
+
matched = true;
|
|
743
|
+
}
|
|
744
|
+
if (!matched) continue;
|
|
745
|
+
if (allowedMethods !== null && allowedMethods.indexOf(method) === -1) continue;
|
|
746
|
+
ok = true;
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
if (!ok) {
|
|
750
|
+
if (opts.audit && typeof opts.audit.safeEmit === "function") {
|
|
751
|
+
try {
|
|
752
|
+
opts.audit.safeEmit({
|
|
753
|
+
action: "system.httpclient.host_denied",
|
|
754
|
+
outcome: "denied",
|
|
755
|
+
resource: { kind: "outbound.http", id: host },
|
|
756
|
+
metadata: { method: method, url: opts.url, allowedHostsCount: opts.allowedHosts.length },
|
|
757
|
+
});
|
|
758
|
+
} catch (_e) { /* audit best-effort */ }
|
|
759
|
+
}
|
|
760
|
+
return Promise.reject(_makeError(opts.errorClass, "HOST_DISALLOWED",
|
|
761
|
+
"host '" + host + "' not in allowedHosts (method=" + method + ")", true));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Attach jar-derived Cookie header BEFORE the request fires; record
|
|
766
|
+
// Set-Cookie response headers AFTER. Both halves run when opts.jar
|
|
767
|
+
// is set; redirect-following naturally re-runs both paths per hop
|
|
768
|
+
// because each hop calls _requestSingle.
|
|
769
|
+
if (opts.jar) {
|
|
770
|
+
var headersWithJar = _attachJarCookie(opts.headers, opts.jar, opts.url);
|
|
771
|
+
if (headersWithJar !== opts.headers) {
|
|
772
|
+
opts = Object.assign({}, opts, { headers: headersWithJar });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// SSRF gate — refuse private / loopback / link-local / cloud-metadata
|
|
777
|
+
// / reserved IP destinations by default. The returned `ips` are
|
|
778
|
+
// threaded into transport creation so the actual TCP connect pins
|
|
779
|
+
// to those exact addresses, closing the DNS-rebinding TOCTOU window.
|
|
780
|
+
return ssrfGuard.checkUrl(u, {
|
|
781
|
+
allowInternal: opts.allowInternal,
|
|
782
|
+
errorClass: opts.errorClass,
|
|
783
|
+
}).then(function (ssrfResult) {
|
|
784
|
+
var ips = ssrfResult && ssrfResult.ips;
|
|
785
|
+
// Caller-supplied agent bypasses transport cache (h1 only). The
|
|
786
|
+
// operator owns the agent's connection pool — we still pass the
|
|
787
|
+
// pinned lookup through per-request so the SSRF check's IPs win.
|
|
788
|
+
if (opts.agent) {
|
|
789
|
+
return _requestH1({
|
|
790
|
+
kind: "h1",
|
|
791
|
+
lib: u.protocol === "https:" ? https : http,
|
|
792
|
+
agent: opts.agent,
|
|
793
|
+
lookup: _pinnedLookupFor(ips),
|
|
794
|
+
}, u, opts);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
var proxyAgent = null;
|
|
798
|
+
try { proxyAgent = networkProxy.agentFor(u); } catch (_e) { proxyAgent = null; }
|
|
799
|
+
if (proxyAgent) {
|
|
800
|
+
return _requestH1({
|
|
801
|
+
kind: "h1",
|
|
802
|
+
lib: u.protocol === "https:" ? https : http,
|
|
803
|
+
agent: proxyAgent,
|
|
804
|
+
lookup: undefined,
|
|
805
|
+
}, u, opts);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return _getTransport(u, opts, ips).then(function (transport) {
|
|
809
|
+
if (transport.kind === "h2") return _requestH2(transport, u, opts);
|
|
810
|
+
return _requestH1(transport, u, opts);
|
|
811
|
+
});
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ---- _requestH1: existing node:http(s) path ----
|
|
816
|
+
|
|
817
|
+
function _requestH1(transport, u, opts) {
|
|
818
|
+
return new Promise(function (resolve, reject) {
|
|
819
|
+
var method = (opts.method || "GET").toUpperCase();
|
|
820
|
+
var headers = Object.assign({}, opts.headers || {});
|
|
821
|
+
var responseMode = opts.responseMode || "buffer";
|
|
822
|
+
var maxResponseBytes = opts.maxResponseBytes ||
|
|
823
|
+
(method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
|
|
824
|
+
var observer = typeof opts.observer === "function" ? opts.observer : null;
|
|
825
|
+
var startedAt = Date.now();
|
|
826
|
+
|
|
827
|
+
var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
|
|
828
|
+
if (signal && signal.aborted) {
|
|
829
|
+
var r0 = signal.reason;
|
|
830
|
+
var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
831
|
+
reject(_makeError(opts.errorClass, code0,
|
|
832
|
+
(r0 && r0.message) || "request aborted before start", false));
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (Buffer.isBuffer(opts.body)) {
|
|
837
|
+
headers["Content-Length"] = opts.body.length;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
var reqOpts = {
|
|
841
|
+
method: method,
|
|
842
|
+
hostname: u.hostname,
|
|
843
|
+
port: u.port || _defaultPortFor(u),
|
|
844
|
+
path: u.pathname + (u.search || ""),
|
|
845
|
+
headers: headers,
|
|
846
|
+
agent: transport.agent,
|
|
847
|
+
timeout: typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS,
|
|
848
|
+
};
|
|
849
|
+
// Pin DNS to the IPs the SSRF guard validated. Closes the
|
|
850
|
+
// rebinding TOCTOU between guard-check and actual TCP connect.
|
|
851
|
+
if (transport.lookup) reqOpts.lookup = transport.lookup;
|
|
852
|
+
|
|
853
|
+
if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h1" });
|
|
854
|
+
|
|
855
|
+
var settled = false;
|
|
856
|
+
function _resolve(value) { if (!settled) { settled = true; resolve(value); } }
|
|
857
|
+
function _reject(err) { if (!settled) { settled = true; reject(err); } }
|
|
858
|
+
|
|
859
|
+
var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
|
|
860
|
+
var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
|
|
861
|
+
|
|
862
|
+
var req = transport.lib.request(reqOpts, function (res) {
|
|
863
|
+
if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
|
|
864
|
+
|
|
865
|
+
// Save Set-Cookie into the jar (if wired) BEFORE delivering the
|
|
866
|
+
// response object — operator inspecting the response can already
|
|
867
|
+
// count on the jar carrying the new state.
|
|
868
|
+
if (opts.jar && res.headers && res.headers["set-cookie"]) {
|
|
869
|
+
try { opts.jar.setFromResponse(opts.url, res.headers["set-cookie"]); }
|
|
870
|
+
catch (_e) { /* jar is best-effort — never break the response */ }
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Download total: Content-Length when present, null otherwise.
|
|
874
|
+
var dlTotal = null;
|
|
875
|
+
if (res.headers && typeof res.headers["content-length"] === "string") {
|
|
876
|
+
var cl = parseInt(res.headers["content-length"], 10);
|
|
877
|
+
if (!isNaN(cl) && cl >= 0) dlTotal = cl;
|
|
878
|
+
}
|
|
879
|
+
var dlLoaded = 0;
|
|
880
|
+
function _emitDownload(chunkBytes) {
|
|
881
|
+
if (!onDownloadProgress) return;
|
|
882
|
+
dlLoaded += chunkBytes;
|
|
883
|
+
try { onDownloadProgress({ loaded: dlLoaded, total: dlTotal }); }
|
|
884
|
+
catch (_e) { /* progress hooks are best-effort */ }
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
if (responseMode === "stream") {
|
|
888
|
+
if (res.statusCode >= 400) {
|
|
889
|
+
res.resume();
|
|
890
|
+
return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
|
|
891
|
+
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
892
|
+
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
893
|
+
}
|
|
894
|
+
if (onDownloadProgress) {
|
|
895
|
+
// Wrap the stream so chunks emit progress to the operator.
|
|
896
|
+
// The framework's contract is to hand back the response stream
|
|
897
|
+
// unmodified; fix-up via a passthrough keeps that contract while
|
|
898
|
+
// observing the chunk sizes.
|
|
899
|
+
var passthrough = new nodeStream.PassThrough();
|
|
900
|
+
res.on("data", function (chunk) { _emitDownload(chunk.length); passthrough.write(chunk); });
|
|
901
|
+
res.on("end", function () { passthrough.end(); });
|
|
902
|
+
res.on("error", function (e) { passthrough.destroy(e); });
|
|
903
|
+
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
|
|
904
|
+
}
|
|
905
|
+
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: res });
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
|
|
909
|
+
var capExceeded = false;
|
|
910
|
+
|
|
911
|
+
res.on("data", function (chunk) {
|
|
912
|
+
if (capExceeded) return;
|
|
913
|
+
try { collector.push(chunk); }
|
|
914
|
+
catch (_e) {
|
|
915
|
+
capExceeded = true;
|
|
916
|
+
req.destroy();
|
|
917
|
+
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
918
|
+
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
_emitDownload(chunk.length);
|
|
922
|
+
});
|
|
923
|
+
res.on("end", function () {
|
|
924
|
+
if (capExceeded) return;
|
|
925
|
+
var buf = collector.result();
|
|
926
|
+
if (observer) observer("response:end", {
|
|
927
|
+
statusCode: res.statusCode,
|
|
928
|
+
durationMs: Date.now() - startedAt,
|
|
929
|
+
bytes: buf.length,
|
|
930
|
+
});
|
|
931
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
932
|
+
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
933
|
+
} else if (opts._resolveOnRedirect && REDIRECT_STATUSES.has(res.statusCode)) {
|
|
934
|
+
// Redirect-following layer needs the response object intact so
|
|
935
|
+
// it can inspect Location and re-issue. The caller-facing
|
|
936
|
+
// request() never sets _resolveOnRedirect — operator code that
|
|
937
|
+
// didn't ask for redirect-following keeps seeing 3xx as errors.
|
|
938
|
+
_resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
|
|
939
|
+
} else {
|
|
940
|
+
var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
|
|
941
|
+
_reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
|
|
942
|
+
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
res.on("error", function (e) {
|
|
946
|
+
if (capExceeded) return;
|
|
947
|
+
if (observer) observer("error", { phase: "response", message: e.message });
|
|
948
|
+
_reject(_makeError(opts.errorClass, e.code || "RES_ERROR", e.message, false));
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
req.on("timeout", function () {
|
|
953
|
+
req.destroy();
|
|
954
|
+
_reject(_makeError(opts.errorClass, "ETIMEDOUT",
|
|
955
|
+
"request idle timeout (no data for " + reqOpts.timeout + "ms)", false));
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
req.on("error", function (e) {
|
|
959
|
+
if (observer) observer("error", { phase: "request", message: e.message });
|
|
960
|
+
_reject(_makeError(opts.errorClass, e.code || "REQ_ERROR", e.message, false));
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
if (signal) {
|
|
964
|
+
var onAbort = function () {
|
|
965
|
+
var r = signal.reason;
|
|
966
|
+
var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
967
|
+
var msg = (r && r.message) || "request aborted";
|
|
968
|
+
try { req.destroy(r || new Error(msg)); } catch (_e) { /* best-effort req teardown */ }
|
|
969
|
+
_reject(_makeError(opts.errorClass, code, msg, false));
|
|
970
|
+
};
|
|
971
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
// Upload progress: emit { loaded, total } as body bytes go to the
|
|
975
|
+
// wire. Buffer / string bodies are sliced into chunks ourselves so
|
|
976
|
+
// operators see incremental progress; Readable bodies emit on each
|
|
977
|
+
// 'data' event from the source stream.
|
|
978
|
+
var ulTotal = null;
|
|
979
|
+
if (Buffer.isBuffer(opts.body)) ulTotal = opts.body.length;
|
|
980
|
+
else if (typeof opts.body === "string") ulTotal = Buffer.byteLength(opts.body, "utf8");
|
|
981
|
+
var ulLoaded = 0;
|
|
982
|
+
function _emitUpload(chunkBytes) {
|
|
983
|
+
if (!onUploadProgress) return;
|
|
984
|
+
ulLoaded += chunkBytes;
|
|
985
|
+
try { onUploadProgress({ loaded: ulLoaded, total: ulTotal }); }
|
|
986
|
+
catch (_e) { /* progress hooks are best-effort */ }
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (opts.body && typeof opts.body.pipe === "function") {
|
|
990
|
+
if (onUploadProgress) {
|
|
991
|
+
opts.body.on("data", function (c) { _emitUpload(c.length); });
|
|
992
|
+
}
|
|
993
|
+
opts.body.on("error", function (e) {
|
|
994
|
+
try { req.destroy(); } catch (_) { /* best-effort req teardown */ }
|
|
995
|
+
_reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
|
|
996
|
+
"request body stream error: " + e.message, false));
|
|
997
|
+
});
|
|
998
|
+
opts.body.pipe(req);
|
|
999
|
+
} else if (Buffer.isBuffer(opts.body) || typeof opts.body === "string") {
|
|
1000
|
+
var bodyBuf = Buffer.isBuffer(opts.body) ? opts.body : Buffer.from(opts.body, "utf8");
|
|
1001
|
+
if (onUploadProgress) {
|
|
1002
|
+
// Chunked write so progress reports land before req.end().
|
|
1003
|
+
var CHUNK = C.BYTES.kib(64);
|
|
1004
|
+
var off = 0;
|
|
1005
|
+
while (off < bodyBuf.length) {
|
|
1006
|
+
var slice = bodyBuf.slice(off, Math.min(off + CHUNK, bodyBuf.length));
|
|
1007
|
+
req.write(slice);
|
|
1008
|
+
_emitUpload(slice.length);
|
|
1009
|
+
off += slice.length;
|
|
1010
|
+
}
|
|
1011
|
+
req.end();
|
|
1012
|
+
} else {
|
|
1013
|
+
req.end(bodyBuf);
|
|
1014
|
+
}
|
|
1015
|
+
} else {
|
|
1016
|
+
req.end();
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ---- _requestH2: node:http2 path ----
|
|
1022
|
+
|
|
1023
|
+
function _requestH2(transport, u, opts) {
|
|
1024
|
+
return new Promise(function (resolve, reject) {
|
|
1025
|
+
var method = (opts.method || "GET").toUpperCase();
|
|
1026
|
+
var responseMode = opts.responseMode || "buffer";
|
|
1027
|
+
var maxResponseBytes = opts.maxResponseBytes ||
|
|
1028
|
+
(method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
|
|
1029
|
+
var observer = typeof opts.observer === "function" ? opts.observer : null;
|
|
1030
|
+
var startedAt = Date.now();
|
|
1031
|
+
|
|
1032
|
+
var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
|
|
1033
|
+
if (signal && signal.aborted) {
|
|
1034
|
+
var r0 = signal.reason;
|
|
1035
|
+
var code0 = (r0 && r0.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
1036
|
+
reject(_makeError(opts.errorClass, code0,
|
|
1037
|
+
(r0 && r0.message) || "request aborted before start", false));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
var headers = _toH2Headers(method, u, opts.headers || {});
|
|
1042
|
+
if (Buffer.isBuffer(opts.body)) headers["content-length"] = String(opts.body.length);
|
|
1043
|
+
|
|
1044
|
+
if (observer) observer("request:start", { method: method, url: String(opts.url), protocol: "h2" });
|
|
1045
|
+
|
|
1046
|
+
var stream;
|
|
1047
|
+
try {
|
|
1048
|
+
stream = transport.session.request(headers, {
|
|
1049
|
+
endStream: opts.body == null,
|
|
1050
|
+
});
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
reject(_makeError(opts.errorClass, e.code || "H2_REQUEST_ERROR", e.message, false));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
var settled = false;
|
|
1057
|
+
function _resolve(v) { if (!settled) { settled = true; resolve(v); } }
|
|
1058
|
+
function _reject(e) { if (!settled) { settled = true; reject(e); } }
|
|
1059
|
+
|
|
1060
|
+
// Idle timeout for the stream itself (zero-progress detector).
|
|
1061
|
+
var idleMs = typeof opts.idleTimeoutMs === "number" ? opts.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
|
|
1062
|
+
stream.setTimeout(idleMs, function () {
|
|
1063
|
+
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
|
|
1064
|
+
_reject(_makeError(opts.errorClass, "ETIMEDOUT",
|
|
1065
|
+
"h2 stream idle timeout (no data for " + idleMs + "ms)", false));
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
stream.on("response", function (resHeaders) {
|
|
1069
|
+
var statusCode = resHeaders[":status"];
|
|
1070
|
+
var responseHeaders = _fromH2Headers(resHeaders);
|
|
1071
|
+
|
|
1072
|
+
if (observer) observer("response:headers", { statusCode: statusCode, headers: responseHeaders });
|
|
1073
|
+
|
|
1074
|
+
// Save Set-Cookie to the jar (h2 set-cookie comes through as
|
|
1075
|
+
// either a single string or array, same shape as h1).
|
|
1076
|
+
if (opts.jar && responseHeaders["set-cookie"]) {
|
|
1077
|
+
try { opts.jar.setFromResponse(opts.url, responseHeaders["set-cookie"]); }
|
|
1078
|
+
catch (_e) { /* jar best-effort */ }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (responseMode === "stream") {
|
|
1082
|
+
if (statusCode >= 400) {
|
|
1083
|
+
stream.resume();
|
|
1084
|
+
return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
|
|
1085
|
+
"HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
|
|
1086
|
+
}
|
|
1087
|
+
return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
var collector = safeBuffer.boundedChunkCollector({ maxBytes: maxResponseBytes });
|
|
1091
|
+
var capExceeded = false;
|
|
1092
|
+
|
|
1093
|
+
stream.on("data", function (chunk) {
|
|
1094
|
+
if (capExceeded) return;
|
|
1095
|
+
try { collector.push(chunk); }
|
|
1096
|
+
catch (_e) {
|
|
1097
|
+
capExceeded = true;
|
|
1098
|
+
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
|
|
1099
|
+
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
1100
|
+
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
stream.on("end", function () {
|
|
1104
|
+
if (capExceeded) return;
|
|
1105
|
+
var buf = collector.result();
|
|
1106
|
+
if (observer) observer("response:end", {
|
|
1107
|
+
statusCode: statusCode,
|
|
1108
|
+
durationMs: Date.now() - startedAt,
|
|
1109
|
+
bytes: buf.length,
|
|
1110
|
+
});
|
|
1111
|
+
if (statusCode >= 200 && statusCode < 300) {
|
|
1112
|
+
_resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
|
|
1113
|
+
} else {
|
|
1114
|
+
var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
|
|
1115
|
+
_reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
|
|
1116
|
+
_isPermanentStatus(statusCode), statusCode));
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
stream.on("error", function (e) {
|
|
1122
|
+
if (observer) observer("error", { phase: "stream", message: e.message });
|
|
1123
|
+
_reject(_makeError(opts.errorClass, e.code || "H2_STREAM_ERROR", e.message, false));
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
if (signal) {
|
|
1127
|
+
var onAbort = function () {
|
|
1128
|
+
var r = signal.reason;
|
|
1129
|
+
var code = (r && r.name === "TimeoutError") ? "ETIMEDOUT" : "ABORT";
|
|
1130
|
+
var msg = (r && r.message) || "request aborted";
|
|
1131
|
+
// NGHTTP2_CANCEL is the protocol-level "I gave up" signal —
|
|
1132
|
+
// cleaner than destroying the stream.
|
|
1133
|
+
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e) { /* best-effort h2 stream cancel */ }
|
|
1134
|
+
_reject(_makeError(opts.errorClass, code, msg, false));
|
|
1135
|
+
};
|
|
1136
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (opts.body && typeof opts.body.pipe === "function") {
|
|
1140
|
+
opts.body.on("error", function (e) {
|
|
1141
|
+
try { stream.close(http2.constants.NGHTTP2_INTERNAL_ERROR); } catch (_) { /* best-effort h2 stream cancel */ }
|
|
1142
|
+
_reject(_makeError(opts.errorClass, "REQ_BODY_ERROR",
|
|
1143
|
+
"request body stream error: " + e.message, false));
|
|
1144
|
+
});
|
|
1145
|
+
opts.body.pipe(stream);
|
|
1146
|
+
} else if (Buffer.isBuffer(opts.body)) {
|
|
1147
|
+
stream.end(opts.body);
|
|
1148
|
+
} else if (typeof opts.body === "string") {
|
|
1149
|
+
stream.end(Buffer.from(opts.body, "utf8"));
|
|
1150
|
+
}
|
|
1151
|
+
// If body is null/undefined, endStream:true was set in session.request()
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ---- Test helpers ----
|
|
1156
|
+
|
|
1157
|
+
function _resetForTest() {
|
|
1158
|
+
_transports.forEach(function (t) {
|
|
1159
|
+
if (t && t.kind === "h1" && t.agent && typeof t.agent.destroy === "function") {
|
|
1160
|
+
try { t.agent.destroy(); } catch (_e) { /* best-effort agent teardown */ }
|
|
1161
|
+
}
|
|
1162
|
+
if (t && t.kind === "h2" && t.session) {
|
|
1163
|
+
_tearDownH2Session(t.session);
|
|
1164
|
+
}
|
|
1165
|
+
});
|
|
1166
|
+
_transports.clear();
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function _getCachedTransportCount() {
|
|
1170
|
+
return _transports.size;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Diagnostic — returns 'h1' | 'h2' | null for a given URL's cached transport.
|
|
1174
|
+
function _getCachedTransportKind(url) {
|
|
1175
|
+
var u = url instanceof URL ? url : safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
|
|
1176
|
+
var t = _transports.get(_originKey(u));
|
|
1177
|
+
if (!t) return null;
|
|
1178
|
+
if (t.then) return "pending";
|
|
1179
|
+
return t.kind;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
module.exports = {
|
|
1183
|
+
request: request,
|
|
1184
|
+
configurePool: configurePool,
|
|
1185
|
+
DEFAULT_CONTROL_PLANE_CAP: DEFAULT_CONTROL_PLANE_CAP,
|
|
1186
|
+
DEFAULT_GET_CAP: DEFAULT_GET_CAP,
|
|
1187
|
+
DEFAULT_AGENT_OPTS: DEFAULT_AGENT_OPTS,
|
|
1188
|
+
_resetForTest: _resetForTest,
|
|
1189
|
+
_getCachedTransportCount: _getCachedTransportCount,
|
|
1190
|
+
_getCachedTransportKind: _getCachedTransportKind,
|
|
1191
|
+
// Test-only — exposes the SSRF-pinned DNS lookup builder so unit
|
|
1192
|
+
// tests can confirm the callback shape matches Node's documented
|
|
1193
|
+
// `lookup(hostname, options, callback)` contract.
|
|
1194
|
+
_pinnedLookupForTest: _pinnedLookupFor,
|
|
1195
|
+
};
|