@blamejs/core 0.8.11 → 0.8.13
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 +4 -0
- package/lib/http-client.js +214 -33
- package/lib/websocket.js +79 -3
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **0.8.13** (2026-05-07) — Streaming multipart uploads + `onChunk` response hook on `b.httpClient`. **Streaming multipart** — `b.httpClient.request({ multipart: { files: [...] } })` now accepts file entries in three shapes: the existing `{ field, content: Buffer | string }` (in-memory), plus new `{ field, filePath: string }` (stream-from-disk via `fs.createReadStream`) and `{ field, stream: Readable, size?: number }` (operator-supplied stream). When every entry's size is statically resolvable (Buffer length / `fs.statSync().size` / explicit `opts.size`), the framework sets `Content-Length` and uses identity transfer; otherwise the framework omits the header and Node's HTTP layer falls back to chunked transfer. Closes the `Buffer.concat` OOM class on large uploads — the body is materialized one chunk at a time through a `Readable.from(asyncIterator)` that yields boundary headers, source bytes, and CRLF in order. Fast-path preserved: when no streaming source is involved, `_buildMultipartBody` still returns a single Buffer with a known `Content-Length`. **`onChunk(chunk)` response hook** — fires for each response data chunk in BOTH `responseMode: "buffer"` and `responseMode: "stream"`. Use case: hash bytes during pipe-to-disk without an extra Transform pass (`onChunk: (c) => hasher.update(c)`). Throws inside the hook are caught and dropped — a hash-mismatch detector can raise without breaking the pipe; callers surface the error through their own pipe handler. Verified-already-shipped during the v0.8.x framework-gap audit: `b.httpClient` `responseMode: "always-resolve"`, `onRedirect({from, to, hop, headersStripped, statusCode})` hook, `body: Readable` upload path; `b.cryptoField` derived-hash domain separation (`bj-<table>-<field>:` per-field namespace prefix matches the indexed-lookup requirement); `b.config` `redactKeys` allowlist + `redacted()` view.
|
|
12
|
+
|
|
13
|
+
- **0.8.12** (2026-05-07) — WebSocket upgrade refuses credential-shaped query parameters by default. `validateUpgradeRequest(req, opts)` now scans the request URL for the credential-leak names `access_token`, `bearer`, `bearer_token`, `apikey`, `api_key`, `api-key`, `authorization` (case-insensitive, with percent-decoding) and refuses the upgrade with HTTP 400 when one is present. URL query strings leak through web-server access logs, browser history, the Referer header forwarded to third-party CDN / analytics, in-process / proxy log captures, and crash dumps — RFC 6750 §2.3 explicitly cautions against bearer tokens in URI query parameters for these reasons. Operators with a non-credential parameter that happens to share a credential-shaped name opt out per route via `opts.allowQueryAuthParams: true` with an audited operator reason. The refused list is deliberately narrow: overloaded names (`token`, `auth`, `key`, `session`) have non-credential meanings (CSRF tokens, file-share tokens, session-resume identifiers) and are NOT refused.
|
|
14
|
+
|
|
11
15
|
- **0.8.11** (2026-05-07) — Three new state-and-federal regulatory primitives + a per-primitive test-coverage gate. **`b.breach.deadline` + `b.breach.report`** — all-50-states data-breach-notification deadline registry. `b.breach.deadline.forStates(states, detectedAt)` returns per-state `{ state, kind, dueBy, citation }` records (`kind: "as-soon-as-possible"` for AS-OF / `"hard-deadline"` for fixed-day deadlines like Texas / Florida / Maine). `b.breach.report.create()` opens a multi-state breach with a single record, tracks per-state filings via `fileNotice(id, state, ...)`, exposes `pending(id)` for dashboards, and auto-closes once every affected state has filed. Every transition records a `breach.report.*` audit event. Statutory citations + day counts wired in `lib/breach-deadline.js` per-state. **`b.ai.adverseDecision`** — wraps an operator-supplied `decide(subject)` predicate, automatically attaches a consumer-rights notice when the outcome is `"adverse"` / `"denied"` / `"rejected"`. Built-in regulation templates for `gdpr-22` (Article 22 automated-decision rights), `ai-act-86` (EU AI Act high-risk consumer recourse), `ecoa-1002.9` (US Equal Credit Opportunity Act adverse-action notice), `colorado-ai-act` (CO SB 24-205 §6-1-1701), `nyc-ll-144` (NYC Local Law 144 employment AEDT), `fcra-615` (US FCRA adverse action), and `operator-defined`. Notice carries `principalReasons` + `consumerRights: { requestData, requestExplanation, contestDecision, requestHumanReview }` shaped per regime. **`b.middleware.ageGate`** — request-level age-classification middleware. Operator-supplied `getAge(req)` returns the subject age (or null/undefined when unknown); middleware classifies as `"above-threshold"` / `"below-threshold"` / `"unknown"` against `consentRequired`, sets `X-Privacy-Posture` header, and refuses with 451 + audited reason when `requireAge` is set and `hasParentalConsent(req)` is unmet. Composes upstream of session / authn for COPPA / AADC / UK Children's Code postures. **Per-primitive test-coverage gate** — new `test/layer-0-primitives/test-coverage.test.js` walks every operator-facing `b.*` primitive and refuses release unless the primitive has at least one test reference (or an explicit `UNTESTED_BACKLOG` entry naming the reason). Closes the drift class where a primitive landed on `b.*` but never gained a unit test.
|
|
12
16
|
|
|
13
17
|
- **0.8.10** (2026-05-07) — Five new compliance / regulatory primitives composing on v0.8.9's `b.incident.report`. **`b.cra.report`** — EU Cyber Resilience Act (Regulation (EU) 2024/2847) Article 14 §1 incident reporting wrapper. Three-stage statutory deadlines: 24h early warning / 72h incident notification / 14d final report. Required `productId` + `manufacturer` per Annex VII §1. Optional ENISA submission via `opts.enisaEndpoint` + `b.httpClient`; submission is operator-opt-in per stage call (regulators uniformly require operator review before filing). **`b.nis2.report`** — NIS2 Directive (Directive (EU) 2022/2555) Article 23 §4 incident reporting wrapper. Three-stage deadlines: 24h / 72h / 1 month. Annex I (essential) / Annex II (important) entity classification + sector codes (`I.6` drinking water / `II.6` digital providers / etc.). **`b.gdpr.ropa`** — GDPR Article 30 Records of Processing Activities registry + JSON / CSV / Markdown exporter. Validates required fields per Article 30 §1; legal-basis enum per Article 6(1); produces a regulator-friendly RoPA document for the operator's DPO to file. **`b.compliance.eaa`** — EU Accessibility Act (Directive (EU) 2019/882) Article 13 declared-conformance generator. Operators declare per-criterion conformance against WCAG 2.1/2.2 AA / EN 301 549; non-conformances ship with reason + mitigation. JSON / Markdown export for the operator's accessibility statement. **`b.middleware.botDisclose`** — California SB 1001 (Cal. Bus. & Prof. Code §17941) bot-disclosure middleware. Injects a disclosure banner into HTML responses, sets `X-Bot-Disclosure` header for API consumers, audits every conversation-initiating request. Operators wire `mountPaths` to scope and `bannerHtml` for visual customization.
|
package/lib/http-client.js
CHANGED
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
* maxResponseBytes, // for buffer mode (default 16 MiB control,
|
|
21
21
|
* // 1 GiB GET — operators with > 1 GiB
|
|
22
22
|
* // stored objects must use stream mode)
|
|
23
|
+
* onChunk, // (chunk: Buffer) => void — fires for each
|
|
24
|
+
* // response chunk in BOTH buffer and stream
|
|
25
|
+
* // modes. Use to hash bytes during pipe-to-disk
|
|
26
|
+
* // without an extra Transform pass.
|
|
23
27
|
* signal, // AbortSignal — propagated to req/stream
|
|
24
28
|
* errorClass, // FrameworkError subclass
|
|
25
29
|
* observer, // optional (stage, info) => void hook
|
|
@@ -426,20 +430,78 @@ function _attachJarCookie(headers, jar, url) {
|
|
|
426
430
|
// Mirrors the wire format that lib/middleware/body-parser.js's multipart
|
|
427
431
|
// parser accepts so round-trip from one blamejs app's outbound to
|
|
428
432
|
// another's inbound is exact.
|
|
433
|
+
//
|
|
434
|
+
// Two output shapes:
|
|
435
|
+
//
|
|
436
|
+
// - { boundary, body: Buffer, contentLength }
|
|
437
|
+
// When every file entry is a Buffer / string (size known
|
|
438
|
+
// up front) and no operator opted into streaming, the result
|
|
439
|
+
// is a fully-materialized body. Smaller payloads avoid the
|
|
440
|
+
// streaming overhead and let HTTP/1.1 KeepAlive reuse with a
|
|
441
|
+
// known Content-Length.
|
|
442
|
+
//
|
|
443
|
+
// - { boundary, body: Readable, contentLength }
|
|
444
|
+
// When at least one file entry is `{ filePath }` / `{ stream }`
|
|
445
|
+
// OR opts.streaming === true, the result is a Readable that
|
|
446
|
+
// emits boundary headers + content + CRLF in order. Avoids the
|
|
447
|
+
// Buffer.concat() OOM class on large uploads. contentLength is
|
|
448
|
+
// a finite number when every source's size is statically
|
|
449
|
+
// resolvable (Buffer length, fs.statSync().size, opts.size on
|
|
450
|
+
// a stream entry); null otherwise — caller falls back to
|
|
451
|
+
// chunked transfer.
|
|
452
|
+
//
|
|
453
|
+
// File entry shapes (all require `field`):
|
|
454
|
+
//
|
|
455
|
+
// { field, content: Buffer | string } — in-memory (existing)
|
|
456
|
+
// { field, filePath: string } — stream-from-disk
|
|
457
|
+
// { field, stream: Readable, size?: number } — operator-supplied stream
|
|
458
|
+
//
|
|
459
|
+
// `filename` and `contentType` apply to all three shapes; for
|
|
460
|
+
// `filePath` entries, `filename` defaults to path.basename(filePath).
|
|
429
461
|
function _buildMultipartBody(spec) {
|
|
430
462
|
var boundary = "----blamejs-mp-" + crypto.generateToken(C.BYTES.bytes(16));
|
|
431
463
|
var CRLF = "\r\n";
|
|
432
|
-
var
|
|
464
|
+
var fs = require("fs"); // allow:inline-require — only on multipart paths that touch the filesystem
|
|
465
|
+
var path = require("path"); // allow:inline-require — same
|
|
466
|
+
var nodeStream = require("stream"); // allow:inline-require — Readable subclass only when streaming
|
|
467
|
+
|
|
468
|
+
// Each entry is { headerBytes, source } where source is one of:
|
|
469
|
+
// { kind: "buffer", buf: Buffer }
|
|
470
|
+
// { kind: "filePath", filePath: string, size: number }
|
|
471
|
+
// { kind: "stream", stream: Readable, size: number | null }
|
|
472
|
+
var entries = [];
|
|
473
|
+
var anyStreaming = false;
|
|
474
|
+
var totalSize = 0;
|
|
475
|
+
var sizeKnown = true;
|
|
476
|
+
|
|
477
|
+
function _entryHeaderBytes(disposition, contentType) {
|
|
478
|
+
var head = "--" + boundary + CRLF + disposition + CRLF;
|
|
479
|
+
if (contentType) head += "Content-Type: " + contentType + CRLF;
|
|
480
|
+
head += CRLF;
|
|
481
|
+
return Buffer.from(head, "utf8");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _addEntry(headerBytes, source) {
|
|
485
|
+
entries.push({ header: headerBytes, source: source });
|
|
486
|
+
totalSize += headerBytes.length;
|
|
487
|
+
if (source.kind === "buffer") {
|
|
488
|
+
totalSize += source.buf.length;
|
|
489
|
+
} else if (typeof source.size === "number" && isFinite(source.size) && source.size >= 0) {
|
|
490
|
+
totalSize += source.size;
|
|
491
|
+
} else {
|
|
492
|
+
sizeKnown = false;
|
|
493
|
+
}
|
|
494
|
+
totalSize += CRLF.length;
|
|
495
|
+
}
|
|
433
496
|
|
|
434
497
|
function _pushField(name, value) {
|
|
435
498
|
if (typeof name !== "string" || name.length === 0) {
|
|
436
499
|
throw new Error("multipart: field name must be a non-empty string");
|
|
437
500
|
}
|
|
438
|
-
var
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
parts.push(Buffer.from(CRLF, "utf8"));
|
|
501
|
+
var disposition = 'Content-Disposition: form-data; name="' + name + '"';
|
|
502
|
+
var head = _entryHeaderBytes(disposition, null);
|
|
503
|
+
var bodyBuf = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
|
|
504
|
+
_addEntry(head, { kind: "buffer", buf: bodyBuf });
|
|
443
505
|
}
|
|
444
506
|
|
|
445
507
|
function _pushFile(file) {
|
|
@@ -447,21 +509,50 @@ function _buildMultipartBody(spec) {
|
|
|
447
509
|
if (typeof file.field !== "string" || file.field.length === 0) {
|
|
448
510
|
throw new Error("multipart: file.field must be a non-empty string");
|
|
449
511
|
}
|
|
450
|
-
var
|
|
451
|
-
|
|
512
|
+
var hasContent = file.content !== undefined && file.content !== null;
|
|
513
|
+
var hasFilePath = typeof file.filePath === "string" && file.filePath.length > 0;
|
|
514
|
+
var hasStream = file.stream && typeof file.stream.pipe === "function";
|
|
515
|
+
var sourceCount = (hasContent ? 1 : 0) + (hasFilePath ? 1 : 0) + (hasStream ? 1 : 0);
|
|
516
|
+
if (sourceCount === 0) {
|
|
517
|
+
throw new Error("multipart: file entry requires one of { content, filePath, stream }");
|
|
518
|
+
}
|
|
519
|
+
if (sourceCount > 1) {
|
|
520
|
+
throw new Error("multipart: file entry must have exactly one of { content, filePath, stream }");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
var filename;
|
|
524
|
+
if (typeof file.filename === "string" && file.filename.length > 0) {
|
|
525
|
+
filename = file.filename;
|
|
526
|
+
} else if (hasFilePath) {
|
|
527
|
+
filename = path.basename(file.filePath);
|
|
528
|
+
} else {
|
|
529
|
+
filename = "blob";
|
|
530
|
+
}
|
|
452
531
|
var mimeType = file.contentType || file.mimeType || "application/octet-stream";
|
|
453
|
-
var
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
532
|
+
var disposition = 'Content-Disposition: form-data; name="' + file.field + '"' +
|
|
533
|
+
'; filename="' + filename.replace(/"/g, "%22") + '"';
|
|
534
|
+
var head = _entryHeaderBytes(disposition, mimeType);
|
|
535
|
+
|
|
536
|
+
if (hasContent) {
|
|
537
|
+
var content = file.content;
|
|
538
|
+
if (typeof content === "string") content = Buffer.from(content, "utf8");
|
|
539
|
+
if (!Buffer.isBuffer(content)) {
|
|
540
|
+
throw new Error("multipart: file.content must be a Buffer or string");
|
|
541
|
+
}
|
|
542
|
+
_addEntry(head, { kind: "buffer", buf: content });
|
|
543
|
+
} else if (hasFilePath) {
|
|
544
|
+
anyStreaming = true;
|
|
545
|
+
var st;
|
|
546
|
+
try { st = fs.statSync(file.filePath); }
|
|
547
|
+
catch (e) { throw new Error("multipart: file.filePath not readable: " + e.message); }
|
|
548
|
+
if (!st.isFile()) throw new Error("multipart: file.filePath is not a regular file");
|
|
549
|
+
_addEntry(head, { kind: "filePath", filePath: file.filePath, size: st.size });
|
|
550
|
+
} else {
|
|
551
|
+
anyStreaming = true;
|
|
552
|
+
var streamSize = (typeof file.size === "number" && isFinite(file.size) && file.size >= 0)
|
|
553
|
+
? file.size : null;
|
|
554
|
+
_addEntry(head, { kind: "stream", stream: file.stream, size: streamSize });
|
|
457
555
|
}
|
|
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
556
|
}
|
|
466
557
|
|
|
467
558
|
if (spec && spec.fields && typeof spec.fields === "object") {
|
|
@@ -479,8 +570,54 @@ function _buildMultipartBody(spec) {
|
|
|
479
570
|
if (spec && Array.isArray(spec.files)) {
|
|
480
571
|
for (var fi = 0; fi < spec.files.length; fi++) _pushFile(spec.files[fi]);
|
|
481
572
|
}
|
|
482
|
-
|
|
483
|
-
|
|
573
|
+
var trailer = Buffer.from("--" + boundary + "--" + CRLF, "utf8");
|
|
574
|
+
totalSize += trailer.length;
|
|
575
|
+
|
|
576
|
+
// All-buffer fast path — return a fully-materialized body when no
|
|
577
|
+
// streaming sources are involved AND the operator didn't ask for
|
|
578
|
+
// streaming explicitly. Existing callers that pass small in-memory
|
|
579
|
+
// payloads keep the buffer codepath.
|
|
580
|
+
if (!anyStreaming && !(spec && spec.streaming === true)) {
|
|
581
|
+
var parts = [];
|
|
582
|
+
for (var ei = 0; ei < entries.length; ei++) {
|
|
583
|
+
parts.push(entries[ei].header);
|
|
584
|
+
parts.push(entries[ei].source.buf);
|
|
585
|
+
parts.push(Buffer.from(CRLF, "utf8"));
|
|
586
|
+
}
|
|
587
|
+
parts.push(trailer);
|
|
588
|
+
return { boundary: boundary, body: Buffer.concat(parts), contentLength: totalSize };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Streaming path — produce a Readable from an async iterator that
|
|
592
|
+
// yields the bytes for each entry in order.
|
|
593
|
+
var crlfBuf = Buffer.from(CRLF, "utf8");
|
|
594
|
+
async function* _iter() {
|
|
595
|
+
for (var ix = 0; ix < entries.length; ix++) {
|
|
596
|
+
var entry = entries[ix];
|
|
597
|
+
yield entry.header;
|
|
598
|
+
if (entry.source.kind === "buffer") {
|
|
599
|
+
yield entry.source.buf;
|
|
600
|
+
} else if (entry.source.kind === "filePath") {
|
|
601
|
+
var rs = fs.createReadStream(entry.source.filePath);
|
|
602
|
+
try {
|
|
603
|
+
for await (var chunk of rs) yield chunk;
|
|
604
|
+
} finally {
|
|
605
|
+
try { rs.destroy(); } catch (_e) { /* best-effort cleanup */ }
|
|
606
|
+
}
|
|
607
|
+
} else {
|
|
608
|
+
// operator-supplied stream
|
|
609
|
+
for await (var chunk2 of entry.source.stream) yield chunk2;
|
|
610
|
+
}
|
|
611
|
+
yield crlfBuf;
|
|
612
|
+
}
|
|
613
|
+
yield trailer;
|
|
614
|
+
}
|
|
615
|
+
var body = nodeStream.Readable.from(_iter());
|
|
616
|
+
return {
|
|
617
|
+
boundary: boundary,
|
|
618
|
+
body: body,
|
|
619
|
+
contentLength: sizeKnown ? totalSize : null,
|
|
620
|
+
};
|
|
484
621
|
}
|
|
485
622
|
|
|
486
623
|
// Headers stripped on cross-origin redirect to defend against accidental
|
|
@@ -525,6 +662,10 @@ function request(opts) {
|
|
|
525
662
|
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
526
663
|
"onDownloadProgress must be a function", true));
|
|
527
664
|
}
|
|
665
|
+
if (opts.onChunk !== undefined && typeof opts.onChunk !== "function") {
|
|
666
|
+
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG",
|
|
667
|
+
"onChunk must be a function (chunk: Buffer) -> void", true));
|
|
668
|
+
}
|
|
528
669
|
if (opts.jar !== undefined && opts.jar !== null) {
|
|
529
670
|
if (typeof opts.jar !== "object" ||
|
|
530
671
|
typeof opts.jar.cookieHeaderFor !== "function" ||
|
|
@@ -567,13 +708,20 @@ function request(opts) {
|
|
|
567
708
|
catch (e) {
|
|
568
709
|
return Promise.reject(_makeError(opts.errorClass, "BAD_ARG", e.message, true));
|
|
569
710
|
}
|
|
711
|
+
var mpHeaders = Object.assign({}, opts.headers || {}, {
|
|
712
|
+
"Content-Type": "multipart/form-data; boundary=" + built.boundary,
|
|
713
|
+
});
|
|
714
|
+
// Content-Length is set when the framework can statically resolve
|
|
715
|
+
// every source's byte size. Otherwise the framework omits the
|
|
716
|
+
// header and Node's HTTP layer falls back to chunked transfer —
|
|
717
|
+
// valid HTTP/1.1, requires no operator opt-in.
|
|
718
|
+
if (typeof built.contentLength === "number" && isFinite(built.contentLength)) {
|
|
719
|
+
mpHeaders["Content-Length"] = String(built.contentLength);
|
|
720
|
+
}
|
|
570
721
|
opts = Object.assign({}, opts, {
|
|
571
|
-
method:
|
|
572
|
-
body:
|
|
573
|
-
headers:
|
|
574
|
-
"Content-Type": "multipart/form-data; boundary=" + built.boundary,
|
|
575
|
-
"Content-Length": String(built.body.length),
|
|
576
|
-
}),
|
|
722
|
+
method: opts.method || "POST",
|
|
723
|
+
body: built.body,
|
|
724
|
+
headers: mpHeaders,
|
|
577
725
|
multipart: undefined,
|
|
578
726
|
});
|
|
579
727
|
}
|
|
@@ -894,6 +1042,7 @@ function _requestH1(transport, u, opts) {
|
|
|
894
1042
|
|
|
895
1043
|
var onUploadProgress = typeof opts.onUploadProgress === "function" ? opts.onUploadProgress : null;
|
|
896
1044
|
var onDownloadProgress = typeof opts.onDownloadProgress === "function" ? opts.onDownloadProgress : null;
|
|
1045
|
+
var onChunk = typeof opts.onChunk === "function" ? opts.onChunk : null;
|
|
897
1046
|
|
|
898
1047
|
var req = transport.lib.request(reqOpts, function (res) {
|
|
899
1048
|
if (observer) observer("response:headers", { statusCode: res.statusCode, headers: res.headers });
|
|
@@ -927,13 +1076,24 @@ function _requestH1(transport, u, opts) {
|
|
|
927
1076
|
"HTTP " + res.statusCode + " " + (res.statusMessage || ""),
|
|
928
1077
|
_isPermanentStatus(res.statusCode), res.statusCode));
|
|
929
1078
|
}
|
|
930
|
-
if (onDownloadProgress) {
|
|
931
|
-
// Wrap the stream so chunks emit progress to the
|
|
932
|
-
// The framework's contract is to hand back the
|
|
933
|
-
// unmodified; fix-up via a passthrough keeps
|
|
934
|
-
// observing the chunk sizes.
|
|
1079
|
+
if (onDownloadProgress || onChunk) {
|
|
1080
|
+
// Wrap the stream so chunks emit progress + onChunk to the
|
|
1081
|
+
// operator. The framework's contract is to hand back the
|
|
1082
|
+
// response stream unmodified; fix-up via a passthrough keeps
|
|
1083
|
+
// that contract while observing the chunk sizes. onChunk
|
|
1084
|
+
// gets the buffer itself (for hash-as-you-go); a throw from
|
|
1085
|
+
// it is caught and dropped so a hash-mismatch detector can
|
|
1086
|
+
// raise without breaking the response stream — caller
|
|
1087
|
+
// surfaces the error through their own pipe handler.
|
|
935
1088
|
var passthrough = new nodeStream.PassThrough();
|
|
936
|
-
res.on("data", function (chunk) {
|
|
1089
|
+
res.on("data", function (chunk) {
|
|
1090
|
+
_emitDownload(chunk.length);
|
|
1091
|
+
if (onChunk) {
|
|
1092
|
+
try { onChunk(chunk); }
|
|
1093
|
+
catch (_e) { /* operator-supplied hook — drop-silent */ }
|
|
1094
|
+
}
|
|
1095
|
+
passthrough.write(chunk);
|
|
1096
|
+
});
|
|
937
1097
|
res.on("end", function () { passthrough.end(); });
|
|
938
1098
|
res.on("error", function (e) { passthrough.destroy(e); });
|
|
939
1099
|
return _resolve({ statusCode: res.statusCode, headers: res.headers, body: passthrough });
|
|
@@ -955,6 +1115,10 @@ function _requestH1(transport, u, opts) {
|
|
|
955
1115
|
return;
|
|
956
1116
|
}
|
|
957
1117
|
_emitDownload(chunk.length);
|
|
1118
|
+
if (onChunk) {
|
|
1119
|
+
try { onChunk(chunk); }
|
|
1120
|
+
catch (_e) { /* operator-supplied hook — drop-silent */ }
|
|
1121
|
+
}
|
|
958
1122
|
});
|
|
959
1123
|
res.on("end", function () {
|
|
960
1124
|
if (capExceeded) return;
|
|
@@ -1072,6 +1236,7 @@ function _requestH2(transport, u, opts) {
|
|
|
1072
1236
|
(method === "GET" ? DEFAULT_GET_CAP : DEFAULT_CONTROL_PLANE_CAP);
|
|
1073
1237
|
var observer = typeof opts.observer === "function" ? opts.observer : null;
|
|
1074
1238
|
var startedAt = Date.now();
|
|
1239
|
+
var onChunkH2 = typeof opts.onChunk === "function" ? opts.onChunk : null;
|
|
1075
1240
|
|
|
1076
1241
|
var signal = safeAsync.withTimeoutSignal(opts.signal || null, opts.timeoutMs);
|
|
1077
1242
|
if (signal && signal.aborted) {
|
|
@@ -1128,6 +1293,17 @@ function _requestH2(transport, u, opts) {
|
|
|
1128
1293
|
return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
|
|
1129
1294
|
"HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
|
|
1130
1295
|
}
|
|
1296
|
+
if (onChunkH2) {
|
|
1297
|
+
var passthroughH2 = new nodeStream.PassThrough();
|
|
1298
|
+
stream.on("data", function (chunk) {
|
|
1299
|
+
try { onChunkH2(chunk); }
|
|
1300
|
+
catch (_e) { /* operator-supplied hook — drop-silent */ }
|
|
1301
|
+
passthroughH2.write(chunk);
|
|
1302
|
+
});
|
|
1303
|
+
stream.on("end", function () { passthroughH2.end(); });
|
|
1304
|
+
stream.on("error", function (e) { passthroughH2.destroy(e); });
|
|
1305
|
+
return _resolve({ statusCode: statusCode, headers: responseHeaders, body: passthroughH2 });
|
|
1306
|
+
}
|
|
1131
1307
|
return _resolve({ statusCode: statusCode, headers: responseHeaders, body: stream });
|
|
1132
1308
|
}
|
|
1133
1309
|
|
|
@@ -1142,6 +1318,11 @@ function _requestH2(transport, u, opts) {
|
|
|
1142
1318
|
try { stream.close(http2.constants.NGHTTP2_CANCEL); } catch (_e2) { /* best-effort h2 stream cancel */ }
|
|
1143
1319
|
_reject(_makeError(opts.errorClass, "RESPONSE_TOO_LARGE",
|
|
1144
1320
|
"response body exceeds " + maxResponseBytes + " bytes", true));
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (onChunkH2) {
|
|
1324
|
+
try { onChunkH2(chunk); }
|
|
1325
|
+
catch (_e) { /* operator-supplied hook — drop-silent */ }
|
|
1145
1326
|
}
|
|
1146
1327
|
});
|
|
1147
1328
|
stream.on("end", function () {
|
package/lib/websocket.js
CHANGED
|
@@ -108,6 +108,36 @@ var GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
108
108
|
// catches the typo class.
|
|
109
109
|
var GUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
110
110
|
|
|
111
|
+
// Credential-shaped query parameter names refused at upgrade time. URL
|
|
112
|
+
// query strings end up in: web-server access logs, the browser's
|
|
113
|
+
// history + Referer header forwarded to third-party CDN / analytics
|
|
114
|
+
// requests, in-process / proxy log captures, and crash dumps. Any
|
|
115
|
+
// authentication credential placed in the query string is leaked
|
|
116
|
+
// through one of those channels by default. RFC 6750 §2.3 explicitly
|
|
117
|
+
// cautions against bearer tokens in URI query parameters for exactly
|
|
118
|
+
// these reasons.
|
|
119
|
+
//
|
|
120
|
+
// Operators with a non-credential query parameter that happens to
|
|
121
|
+
// match one of these names (e.g. an "apikey" field passed to a
|
|
122
|
+
// downstream tenant API by mistake) opt out per route via
|
|
123
|
+
// `opts.allowQueryAuthParams: true` with an audited operator reason —
|
|
124
|
+
// the lift exists, but the operator owns the audit trail.
|
|
125
|
+
//
|
|
126
|
+
// The list is deliberately narrow — overloaded names like `token`,
|
|
127
|
+
// `auth`, `key`, `session` have non-credential meanings (CSRF tokens,
|
|
128
|
+
// file-share tokens, ICE candidates, session-resume identifiers) and
|
|
129
|
+
// would create false-positive friction without closing a genuine
|
|
130
|
+
// leak vector. The names below are unambiguously credential-shaped.
|
|
131
|
+
var REFUSED_AUTH_QUERY_PARAMS = Object.freeze([
|
|
132
|
+
"access_token", // OAuth 2.0 bearer (RFC 6750)
|
|
133
|
+
"bearer", // synonym
|
|
134
|
+
"bearer_token", // synonym
|
|
135
|
+
"apikey", // common convention
|
|
136
|
+
"api_key", // common convention
|
|
137
|
+
"api-key", // common convention
|
|
138
|
+
"authorization", // literal Authorization-header value
|
|
139
|
+
]);
|
|
140
|
+
|
|
111
141
|
var OPCODE_CONTINUATION = 0x0;
|
|
112
142
|
var OPCODE_TEXT = 0x1;
|
|
113
143
|
var OPCODE_BINARY = 0x2;
|
|
@@ -186,7 +216,7 @@ function computeAcceptKey(secWebSocketKey, handshakeGuid) {
|
|
|
186
216
|
return hash.digest("base64");
|
|
187
217
|
}
|
|
188
218
|
|
|
189
|
-
function validateUpgradeRequest(req) {
|
|
219
|
+
function validateUpgradeRequest(req, opts) {
|
|
190
220
|
if (req.method !== "GET") {
|
|
191
221
|
return { ok: false, status: HTTP.METHOD_NOT_ALLOWED, reason: "method must be GET" };
|
|
192
222
|
}
|
|
@@ -205,9 +235,54 @@ function validateUpgradeRequest(req) {
|
|
|
205
235
|
if (h["sec-websocket-version"] !== "13") {
|
|
206
236
|
return { ok: false, status: HTTP.BAD_REQUEST, reason: "Sec-WebSocket-Version must be 13" };
|
|
207
237
|
}
|
|
238
|
+
if (!(opts && opts.allowQueryAuthParams === true)) {
|
|
239
|
+
var leaked = _findCredentialQueryParam(req.url);
|
|
240
|
+
if (leaked) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: HTTP.BAD_REQUEST,
|
|
244
|
+
reason: "credential-shaped query parameter '" + leaked +
|
|
245
|
+
"' refused — query strings leak via logs / Referer / history. " +
|
|
246
|
+
"Move the credential to the Authorization header, or set " +
|
|
247
|
+
"opts.allowQueryAuthParams: true with an audited operator reason " +
|
|
248
|
+
"if this parameter is not actually a credential.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
208
252
|
return { ok: true };
|
|
209
253
|
}
|
|
210
254
|
|
|
255
|
+
// _findCredentialQueryParam walks the request's query string and
|
|
256
|
+
// returns the first credential-shaped parameter name it finds, or
|
|
257
|
+
// null. Comparison is case-insensitive; an attacker who URL-encodes
|
|
258
|
+
// the parameter name (e.g. "%41ccess_token") still hits the check
|
|
259
|
+
// because URL parsing decodes the name before comparison.
|
|
260
|
+
function _findCredentialQueryParam(reqUrl) {
|
|
261
|
+
if (typeof reqUrl !== "string" || reqUrl.length === 0) return null;
|
|
262
|
+
var qIdx = reqUrl.indexOf("?");
|
|
263
|
+
if (qIdx === -1) return null;
|
|
264
|
+
var query = reqUrl.slice(qIdx + 1);
|
|
265
|
+
// Strip a fragment if any (defensive — real HTTP requests don't carry
|
|
266
|
+
// one, but req.url has been observed with appended fragments behind
|
|
267
|
+
// misconfigured proxies).
|
|
268
|
+
var fIdx = query.indexOf("#");
|
|
269
|
+
if (fIdx !== -1) query = query.slice(0, fIdx);
|
|
270
|
+
if (query.length === 0) return null;
|
|
271
|
+
var pairs = query.split("&");
|
|
272
|
+
for (var p = 0; p < pairs.length; p++) {
|
|
273
|
+
var eqIdx = pairs[p].indexOf("=");
|
|
274
|
+
var rawName = eqIdx === -1 ? pairs[p] : pairs[p].slice(0, eqIdx);
|
|
275
|
+
if (rawName.length === 0) continue;
|
|
276
|
+
var name;
|
|
277
|
+
try { name = decodeURIComponent(rawName).toLowerCase(); }
|
|
278
|
+
catch (_e) { name = rawName.toLowerCase(); }
|
|
279
|
+
for (var r = 0; r < REFUSED_AUTH_QUERY_PARAMS.length; r++) {
|
|
280
|
+
if (name === REFUSED_AUTH_QUERY_PARAMS[r]) return name;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
211
286
|
function negotiateSubprotocol(req, supported) {
|
|
212
287
|
if (!supported || supported.length === 0) return null;
|
|
213
288
|
var raw = (req.headers || {})["sec-websocket-protocol"] || "";
|
|
@@ -885,9 +960,9 @@ function handleUpgrade(req, socket, head, opts) {
|
|
|
885
960
|
// Validate handshake first — refusing here writes a plain HTTP/1.1
|
|
886
961
|
// response and closes the socket, matching what the upgrade-event
|
|
887
962
|
// consumer would expect for a malformed request.
|
|
888
|
-
var v = validateUpgradeRequest(req);
|
|
963
|
+
var v = validateUpgradeRequest(req, opts);
|
|
889
964
|
if (!v.ok) {
|
|
890
|
-
_refuseUpgrade(socket, v.status || 400, v.reason);
|
|
965
|
+
_refuseUpgrade(socket, v.status || 400, v.reason); // allow:raw-byte-literal — HTTP 400 fallback
|
|
891
966
|
return null;
|
|
892
967
|
}
|
|
893
968
|
|
|
@@ -1047,6 +1122,7 @@ module.exports = {
|
|
|
1047
1122
|
handleExtendedConnect: handleExtendedConnect, // h2 — RFC 8441 Extended CONNECT
|
|
1048
1123
|
// Constants
|
|
1049
1124
|
GUID: GUID,
|
|
1125
|
+
REFUSED_AUTH_QUERY_PARAMS: REFUSED_AUTH_QUERY_PARAMS,
|
|
1050
1126
|
OPCODE_CONTINUATION: OPCODE_CONTINUATION,
|
|
1051
1127
|
OPCODE_TEXT: OPCODE_TEXT,
|
|
1052
1128
|
OPCODE_BINARY: OPCODE_BINARY,
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:52762d7e-86ab-4e69-9636-eacc1acc2d2d",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-07T04:52:07.494Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.8.13",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.13",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.8.13",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.8.
|
|
57
|
+
"ref": "@blamejs/core@0.8.13",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|