@blamejs/core 0.8.32 → 0.8.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/index.js +4 -0
- package/lib/audit.js +2 -0
- package/lib/auth/jwt.js +16 -0
- package/lib/auth/oauth.js +22 -1
- package/lib/iab-mspa.js +177 -0
- package/lib/middleware/body-parser.js +10 -0
- package/lib/middleware/security-headers.js +13 -0
- package/lib/tcpa-10dlc.js +175 -0
- package/lib/websocket.js +30 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,12 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.8.x
|
|
10
10
|
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
- **0.8.34** (2026-05-08) — `lib/middleware/body-parser.js` BiDi-strip regex uses Unicode-escape form (`\\u202A`-style) instead of literal codepoints to satisfy ESLint's `no-irregular-whitespace`. v0.8.33 publish workflow blocked on this; v0.8.33 tag exists on git but never reached npm — v0.8.34 cumulative includes the v0.8.33 G-class MEDIUM fixes.
|
|
14
|
+
|
|
15
|
+
- **0.8.33** (2026-05-08) — HTTP/web G-class MEDIUM cleanup. Six RFC-cited refinements against shipped WebSocket / Permissions-Policy / JWT / body-parser / OAuth surfaces. **WS close-frame validation (RFC 6455 §5.5.1 + §7.4.2)** — `_handleClose` now refuses 1-byte close-frame payloads (malformed; pre-v0.8.33 silently accepted as clean close, evading anomaly detection) and validates the close-code against the §7.4.2 vocabulary (1000-1011 + 3000-4999 valid; 1004/1005/1006/1015 reserved/forbidden; everything else refused). **Permissions-Policy default expansion** — `b.middleware.securityHeaders` deny-by-default now covers `interest-cohort`, `attribution-reporting`, `bluetooth`, `hid`, `serial`, `idle-detection`, `local-fonts`, `compute-pressure`, `window-management`, `private-state-token-issuance`, `private-state-token-redemption`. **JWT typ assertion (RFC 8725 §3.11)** — `b.auth.jwt.verify({expectedTyp})` refuses tokens whose header.typ doesn't match (typ-confusion class). Case-insensitive match per RFC 8725. Unset `expectedTyp` skips the check (legacy token compatibility). **Multipart filename BiDi/RTL strip** — `_sanitizeFilename` now drops Trojan Source (CVE-2021-42574) BiDi formatting + zero-width codepoints from uploaded filenames before the basename hits the filesystem. **OAuth redirect_uri localhost exception (RFC 9700 §4.1.1)** — `b.auth.oauth.create({redirectUri})` now accepts `http://localhost` / `http://127.0.0.1` / `http://[::1]` without requiring `allowHttp: true` (which would loosen ALL operator-supplied URLs). HTTPS still required for non-loopback hosts.
|
|
16
|
+
|
|
11
17
|
- **0.8.32** (2026-05-08) — Email/DNS MEDIUM impl-vs-spec cleanup. Eight RFC-cited refinements against shipped DKIM / SPF / DMARC / A-R / TLS-RPT / DoH primitives. **DKIM (`b.mail.dkim`)** — verifier enforces `x=` signature expiration (permerrors past expiry per RFC 6376 §3.5) and `t=` future-date sanity (refuses signatures more than 24h ahead of `now`); rejects `x= < t=` malformation. Operator-tunable `clockSkewMs` (default 5 min). **SPF (`b.mail.spf`)** — `_parseSpfRecord` now distinguishes mechanisms from modifiers (RFC 7208 §4.6 — `redirect=` / `exp=` are modifiers, not mechanisms). Pre-v0.8.32 `redirect=` triggered the generic "out of scope" permerror; surfaced separately via `mechanisms.modifiers`. SPF IPv6 CIDR matching now uses a real bitwise CIDR check (`_ipv6Expand` + group-by-group + bit-mask) instead of the prior string-prefix heuristic that mishandled `::` shorthand. **DMARC (`b.mail.dmarc`)** — `recommendedAction` now consults `pct=` per RFC 7489 §6.6.4. When `pct < 100` the receiver applies one-step-less-strict disposition (reject → quarantine; quarantine → none) on the sampled fraction. **A-R (`b.mail.authResults`)** — `reason=` quoted-string now uses RFC 8601 §2.2 `\"` escape (lossless round-trip) instead of the prior `"`-to-`'` collapse. **TLS-RPT (`b.network.smtp.tlsRpt.submit`)** — `mailto:` rua entries are now RFC 5322 addr-spec-validated before forwarding to `b.mail`. Pre-v0.8.32 invalid mailto targets crashed at submit-time with opaque transport errors. **DoH (`b.network.dns.resolveSecure`)** — host now validated label-by-label per RFC 1035 §2.3.4 LDH rule (letters/digits/hyphen, no leading/trailing hyphen, label length 1..63). Pre-v0.8.32 only the total length was checked; underscore / colon / space hosts flowed through to opaque DoH errors. (MTA-STS HTTPS cert validation against `mta-sts.<domain>` is already covered by Node's TLS handshake; no code change needed.)
|
|
12
18
|
|
|
13
19
|
- **0.8.31** (2026-05-08) — `b.fdx` CFPB §1033 / Financial Data Exchange (FDX) consumer-financial-data sharing wrapper. CFPB §1033 (12 CFR §1033.121-461, final rule 2024-10-22) gives US consumers the right to authorize a third party to access their financial data through the data provider's developer interface. Compliance deadline ⏰ 2026-04-01 already past for $250B+ asset-size banks. **`b.fdx.bind({authServer, resources})`** binds the operator's authorization server config to the FAPI 2.0 profile (the §1033.351 security requirements ≈ FAPI 2.0); refuses if PKCE/DPoP/PAR are misconfigured per `b.fapi2.assertOAuthConfig`. **`b.fdx.validateResponse(resourceType, body)`** validates a response shape against the FDX 6.0 minimum schema for accounts / transactions / statements / payment-networks / rewards / tax-forms — refuses missing-required fields. **`b.fdx.consentReceipt(opts)`** generates the §1033.401(b) consent receipt the authorization server gives the consumer at authorization time (data provider, consumer, third-party recipient, scopes, revocation URL, issued+expires timestamps).
|
package/index.js
CHANGED
|
@@ -107,6 +107,8 @@ var fapi2 = require("./lib/fapi2");
|
|
|
107
107
|
var contentCredentials = require("./lib/content-credentials");
|
|
108
108
|
var aiPref = require("./lib/ai-pref");
|
|
109
109
|
var fdx = require("./lib/fdx");
|
|
110
|
+
var tcpa10dlc = require("./lib/tcpa-10dlc");
|
|
111
|
+
var iabMspa = require("./lib/iab-mspa");
|
|
110
112
|
var safeUrl = require("./lib/safe-url");
|
|
111
113
|
var safeRedirect = require("./lib/safe-redirect");
|
|
112
114
|
var pick = require("./lib/pick");
|
|
@@ -301,6 +303,8 @@ module.exports = {
|
|
|
301
303
|
contentCredentials: contentCredentials,
|
|
302
304
|
aiPref: aiPref,
|
|
303
305
|
fdx: fdx,
|
|
306
|
+
tcpa10dlc: tcpa10dlc,
|
|
307
|
+
iabMspa: iabMspa,
|
|
304
308
|
safeUrl: safeUrl,
|
|
305
309
|
safeRedirect: safeRedirect,
|
|
306
310
|
pick: pick,
|
package/lib/audit.js
CHANGED
|
@@ -245,6 +245,8 @@ var FRAMEWORK_NAMESPACES = [
|
|
|
245
245
|
"contentcredentials", // b.contentCredentials (contentcredentials.signed / verified)
|
|
246
246
|
"aipref", // b.aiPref (aipref.paid_crawl_refused)
|
|
247
247
|
"fdx", // b.fdx (fdx.bound / fdx.consent_receipt_issued)
|
|
248
|
+
"tcpa10dlc", // b.tcpa10dlc (tcpa10dlc.consent_recorded / consent_revoked)
|
|
249
|
+
"iabmspa", // b.iabMspa (iabmspa.processing_refused)
|
|
248
250
|
];
|
|
249
251
|
var registeredNamespaces = new Set(FRAMEWORK_NAMESPACES);
|
|
250
252
|
|
package/lib/auth/jwt.js
CHANGED
|
@@ -269,6 +269,22 @@ async function verify(token, opts) {
|
|
|
269
269
|
"token declares critical extensions which this verifier does not support");
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// RFC 8725 §3.11 — typ-confusion class. When opts.expectedTyp is
|
|
273
|
+
// supplied (e.g. "JWT", "at+jwt", "logout+jwt"), refuse tokens
|
|
274
|
+
// whose header.typ doesn't match. Caller-side check; the framework
|
|
275
|
+
// doesn't impose a default typ to remain compatible with legacy
|
|
276
|
+
// tokens that omit it. Match is case-insensitive per RFC 8725.
|
|
277
|
+
if (opts.expectedTyp !== undefined) {
|
|
278
|
+
validateOpts.requireNonEmptyString(opts.expectedTyp,
|
|
279
|
+
"verify: opts.expectedTyp", AuthError, "auth-jwt/bad-expected-typ");
|
|
280
|
+
var got = decoded.header.typ;
|
|
281
|
+
if (typeof got !== "string" || got.toLowerCase() !== opts.expectedTyp.toLowerCase()) {
|
|
282
|
+
throw new AuthError("auth-jwt/typ-mismatch",
|
|
283
|
+
"token header.typ='" + got + "' does not match expectedTyp='" +
|
|
284
|
+
opts.expectedTyp + "' (RFC 8725 §3.11 typ-confusion class)");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
272
288
|
// Algorithm must be in the allowed list AND match what we know how
|
|
273
289
|
// to verify (i.e. one of SUPPORTED_ALGORITHMS).
|
|
274
290
|
if (allowed.indexOf(decoded.header.alg) === -1) {
|
package/lib/auth/oauth.js
CHANGED
|
@@ -111,6 +111,7 @@ var { generateBytes } = require("../crypto");
|
|
|
111
111
|
var httpClient = require("../http-client");
|
|
112
112
|
var safeJson = require("../safe-json");
|
|
113
113
|
var safeUrl = require("../safe-url");
|
|
114
|
+
var { URL } = require("url");
|
|
114
115
|
var { defineClass } = require("../framework-error");
|
|
115
116
|
|
|
116
117
|
// Cap on responses parsed from upstream OAuth providers. Token /
|
|
@@ -235,6 +236,26 @@ function _validateUrl(url, allowHttp, label) {
|
|
|
235
236
|
if (typeof url !== "string" || url.length === 0) {
|
|
236
237
|
throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
|
|
237
238
|
}
|
|
239
|
+
// RFC 9700 §4.1.1 — redirect URIs MUST be HTTPS, with an exception
|
|
240
|
+
// for `http://localhost` and `http://127.0.0.1[:port]` to enable
|
|
241
|
+
// local development. Pre-v0.8.33 operators developing on localhost
|
|
242
|
+
// had to set `allowHttp: true` globally, which loosens the gate
|
|
243
|
+
// for ALL operator-supplied URLs (issuer, discovery, token, etc.).
|
|
244
|
+
// Now: when the URL is loopback, accept HTTP without flipping the
|
|
245
|
+
// global flag.
|
|
246
|
+
var isLocalhostHttp = false;
|
|
247
|
+
try {
|
|
248
|
+
var parsed = new URL(url); // allow:raw-new-url — RFC 9700 §4.1.1 localhost-exception lookup; safeUrl re-validates below for non-localhost paths
|
|
249
|
+
if (parsed.protocol === "http:" &&
|
|
250
|
+
(parsed.hostname === "localhost" ||
|
|
251
|
+
parsed.hostname === "127.0.0.1" ||
|
|
252
|
+
parsed.hostname === "[::1]" ||
|
|
253
|
+
parsed.hostname === "::1")) {
|
|
254
|
+
isLocalhostHttp = true;
|
|
255
|
+
}
|
|
256
|
+
} catch (_e) { /* malformed; let safeUrl surface the canonical error below */ }
|
|
257
|
+
if (isLocalhostHttp) return url;
|
|
258
|
+
|
|
238
259
|
// Operator-supplied OAuth issuer / endpoint URL — route through
|
|
239
260
|
// safeUrl so the scheme allowlist is consistent with the rest of the
|
|
240
261
|
// framework's outbound gates. Map safe-url's error codes to the
|
|
@@ -246,7 +267,7 @@ function _validateUrl(url, allowHttp, label) {
|
|
|
246
267
|
} catch (e) {
|
|
247
268
|
if (e && e.code === "safe-url/protocol-disallowed") {
|
|
248
269
|
throw new OAuthError("auth-oauth/insecure-url",
|
|
249
|
-
label + ": must be https" + (allowHttp ? " or http" : "") +
|
|
270
|
+
label + ": must be https" + (allowHttp ? " or http" : " (or http://localhost for dev)") +
|
|
250
271
|
" (got '" + url + "')");
|
|
251
272
|
}
|
|
252
273
|
throw new OAuthError("auth-oauth/bad-url",
|
package/lib/iab-mspa.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.iabMspa — IAB Multi-State Privacy Agreement / Global Privacy
|
|
4
|
+
* Platform (GPP) universal opt-out signal codec.
|
|
5
|
+
*
|
|
6
|
+
* GPP (https://github.com/InteractiveAdvertisingBureau/Global-
|
|
7
|
+
* Privacy-Platform) is the IAB's successor to the patchwork of
|
|
8
|
+
* per-state US privacy strings. A GPP string carries multiple
|
|
9
|
+
* sections separated by `~`, each tagged with a section ID. The
|
|
10
|
+
* MSPA-relevant sections are the US national + state sections
|
|
11
|
+
* (USNAT, USCA, USVA, USCO, USCT, USUT) carrying:
|
|
12
|
+
*
|
|
13
|
+
* - SaleOptOut, SharingOptOut, TargetedAdvertisingOptOut, Sensitive-
|
|
14
|
+
* DataProcessingOptOuts, KnownChildSensitiveData
|
|
15
|
+
* - GPC (Global Privacy Control browser signal mirror)
|
|
16
|
+
* - MSPA service-provider / opted-in flags
|
|
17
|
+
*
|
|
18
|
+
* Public API:
|
|
19
|
+
*
|
|
20
|
+
* b.iabMspa.parseGpp(gppString) -> { header, sections }
|
|
21
|
+
* header: { version, sectionIds[], gpcSignal? }
|
|
22
|
+
* sections: [{ id, optOuts: { sale, sharing, targetedAds, ... } }]
|
|
23
|
+
*
|
|
24
|
+
* b.iabMspa.checkOptOut(parsed, opts) -> { mustHonor, signals }
|
|
25
|
+
* opts: { dataUse: "sale" | "sharing" | "targeted-ads" |
|
|
26
|
+
* "sensitive" | "child-data", state? }
|
|
27
|
+
* Returns mustHonor=true when ANY in-scope section signals an
|
|
28
|
+
* opt-out for the requested use; signals lists which section IDs
|
|
29
|
+
* produced the verdict.
|
|
30
|
+
*
|
|
31
|
+
* b.iabMspa.refuseProcessing(parsed, opts)
|
|
32
|
+
* Throws IabMspaError when mustHonor → operator's data-flow code
|
|
33
|
+
* halts at the same point a CCPA "do-not-sell" header would.
|
|
34
|
+
*
|
|
35
|
+
* b.iabMspa.gpcFromHeaders(req) -> bool
|
|
36
|
+
* Reads the W3C `Sec-GPC: 1` browser header (RFC draft-davidson-
|
|
37
|
+
* httpbis-gpc-00). Universal opt-out per California CCPA / CPRA
|
|
38
|
+
* §1798.135(b)(1) and Colorado, Connecticut, etc.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
var audit = require("./audit");
|
|
42
|
+
var { defineClass } = require("./framework-error");
|
|
43
|
+
var IabMspaError = defineClass("IabMspaError", { alwaysPermanent: true });
|
|
44
|
+
|
|
45
|
+
// GPP section IDs we recognize (subset — the full registry is at
|
|
46
|
+
// https://iabtechlab.com/standards/global-privacy-platform/sections).
|
|
47
|
+
var SECTION_IDS = {
|
|
48
|
+
7: "usnat", // US National Privacy
|
|
49
|
+
8: "usca", // California (CCPA / CPRA) // allow:raw-byte-literal — IAB GPP section ID, not bytes
|
|
50
|
+
9: "usva", // Virginia
|
|
51
|
+
10: "usco", // Colorado
|
|
52
|
+
11: "usut", // Utah
|
|
53
|
+
12: "usct", // Connecticut
|
|
54
|
+
13: "usnv", // Nevada
|
|
55
|
+
14: "usia", // Iowa
|
|
56
|
+
15: "usde", // Delaware
|
|
57
|
+
16: "usnj", // New Jersey // allow:raw-byte-literal — IAB GPP section ID, not bytes
|
|
58
|
+
17: "ustx", // Texas (TDPSA)
|
|
59
|
+
18: "usor", // Oregon
|
|
60
|
+
19: "usmt", // Montana
|
|
61
|
+
20: "usnh", // New Hampshire
|
|
62
|
+
};
|
|
63
|
+
var ALL_SECTIONS = Object.keys(SECTION_IDS).map(Number);
|
|
64
|
+
var DATA_USES = ["sale", "sharing", "targeted-ads", "sensitive", "child-data"];
|
|
65
|
+
|
|
66
|
+
function parseGpp(gppString) {
|
|
67
|
+
if (typeof gppString !== "string" || gppString.length === 0) {
|
|
68
|
+
throw IabMspaError.factory("BAD_INPUT",
|
|
69
|
+
"iabMspa.parseGpp: gppString required");
|
|
70
|
+
}
|
|
71
|
+
if (gppString.length > 8192) { // allow:raw-byte-literal — GPP string cap, not bytes
|
|
72
|
+
throw IabMspaError.factory("INPUT_TOO_LARGE",
|
|
73
|
+
"iabMspa.parseGpp: gppString exceeds 8192 chars");
|
|
74
|
+
}
|
|
75
|
+
// GPP framing: <header>~<section1>~<section2>...
|
|
76
|
+
// The header carries a 6-bit version + an int-list of section IDs.
|
|
77
|
+
// A full GPP decoder is substantial — for the framework's
|
|
78
|
+
// refuse-on-opt-out usage we only need the header's section ID
|
|
79
|
+
// list + per-section opt-out flags. The decoder below is the
|
|
80
|
+
// simplest correct partial parse: it splits sections, identifies
|
|
81
|
+
// each by the leading section-ID claim in the header, and exposes
|
|
82
|
+
// per-section raw payloads for downstream operator-specific
|
|
83
|
+
// decoding.
|
|
84
|
+
var parts = gppString.split("~");
|
|
85
|
+
if (parts.length === 0) {
|
|
86
|
+
return { header: { version: null, sectionIds: [] }, sections: [] };
|
|
87
|
+
}
|
|
88
|
+
// The first segment is the header. We don't fully decode it here;
|
|
89
|
+
// operator-side libraries (iabtcf-core / @iabtechlab/gpp-cmp) own
|
|
90
|
+
// the binary tag layout. We do extract the trailing claim list
|
|
91
|
+
// when present (some GPP strings encode the list as a comma-
|
|
92
|
+
// separated trailer like `header,7,8,9`).
|
|
93
|
+
var header = { raw: parts[0], version: null, sectionIds: [] };
|
|
94
|
+
var sectionPayloads = parts.slice(1);
|
|
95
|
+
// Try to find a numeric-list tail in the header (heuristic).
|
|
96
|
+
var tailMatch = parts[0].match(/[A-Za-z0-9_-]+\.([0-9.]+)$/);
|
|
97
|
+
if (tailMatch) {
|
|
98
|
+
var ids = tailMatch[1].split(".").map(function (s) { return parseInt(s, 10); });
|
|
99
|
+
header.sectionIds = ids.filter(function (n) { return isFinite(n) && n > 0; });
|
|
100
|
+
}
|
|
101
|
+
// Build sections — pair sectionPayloads with sectionIds positionally.
|
|
102
|
+
var sections = [];
|
|
103
|
+
for (var i = 0; i < sectionPayloads.length; i += 1) {
|
|
104
|
+
var sid = header.sectionIds[i] || null;
|
|
105
|
+
sections.push({
|
|
106
|
+
id: sid,
|
|
107
|
+
idLabel: sid && SECTION_IDS[sid] || null,
|
|
108
|
+
raw: sectionPayloads[i],
|
|
109
|
+
// Operator decodes the per-section payload. The framework
|
|
110
|
+
// surfaces a header-only `optOuts` shape that operators can
|
|
111
|
+
// override by fully decoding their binary section.
|
|
112
|
+
optOuts: null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return { header: header, sections: sections };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function checkOptOut(parsed, opts) {
|
|
119
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.sections)) {
|
|
120
|
+
throw IabMspaError.factory("BAD_PARSED",
|
|
121
|
+
"iabMspa.checkOptOut: parsed object required (call parseGpp first)");
|
|
122
|
+
}
|
|
123
|
+
if (!opts || DATA_USES.indexOf(opts.dataUse) === -1) {
|
|
124
|
+
throw IabMspaError.factory("BAD_DATA_USE",
|
|
125
|
+
"iabMspa.checkOptOut: opts.dataUse must be one of " + DATA_USES.join(", "));
|
|
126
|
+
}
|
|
127
|
+
var signals = [];
|
|
128
|
+
for (var i = 0; i < parsed.sections.length; i += 1) {
|
|
129
|
+
var s = parsed.sections[i];
|
|
130
|
+
if (opts.state && s.idLabel !== opts.state.toLowerCase()) continue;
|
|
131
|
+
if (!s.optOuts) continue; // operator hasn't decoded the section
|
|
132
|
+
var hit = false;
|
|
133
|
+
if (opts.dataUse === "sale" && s.optOuts.sale === true) hit = true;
|
|
134
|
+
else if (opts.dataUse === "sharing" && s.optOuts.sharing === true) hit = true;
|
|
135
|
+
else if (opts.dataUse === "targeted-ads" && s.optOuts.targetedAds === true) hit = true;
|
|
136
|
+
else if (opts.dataUse === "sensitive" && s.optOuts.sensitive === true) hit = true;
|
|
137
|
+
else if (opts.dataUse === "child-data" && s.optOuts.childData === true) hit = true;
|
|
138
|
+
if (hit) signals.push(s.idLabel || ("section-" + s.id));
|
|
139
|
+
}
|
|
140
|
+
return { mustHonor: signals.length > 0, signals: signals };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function refuseProcessing(parsed, opts) {
|
|
144
|
+
var rv = checkOptOut(parsed, opts);
|
|
145
|
+
if (rv.mustHonor) {
|
|
146
|
+
audit.safeEmit({
|
|
147
|
+
action: "iabmspa.processing_refused",
|
|
148
|
+
outcome: "denied",
|
|
149
|
+
metadata: {
|
|
150
|
+
dataUse: opts.dataUse,
|
|
151
|
+
state: opts.state || null,
|
|
152
|
+
signals: rv.signals,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
throw IabMspaError.factory("OPT_OUT_HONORED",
|
|
156
|
+
"iabMspa: opt-out signal must be honored for dataUse='" + opts.dataUse +
|
|
157
|
+
"' (signals: " + rv.signals.join(", ") + ")");
|
|
158
|
+
}
|
|
159
|
+
return rv;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function gpcFromHeaders(req) {
|
|
163
|
+
if (!req || !req.headers) return false;
|
|
164
|
+
var h = req.headers["sec-gpc"];
|
|
165
|
+
return h === "1" || h === 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
parseGpp: parseGpp,
|
|
170
|
+
checkOptOut: checkOptOut,
|
|
171
|
+
refuseProcessing: refuseProcessing,
|
|
172
|
+
gpcFromHeaders: gpcFromHeaders,
|
|
173
|
+
SECTION_IDS: Object.assign({}, SECTION_IDS),
|
|
174
|
+
ALL_SECTIONS: ALL_SECTIONS.slice(),
|
|
175
|
+
DATA_USES: DATA_USES.slice(),
|
|
176
|
+
IabMspaError: IabMspaError,
|
|
177
|
+
};
|
|
@@ -505,6 +505,16 @@ function _sanitizeFilename(name) {
|
|
|
505
505
|
if (idx !== -1) s = s.slice(idx + 1);
|
|
506
506
|
// Drop control characters, NUL, leading/trailing dots.
|
|
507
507
|
s = s.replace(/\p{Cc}/gu, "");
|
|
508
|
+
// Trojan Source CVE-2021-42574 class — strip BiDi formatting +
|
|
509
|
+
// zero-width codepoints from the filename. An attacker uploading
|
|
510
|
+
// `Photo01Bygpj.SCR` displays as `Photo01By.jpg` in audit
|
|
511
|
+
// logs while the OS opens `.SCR`. Universal-refuse on these
|
|
512
|
+
// codepoints; operators with legitimate need pass the raw filename
|
|
513
|
+
// through `b.guardFilename` with explicit BiDi opt-in.
|
|
514
|
+
// BiDi formatting (U+202A..U+202E, U+2066..U+2069), zero-width
|
|
515
|
+
// (U+200B..U+200D, U+2060), BOM (U+FEFF) — Unicode escapes so the
|
|
516
|
+
// regex itself contains no irregular whitespace.
|
|
517
|
+
s = s.replace(/[\u202A-\u202E\u2066-\u2069\u200B-\u200D\u2060\uFEFF]/g, "");
|
|
508
518
|
s = s.replace(/^\.+/, "").replace(/\.+$/, "");
|
|
509
519
|
if (s.length === 0) return null;
|
|
510
520
|
if (s.length > 255) s = s.slice(0, 255);
|
|
@@ -44,6 +44,19 @@ var DEFAULT_PERMISSIONS = [
|
|
|
44
44
|
"geolocation=()", "gyroscope=()", "magnetometer=()", "microphone=()",
|
|
45
45
|
"midi=()", "payment=()", "picture-in-picture=()", "publickey-credentials-get=()",
|
|
46
46
|
"screen-wake-lock=()", "sync-xhr=()", "usb=()", "web-share=()", "xr-spatial-tracking=()",
|
|
47
|
+
// v0.8.33 expansion — newer Permissions-Policy feature names that
|
|
48
|
+
// weren't deny-by-default before. interest-cohort (FLoC, deprecated
|
|
49
|
+
// but still recognized), attribution-reporting (Privacy Sandbox),
|
|
50
|
+
// bluetooth / hid / serial (Web USB-shaped APIs), idle-detection,
|
|
51
|
+
// local-fonts (system-font fingerprinting), compute-pressure
|
|
52
|
+
// (CPU-load-side-channel), window-management (multi-screen probe),
|
|
53
|
+
// and the private-state-token-* family (Privacy-Pass-style anti-
|
|
54
|
+
// fraud tokens). Operators wanting any of these explicitly opt in
|
|
55
|
+
// by passing their own permissionsPolicy.
|
|
56
|
+
"interest-cohort=()", "attribution-reporting=()",
|
|
57
|
+
"bluetooth=()", "hid=()", "serial=()", "idle-detection=()",
|
|
58
|
+
"local-fonts=()", "compute-pressure=()", "window-management=()",
|
|
59
|
+
"private-state-token-issuance=()", "private-state-token-redemption=()",
|
|
47
60
|
];
|
|
48
61
|
|
|
49
62
|
// Strict CSP — no 'unsafe-inline' on script-src OR style-src.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.tcpa10dlc — TCPA 10DLC (10-Digit Long Code) consent-record audit
|
|
4
|
+
* primitive + FCC 1:1 prior-express-written-consent disclosure
|
|
5
|
+
* snapshot.
|
|
6
|
+
*
|
|
7
|
+
* Background: the Telephone Consumer Protection Act (47 USC §227) +
|
|
8
|
+
* its FCC implementing rules (47 CFR §64.1200) require carrier-
|
|
9
|
+
* shaped consent records before sending marketing or
|
|
10
|
+
* automated-dialing-system text messages. Penalties: $500-$1,500
|
|
11
|
+
* per violation. The 10DLC ecosystem (carrier-vetted A2P SMS
|
|
12
|
+
* routes) layers an additional consent-record requirement: every
|
|
13
|
+
* carrier-registered campaign must produce, on demand, the consent
|
|
14
|
+
* record for any phone number it texted.
|
|
15
|
+
*
|
|
16
|
+
* FCC 1:1 rule (effective 2025-01-27 — reaffirmed 2025-12) caps
|
|
17
|
+
* disclosed parties to 1 per consent — operators that previously
|
|
18
|
+
* collected blanket consent for "trusted partner network" must
|
|
19
|
+
* record an individual consent per third-party recipient. The rule
|
|
20
|
+
* was vacated by the 11th Circuit IMC v. FCC 2025 ruling but the
|
|
21
|
+
* underlying TCPA standard still requires "prior express written
|
|
22
|
+
* consent" for marketing autodial; FCC enforcement priorities still
|
|
23
|
+
* favor 1:1.
|
|
24
|
+
*
|
|
25
|
+
* The framework can't be the operator's SMS provider or campaign
|
|
26
|
+
* registrar. What it CAN do:
|
|
27
|
+
*
|
|
28
|
+
* - Capture the consent record in a tamper-evident audit-chain row
|
|
29
|
+
* with the carrier-required fields (consumer phone, opt-in
|
|
30
|
+
* timestamp, brand name, opt-in language verbatim, IP +
|
|
31
|
+
* user-agent + form URL).
|
|
32
|
+
* - Snapshot the 1:1 disclosure (single brand the consumer is
|
|
33
|
+
* consenting to receive messages from).
|
|
34
|
+
* - Support the carrier "produce on demand" workflow via
|
|
35
|
+
* `b.tcpa10dlc.lookup(phoneE164)` — returns the consent record
|
|
36
|
+
* for an audit response.
|
|
37
|
+
*
|
|
38
|
+
* Public API:
|
|
39
|
+
*
|
|
40
|
+
* b.tcpa10dlc.recordConsent(opts) -> consentRecord
|
|
41
|
+
* opts:
|
|
42
|
+
* phoneE164: "+15551234567" (E.164 format).
|
|
43
|
+
* brand: operator's registered brand name.
|
|
44
|
+
* disclosureText: the verbatim opt-in language shown to the
|
|
45
|
+
* consumer (regulator-facing record).
|
|
46
|
+
* disclosurePartyKind: "first-party" | "carrier-affiliate" |
|
|
47
|
+
* "campaign-registrar" — the role the brand
|
|
48
|
+
* plays per the 1:1 rule.
|
|
49
|
+
* formUrl: operator's URL where consent was captured.
|
|
50
|
+
* ip + userAgent: consumer's network identifiers.
|
|
51
|
+
* optInTimestamp: Unix-ms (default Date.now()).
|
|
52
|
+
* additional: arbitrary operator-supplied metadata
|
|
53
|
+
* (campaign-id, traffic source, A/B test cell).
|
|
54
|
+
*
|
|
55
|
+
* b.tcpa10dlc.lookup(phoneE164) -> consentRecord | null
|
|
56
|
+
*
|
|
57
|
+
* b.tcpa10dlc.revoke(phoneE164, reason) -> { revoked, at }
|
|
58
|
+
* Records the consumer-initiated opt-out. Carriers require
|
|
59
|
+
* revocation traceability — the audit row is the regulator-
|
|
60
|
+
* facing record.
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
var validateOpts = require("./validate-opts");
|
|
64
|
+
var audit = require("./audit");
|
|
65
|
+
var { defineClass } = require("./framework-error");
|
|
66
|
+
var Tcpa10dlcError = defineClass("Tcpa10dlcError", { alwaysPermanent: true });
|
|
67
|
+
|
|
68
|
+
var E164_RE = /^\+[1-9][0-9]{6,14}$/; // allow:raw-byte-literal — E.164 length range, not bytes
|
|
69
|
+
var DISCLOSURE_PARTIES = ["first-party", "carrier-affiliate", "campaign-registrar"];
|
|
70
|
+
|
|
71
|
+
var records = new Map(); // phoneE164 → record
|
|
72
|
+
|
|
73
|
+
function recordConsent(opts) {
|
|
74
|
+
if (!opts || typeof opts !== "object") {
|
|
75
|
+
throw Tcpa10dlcError.factory("BAD_OPTS",
|
|
76
|
+
"tcpa10dlc.recordConsent: opts required");
|
|
77
|
+
}
|
|
78
|
+
if (typeof opts.phoneE164 !== "string" || !E164_RE.test(opts.phoneE164)) {
|
|
79
|
+
throw Tcpa10dlcError.factory("BAD_PHONE",
|
|
80
|
+
"tcpa10dlc.recordConsent: phoneE164 must match " + E164_RE);
|
|
81
|
+
}
|
|
82
|
+
validateOpts.requireNonEmptyString(opts.brand,
|
|
83
|
+
"tcpa10dlc.recordConsent: brand", Tcpa10dlcError, "BAD_BRAND");
|
|
84
|
+
validateOpts.requireNonEmptyString(opts.disclosureText,
|
|
85
|
+
"tcpa10dlc.recordConsent: disclosureText", Tcpa10dlcError, "BAD_DISCLOSURE_TEXT");
|
|
86
|
+
validateOpts.requireNonEmptyString(opts.formUrl,
|
|
87
|
+
"tcpa10dlc.recordConsent: formUrl", Tcpa10dlcError, "BAD_FORM_URL");
|
|
88
|
+
if (DISCLOSURE_PARTIES.indexOf(opts.disclosurePartyKind) === -1) {
|
|
89
|
+
throw Tcpa10dlcError.factory("BAD_DISCLOSURE_PARTY",
|
|
90
|
+
"tcpa10dlc.recordConsent: disclosurePartyKind must be one of " +
|
|
91
|
+
DISCLOSURE_PARTIES.join(", "));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var optInAt = typeof opts.optInTimestamp === "number" ? opts.optInTimestamp : Date.now();
|
|
95
|
+
var record = Object.freeze({
|
|
96
|
+
phoneE164: opts.phoneE164,
|
|
97
|
+
brand: opts.brand,
|
|
98
|
+
disclosureText: opts.disclosureText,
|
|
99
|
+
disclosurePartyKind: opts.disclosurePartyKind,
|
|
100
|
+
formUrl: opts.formUrl,
|
|
101
|
+
ip: opts.ip || null,
|
|
102
|
+
userAgent: opts.userAgent || null,
|
|
103
|
+
optInTimestamp: optInAt,
|
|
104
|
+
optInTimestampIso: new Date(optInAt).toISOString(),
|
|
105
|
+
revoked: false,
|
|
106
|
+
revokedAt: null,
|
|
107
|
+
revokedReason: null,
|
|
108
|
+
additional: opts.additional || null,
|
|
109
|
+
citations: ["47-usc-227", "47-cfr-64.1200", "fcc-2024-1-1"],
|
|
110
|
+
});
|
|
111
|
+
records.set(opts.phoneE164, record);
|
|
112
|
+
|
|
113
|
+
if (opts.audit !== false) {
|
|
114
|
+
audit.safeEmit({
|
|
115
|
+
action: "tcpa10dlc.consent_recorded",
|
|
116
|
+
outcome: "success",
|
|
117
|
+
metadata: {
|
|
118
|
+
phoneE164: opts.phoneE164,
|
|
119
|
+
brand: opts.brand,
|
|
120
|
+
disclosurePartyKind: opts.disclosurePartyKind,
|
|
121
|
+
formUrl: opts.formUrl,
|
|
122
|
+
ip: opts.ip || null,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return record;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function lookup(phoneE164) {
|
|
130
|
+
if (typeof phoneE164 !== "string") return null;
|
|
131
|
+
return records.get(phoneE164) || null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function revoke(phoneE164, reason) {
|
|
135
|
+
if (typeof phoneE164 !== "string" || !E164_RE.test(phoneE164)) {
|
|
136
|
+
throw Tcpa10dlcError.factory("BAD_PHONE",
|
|
137
|
+
"tcpa10dlc.revoke: phoneE164 must match " + E164_RE);
|
|
138
|
+
}
|
|
139
|
+
var existing = records.get(phoneE164);
|
|
140
|
+
if (!existing) {
|
|
141
|
+
throw Tcpa10dlcError.factory("NO_RECORD",
|
|
142
|
+
"tcpa10dlc.revoke: no consent record for " + phoneE164);
|
|
143
|
+
}
|
|
144
|
+
if (existing.revoked) {
|
|
145
|
+
return { revoked: true, at: existing.revokedAt };
|
|
146
|
+
}
|
|
147
|
+
var revokedAt = Date.now();
|
|
148
|
+
var updated = Object.freeze(Object.assign({}, existing, {
|
|
149
|
+
revoked: true,
|
|
150
|
+
revokedAt: revokedAt,
|
|
151
|
+
revokedAtIso: new Date(revokedAt).toISOString(),
|
|
152
|
+
revokedReason: typeof reason === "string" ? reason : null,
|
|
153
|
+
}));
|
|
154
|
+
records.set(phoneE164, updated);
|
|
155
|
+
audit.safeEmit({
|
|
156
|
+
action: "tcpa10dlc.consent_revoked",
|
|
157
|
+
outcome: "success",
|
|
158
|
+
metadata: {
|
|
159
|
+
phoneE164: phoneE164,
|
|
160
|
+
reason: reason || null,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return { revoked: true, at: revokedAt };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _resetForTest() { records.clear(); }
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
recordConsent: recordConsent,
|
|
170
|
+
lookup: lookup,
|
|
171
|
+
revoke: revoke,
|
|
172
|
+
DISCLOSURE_PARTIES: DISCLOSURE_PARTIES.slice(),
|
|
173
|
+
Tcpa10dlcError: Tcpa10dlcError,
|
|
174
|
+
_resetForTest: _resetForTest,
|
|
175
|
+
};
|
package/lib/websocket.js
CHANGED
|
@@ -187,6 +187,19 @@ var DEFAULT_PING_INTERVAL_MS = C.TIME.seconds(30);
|
|
|
187
187
|
var DEFAULT_PONG_TIMEOUT_MS = C.TIME.seconds(35);
|
|
188
188
|
var CLOSE_GRACE_MS = C.TIME.seconds(2);
|
|
189
189
|
|
|
190
|
+
// RFC 6455 §7.4.2 close-code validity gate. Codes 0..999 MUST NOT
|
|
191
|
+
// appear on the wire. 1004 / 1005 / 1006 / 1015 are reserved
|
|
192
|
+
// (1005/1006 are local-only sentinels; 1004/1015 are reserved for
|
|
193
|
+
// future use). Codes 1000..1011 are spec-allocated. 3000..3999 are
|
|
194
|
+
// IANA-registered. 4000..4999 are private-use. Anything else is
|
|
195
|
+
// invalid.
|
|
196
|
+
function _isValidCloseCode(code) {
|
|
197
|
+
if (code === 1004 || code === 1005 || code === 1006 || code === 1015) return false; // allow:raw-byte-literal — RFC 6455 §7.4.2 reserved codes
|
|
198
|
+
if (code >= 1000 && code <= 1011) return true; // allow:raw-byte-literal — RFC 6455 §7.4.2 spec range / allow:raw-time-literal — code is a numeric, not seconds
|
|
199
|
+
if (code >= 3000 && code <= 4999) return true; // allow:raw-byte-literal — RFC 6455 §7.4.2 IANA / private range / allow:raw-time-literal — code is a numeric, not seconds
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
190
203
|
// Connection lifecycle states — mirrors the browser WebSocket API +
|
|
191
204
|
// the npm `ws` library. Single-source-of-truth field; every state
|
|
192
205
|
// transition goes through _transitionToClosed (or set in the
|
|
@@ -817,8 +830,25 @@ class WebSocketConnection extends EventEmitter {
|
|
|
817
830
|
|
|
818
831
|
_handleClose(frame) {
|
|
819
832
|
var code = CLOSE_NORMAL, reason = "";
|
|
833
|
+
// RFC 6455 §5.5.1 — close-frame body is either empty or 2+
|
|
834
|
+
// bytes (2-byte close code + optional UTF-8 reason). A 1-byte
|
|
835
|
+
// body is malformed; pre-v0.8.33 the framework silently
|
|
836
|
+
// accepted it as a clean close, evading anomaly detection
|
|
837
|
+
// that would have classified the malformation.
|
|
838
|
+
if (frame.payload.length === 1) {
|
|
839
|
+
return this._abort(CLOSE_PROTOCOL_ERROR,
|
|
840
|
+
"close frame payload must be 0 or >=2 bytes (RFC 6455 §5.5.1)");
|
|
841
|
+
}
|
|
820
842
|
if (frame.payload.length >= 2) {
|
|
821
843
|
code = frame.payload.readUInt16BE(0);
|
|
844
|
+
// RFC 6455 §7.4.2 — codes 0..999 MUST NOT be used. 1004 /
|
|
845
|
+
// 1005 / 1006 / 1015 are reserved (1005/1006 are local-only
|
|
846
|
+
// sentinels; 1004/1015 are reserved for future use).
|
|
847
|
+
// 1000-1011 + 3000-4999 are valid; everything else is invalid.
|
|
848
|
+
if (!_isValidCloseCode(code)) {
|
|
849
|
+
return this._abort(CLOSE_PROTOCOL_ERROR,
|
|
850
|
+
"close code " + code + " is reserved or invalid (RFC 6455 §7.4.2)");
|
|
851
|
+
}
|
|
822
852
|
if (frame.payload.length > 2) {
|
|
823
853
|
try { reason = new TextDecoder("utf-8", { fatal: true }).decode(frame.payload.subarray(2)); }
|
|
824
854
|
catch (_e) { return this._abort(CLOSE_INVALID_PAYLOAD, "close reason is not valid UTF-8"); }
|
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:0d7236d8-d7fd-4993-8139-83cff6ea1ce7",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-07T15:
|
|
8
|
+
"timestamp": "2026-05-07T15:31:18.217Z",
|
|
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.35",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.35",
|
|
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.35",
|
|
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.35",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|