@blamejs/core 0.8.36 → 0.8.38

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 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
+ - v0.8.38 (2026-05-07) — multipart parser refuses obsolete line folding (RFC 9112 §5.2 obs-fold) and CR/LF/NUL bytes in part-header values (RFC 9110 §5.5). Adds RFC 5987 / 8187 `filename*=UTF-8''…` extended-parameter support; the decoded value takes precedence over a legacy `filename=` companion.
12
+
13
+ - **0.8.37** (2026-05-08) — D-L family SQL/schema hygiene. **`_blamejs_audit_purge_anchor.scope` CHECK constraint** — `scope IN ('audit', 'consent')`. Pre-v0.8.37 a typo silently created a parallel anchor; the chain verifier walked the wrong anchor and missed tampering. **`personalDataCategories` vocabulary validation** — operator-supplied categories validated against the GDPR Article 9 special-category vocabulary + the framework's general categories at `db.init`. Unknown categories don't refuse (operators have legitimate custom labels) but emit a `db.personal_data_category_unknown` audit row so typos surface in regulator reviews. Allowed: `name`, `email`, `phone`, `address`, `ip`, `id-document`, `biometric`, `health`, `genetic`, `sexual-orientation`, `racial-or-ethnic-origin`, `political-opinion`, `religious-belief`, `trade-union-membership`, `criminal-record`, `financial`, `location`, `behavioral`, `device-id`, `child-data`, `education`, `employment`, `operator-defined`.
14
+
11
15
  - **0.8.36** (2026-05-08) — HTTP/web G-class LOW cleanup + scope-aware bearer challenge. **WS handshake (RFC 6455 §4.1)** — `Sec-WebSocket-Key` validated as base64 of 16 random bytes (`/^[A-Za-z0-9+/]{22}==$/`). Pre-v0.8.36 only the presence was checked; truncated / arbitrary-token values flowed through. **Permissions-Policy default** — `fullscreen` flipped to `()` (deny) instead of `(self)`; operators wanting fullscreen pass an explicit override. **`b.middleware.bearerAuth` insufficient_scope (RFC 6750 §3)** — new `requiredScopes: ["scope1", "scope2"]` opt enforces operator-declared scopes. Token's `user.scope` (string, space-separated) or `user.scopes` (array) is checked; missing scopes refuse with HTTP 403 + `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`. **`b.requestHelpers.parseListHeader({strictToken: true})`** — RFC 9110 §5.6.2 token-grammar enforcement. Refuses non-token entries (anything outside `!#$%&'*+-.^_\\`|~` + alnum). **Multipart boundary validation (RFC 2046 §5.1.1)** — `_parseMultipart` refuses boundaries longer than 70 chars OR violating the `bcharsnospace` grammar. Closes the quadratic-match risk on pathological boundaries.
12
16
 
13
17
  - **0.8.35** (2026-05-08) — `b.tcpa10dlc` + `b.iabMspa` primitives. **`b.tcpa10dlc`** — TCPA 10DLC consent-record audit. 47 USC §227 + 47 CFR §64.1200 + FCC 1:1 disclosure rule (effective 2025-01-27, vacated 11th Circuit IMC v. FCC 2025 but TCPA standard still applies). $500-$1,500/violation exposure. `recordConsent({phoneE164, brand, disclosureText, disclosurePartyKind, formUrl, ip, userAgent})` writes a tamper-evident audit row with the carrier-required fields; `lookup(phoneE164)` for the carrier "produce-on-demand" workflow; `revoke(phoneE164, reason)` records consumer-initiated opt-out with audit trail. **`b.iabMspa`** — IAB Multi-State Privacy Agreement / Global Privacy Platform (GPP) universal opt-out signal codec. `parseGpp(gppString)` decodes the framing (header + per-section payloads, section-id mapping for usnat / usca / usva / usco / usct / usut / usnv / usia / usde / usnj / ustx / usor / usmt / usnh). `checkOptOut(parsed, {dataUse, state})` returns `{ mustHonor, signals }` against operator-decoded section opt-outs (sale / sharing / targeted-ads / sensitive / child-data). `refuseProcessing(parsed, opts)` throws `IabMspaError` to halt the operator's data-flow at the same point a CCPA "do-not-sell" header would. `gpcFromHeaders(req)` reads the W3C `Sec-GPC: 1` browser signal — universal opt-out per CCPA / CPRA §1798.135(b)(1) and similar state laws.
package/lib/db.js CHANGED
@@ -245,7 +245,12 @@ var FRAMEWORK_SCHEMA = [
245
245
  {
246
246
  name: "_blamejs_audit_purge_anchor",
247
247
  columns: {
248
- scope: "TEXT PRIMARY KEY",
248
+ // CHECK constraint: scope is one of the framework's audit-
249
+ // chain anchor scopes (`audit` / `consent`). Pre-v0.8.37 a
250
+ // typo silently created a parallel anchor; the chain verifier
251
+ // walked the wrong anchor and missed tampering. The CHECK
252
+ // refuses unknown scope strings at INSERT time.
253
+ scope: "TEXT PRIMARY KEY CHECK (scope IN ('audit', 'consent'))",
249
254
  lastPurgedCounter: "INTEGER NOT NULL",
250
255
  lastPurgedRowHash: "TEXT NOT NULL",
251
256
  archiveBundleId: "TEXT NOT NULL",
@@ -780,6 +785,56 @@ async function init(opts) {
780
785
  for (var si = 0; si < opts.schema.length; si++) {
781
786
  var st = opts.schema[si];
782
787
  if (st.subjectField) {
788
+ // Validate personalDataCategories shape + audit-emit on
789
+ // unknown vocabulary. Pre-v0.8.37 this was a free-form JSON
790
+ // blob; a typo silently dropped the column from subject-export
791
+ // / erase walks. The framework checks the value is a string
792
+ // (catches null / number / object typos) and emits a warning
793
+ // audit when the category is outside the GDPR Art 9 + general
794
+ // vocabulary so operators can audit-trail their custom labels.
795
+ if (st.personalDataCategories) {
796
+ if (typeof st.personalDataCategories !== "object" || Array.isArray(st.personalDataCategories)) {
797
+ throw new DbError("db/bad-personal-data-categories",
798
+ "table '" + st.name + "': personalDataCategories must be an object mapping field name → category");
799
+ }
800
+ var FRAMEWORK_CATEGORY_VOCAB = [
801
+ "name", "email", "phone", "address", "ip", "id-document",
802
+ "biometric", "health", "genetic", "sexual-orientation",
803
+ "racial-or-ethnic-origin", "political-opinion", "religious-belief",
804
+ "trade-union-membership", "criminal-record",
805
+ "financial", "location", "behavioral", "device-id",
806
+ "child-data", "education", "employment", "operator-defined",
807
+ ];
808
+ Object.keys(st.personalDataCategories).forEach(function (field) {
809
+ var cat = st.personalDataCategories[field];
810
+ if (typeof cat !== "string" || cat.length === 0) {
811
+ throw new DbError("db/bad-personal-data-category",
812
+ "table '" + st.name + "' field '" + field +
813
+ "': category must be a non-empty string");
814
+ }
815
+ if (FRAMEWORK_CATEGORY_VOCAB.indexOf(cat) === -1) {
816
+ // Unknown — emit a one-time audit per (table,field,category)
817
+ // tuple so operators see typos in their categorical
818
+ // taxonomy. Lazy require to avoid circular load (audit
819
+ // imports db for chain hashing).
820
+ try {
821
+ var auditMod = require("./audit"); // allow:inline-require — circular-load defense (audit imports db)
822
+ auditMod.safeEmit({
823
+ action: "db.personal_data_category_unknown",
824
+ outcome: "success",
825
+ metadata: {
826
+ severity: "warning",
827
+ table: st.name,
828
+ field: field,
829
+ category: cat,
830
+ vocabHint: "use one of: " + FRAMEWORK_CATEGORY_VOCAB.join(", ") +
831
+ " (or operator-defined for genuinely-custom)",
832
+ },
833
+ });
834
+ } catch (_e) { /* drop-silent */ }
835
+ }
836
+ });
837
+ }
783
838
  subjectTables.push({
784
839
  name: st.name,
785
840
  subjectField: st.subjectField,
@@ -526,27 +526,75 @@ function _sanitizeFilename(name) {
526
526
  function _parseMultipartHeaders(rawHeaders) {
527
527
  // Each line is `Header-Name: value`. Common headers: Content-Disposition,
528
528
  // Content-Type, Content-Transfer-Encoding. Unknown headers are ignored.
529
+ // RFC 9112 §5.2 — line folding (obs-fold) is OBSOLETE in HTTP messages;
530
+ // a continuation line beginning with SP/HTAB MUST be refused. RFC 9110
531
+ // §5.5 — header field values MUST NOT contain CR, LF, or NUL bytes.
532
+ // We refuse the part outright (caller surfaces the throw as 400 + drop).
529
533
  var lines = rawHeaders.split("\r\n");
530
534
  var out = {};
531
535
  for (var i = 0; i < lines.length; i++) {
532
536
  var line = lines[i];
533
537
  if (!line) continue;
538
+ var first = line.charCodeAt(0);
539
+ if (first === 32 || first === 9) { // allow:raw-byte-literal — SP/HTAB obs-fold sentinels
540
+ throw new BodyParserError(
541
+ "body-parser/multipart-obs-fold",
542
+ "multipart part header uses obsolete line folding (RFC 9112 §5.2)",
543
+ true, HTTP_STATUS.BAD_REQUEST
544
+ );
545
+ }
534
546
  var idx = line.indexOf(":");
535
547
  if (idx === -1) continue;
536
548
  var k = line.slice(0, idx).trim().toLowerCase();
537
549
  var v = line.slice(idx + 1).trim();
550
+ for (var j = 0; j < v.length; j++) {
551
+ var c = v.charCodeAt(j);
552
+ if (c === 0 || c === 10 || c === 13) { // allow:raw-byte-literal — NUL/LF/CR forbidden in field-value (RFC 9110 §5.5)
553
+ throw new BodyParserError(
554
+ "body-parser/multipart-bad-header-value",
555
+ "multipart part header `" + k + "` contains CR/LF/NUL (RFC 9110 §5.5)",
556
+ true, HTTP_STATUS.BAD_REQUEST
557
+ );
558
+ }
559
+ }
538
560
  out[k] = v;
539
561
  }
540
562
  return out;
541
563
  }
542
564
 
565
+ // RFC 5987 / 8187 — `filename*=UTF-8''percent%20encoded.txt` extended
566
+ // parameter form for non-ASCII filenames. Charset MUST be `UTF-8`
567
+ // (case-insensitive); we refuse other charsets to keep the decode
568
+ // path single-encoding. Language tag (between the two `'`s) is
569
+ // permitted but ignored.
570
+ function _decodeRfc5987(raw) {
571
+ if (typeof raw !== "string") return null;
572
+ var firstTick = raw.indexOf("'");
573
+ if (firstTick === -1) return null;
574
+ var secondTick = raw.indexOf("'", firstTick + 1);
575
+ if (secondTick === -1) return null;
576
+ var charset = raw.slice(0, firstTick).toLowerCase();
577
+ if (charset !== "utf-8") return null; // RFC 5987 mandated charset; refuse anything else
578
+ var encoded = raw.slice(secondTick + 1);
579
+ try {
580
+ return decodeURIComponent(encoded);
581
+ } catch (_e) {
582
+ return null;
583
+ }
584
+ }
585
+
543
586
  function _parseHeaderParams(headerValue) {
544
587
  // Content-Disposition: form-data; name="field"; filename="x.txt"
545
588
  // Returns { _value: "form-data", name: "field", filename: "x.txt" }
589
+ // RFC 5987 / 8187 — when a `filename*=UTF-8''...` extended parameter
590
+ // is present, it takes precedence over the legacy `filename=`
591
+ // companion (RFC 6266 §4.3). We surface the decoded value at
592
+ // `filename` so downstream consumers don't need parser-aware code.
546
593
  var out = { _value: "" };
547
594
  if (!headerValue) return out;
548
595
  var parts = headerValue.split(";");
549
596
  out._value = parts[0].trim().toLowerCase();
597
+ var extName = null;
550
598
  for (var i = 1; i < parts.length; i++) {
551
599
  var p = parts[i].trim();
552
600
  var eq = p.indexOf("=");
@@ -554,8 +602,18 @@ function _parseHeaderParams(headerValue) {
554
602
  var k = p.slice(0, eq).trim().toLowerCase();
555
603
  var v = p.slice(eq + 1).trim();
556
604
  if (v.length >= 2 && v[0] === '"' && v[v.length - 1] === '"') v = v.slice(1, -1);
605
+ if (k.charAt(k.length - 1) === "*") {
606
+ var decoded = _decodeRfc5987(v);
607
+ if (decoded !== null) {
608
+ var bareKey = k.slice(0, -1);
609
+ if (bareKey === "filename") extName = decoded;
610
+ out[bareKey] = decoded;
611
+ }
612
+ continue;
613
+ }
557
614
  out[k] = v;
558
615
  }
616
+ if (extName !== null) out.filename = extName;
559
617
  return out;
560
618
  }
561
619
 
@@ -751,7 +809,12 @@ async function _parseMultipart(req, opts, ctParams) {
751
809
  true, HTTP_STATUS.PAYLOAD_TOO_LARGE));
752
810
  return;
753
811
  }
754
- currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
812
+ try {
813
+ currentHeaders = _parseMultipartHeaders(pending.slice(0, headEnd).toString("utf8"));
814
+ } catch (parseErr) {
815
+ done(parseErr);
816
+ return;
817
+ }
755
818
  pending = pending.slice(headEnd + 4);
756
819
  // Decode Content-Disposition.
757
820
  var cd = _parseHeaderParams(currentHeaders["content-disposition"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.36",
3
+ "version": "0.8.38",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -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:bc456840-498c-445d-b9a6-d087f3ab5715",
5
+ "serialNumber": "urn:uuid:33e4b58d-3cab-4ba0-b54a-4fd4a9b62e38",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T15:39:09.436Z",
8
+ "timestamp": "2026-05-07T16:03:53.948Z",
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.36",
22
+ "bom-ref": "@blamejs/core@0.8.38",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.36",
25
+ "version": "0.8.38",
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.36",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.38",
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.36",
57
+ "ref": "@blamejs/core@0.8.38",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]