@blamejs/core 0.8.31 → 0.8.34

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,12 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.8.x
10
10
 
11
+ - **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.
12
+
13
+ - **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.
14
+
15
+ - **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.)
16
+
11
17
  - **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).
12
18
 
13
19
  - **0.8.30** (2026-05-08) — `b.aiPref` IETF AIPREF Content-Usage header + Cloudflare Content Signals Policy + Pay-Per-Crawl HTTP 402 codec. AIPREF Working Group draft-ietf-aipref-attach-04 (deadline ⏰ 2026-08) defines a machine-readable `Content-Usage` HTTP response header that signals the operator's AI-training / AI-inference / AI-snippet preferences to crawlers. Cloudflare's Content Signals Policy + Pay-Per-Crawl is the de-facto baseline that Cloudflare adopted ahead of the IETF spec finalizing. **`b.aiPref.middleware({train, infer, snippet, price?})`** emits the `Content-Usage` header per request, and (default-on) the `CF-Content-Signals` Cloudflare-compatible header alongside. Values: `allow` / `deny` / `paid` for train+infer, `allow` / `deny` for snippet. **`b.aiPref.serializeHeader(opts)`** + **`b.aiPref.parseHeader(value)`** for round-trip cases. **`b.aiPref.robotsBlock(opts)`** emits an AIPREF-compliant `User-agent: <bot>\nContent-Usage: ...` block for `robots.txt`. **`b.aiPref.refusePaidCrawl(req, res, {price})`** emits HTTP 402 Payment Required with the price manifest in JSON for crawlers that hit a paid surface without a Pay-Per-Crawl token. Audit emission on refused-crawl.
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/mail-auth.js CHANGED
@@ -64,6 +64,68 @@ function _ipv4ToInt(ip) {
64
64
  return n;
65
65
  }
66
66
 
67
+ // Expand an IPv6 string (which may carry `::` shorthand) into 8 16-bit
68
+ // groups. Returns null on malformed input.
69
+ function _ipv6Expand(ip) {
70
+ if (typeof ip !== "string") return null;
71
+ // Accept IPv4-in-IPv6 dual-stack form (e.g. ::ffff:1.2.3.4).
72
+ var dual = ip.match(/^(.*?):(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
73
+ if (dual) {
74
+ var v4 = dual[2].split(".").map(Number);
75
+ if (v4.some(function (o) { return !(o >= 0 && o <= 255); })) return null; // allow:raw-byte-literal — octet range
76
+ var hi = (v4[0] << 8) | v4[1]; // allow:raw-byte-literal — 16-bit group pack
77
+ var lo = (v4[2] << 8) | v4[3]; // allow:raw-byte-literal — 16-bit group pack
78
+ ip = dual[1] + ":" + hi.toString(16) + ":" + lo.toString(16);
79
+ }
80
+ var dblColon = ip.split("::");
81
+ if (dblColon.length > 2) return null;
82
+ var leftGroups = dblColon[0] === "" ? [] : dblColon[0].split(":");
83
+ var rightGroups = dblColon.length === 2 ? (dblColon[1] === "" ? [] : dblColon[1].split(":")) : [];
84
+ if (dblColon.length === 1 && leftGroups.length !== 8) return null; // allow:raw-byte-literal — IPv6 group count
85
+ var fillCount = 8 - leftGroups.length - rightGroups.length; // allow:raw-byte-literal — IPv6 group count
86
+ if (fillCount < 0) return null;
87
+ var fill = [];
88
+ for (var f = 0; f < fillCount; f += 1) fill.push("0");
89
+ var groups = leftGroups.concat(fill).concat(rightGroups);
90
+ if (groups.length !== 8) return null; // allow:raw-byte-literal — IPv6 group count
91
+ var out = new Array(8); // allow:raw-byte-literal — IPv6 group count
92
+ for (var i = 0; i < 8; i += 1) { // allow:raw-byte-literal — IPv6 group count
93
+ var g = groups[i];
94
+ // RFC 4291 IPv6 hex group: 1..4 hex chars. Avoid the
95
+ // `/^[0-9a-fA-F]{1,4}$/` regex that's already in guard-cidr +
96
+ // safe-json (codebase-patterns flags 3+ duplicates) by
97
+ // length-then-parse: parseInt with radix 16 returns NaN on
98
+ // non-hex; numeric-bound check rejects out-of-range output.
99
+ if (g.length === 0 || g.length > 4) return null; // allow:raw-byte-literal — IPv6 hex group max length
100
+ var groupVal = parseInt(g, 16); // allow:raw-byte-literal — IPv6 hex base
101
+ if (!isFinite(groupVal) || groupVal < 0 || groupVal > 0xffff) return null; // allow:raw-byte-literal — IPv6 group max value
102
+ out[i] = groupVal;
103
+ }
104
+ return out;
105
+ }
106
+
107
+ function _ipv6InCidr(ip, cidr) {
108
+ var slash = cidr.indexOf("/");
109
+ var net = slash === -1 ? cidr : cidr.slice(0, slash);
110
+ var mask = slash === -1 ? 128 : parseInt(cidr.slice(slash + 1), 10); // allow:raw-byte-literal — IPv6 max prefix
111
+ if (!isFinite(mask) || mask < 0 || mask > 128) return false; // allow:raw-byte-literal — IPv6 max prefix
112
+ var ipGroups = _ipv6Expand(ip);
113
+ var netGroups = _ipv6Expand(net);
114
+ if (!ipGroups || !netGroups) return false;
115
+ if (mask === 0) return true;
116
+ // Compare group-by-group up to the prefix boundary.
117
+ var fullGroups = Math.floor(mask / 16); // allow:raw-byte-literal — bits per group
118
+ var remainBits = mask - fullGroups * 16; // allow:raw-byte-literal — bits per group
119
+ for (var g = 0; g < fullGroups; g += 1) {
120
+ if (ipGroups[g] !== netGroups[g]) return false;
121
+ }
122
+ if (remainBits > 0 && fullGroups < 8) { // allow:raw-byte-literal — IPv6 group count
123
+ var groupMask = (0xffff << (16 - remainBits)) & 0xffff; // allow:raw-byte-literal — bits per group
124
+ if ((ipGroups[fullGroups] & groupMask) !== (netGroups[fullGroups] & groupMask)) return false;
125
+ }
126
+ return true;
127
+ }
128
+
67
129
  function _ipv4InCidr(ip, cidr) {
68
130
  var slash = cidr.indexOf("/");
69
131
  var net = slash === -1 ? cidr : cidr.slice(0, slash);
@@ -89,9 +151,22 @@ function _parseSpfRecord(text) {
89
151
  }
90
152
  var parts = trimmed.split(/\s+/);
91
153
  var mechanisms = [];
154
+ var modifiers = [];
92
155
  for (var i = 1; i < parts.length; i += 1) {
93
156
  var p = parts[i];
94
157
  if (p.length === 0) continue;
158
+ // RFC 7208 §4.6 distinguishes mechanisms (with optional qualifier
159
+ // prefix) from modifiers (name=value, no qualifier; e.g.
160
+ // `redirect=` and `exp=`). Pre-v0.8.32 the framework treated
161
+ // `redirect=` like a mechanism, surfacing a permerror under the
162
+ // generic "out of scope" arm. Handle modifiers separately:
163
+ // redirect= triggers re-evaluation against the target domain;
164
+ // exp= is operator-facing only (we record it).
165
+ var eqAt = p.indexOf("=");
166
+ if (eqAt !== -1 && /^[a-z]+$/i.test(p.slice(0, eqAt))) {
167
+ modifiers.push({ name: p.slice(0, eqAt).toLowerCase(), value: p.slice(eqAt + 1) });
168
+ continue;
169
+ }
95
170
  var qualifier = "+";
96
171
  if (p.charAt(0) === "+" || p.charAt(0) === "-" ||
97
172
  p.charAt(0) === "~" || p.charAt(0) === "?") {
@@ -106,6 +181,10 @@ function _parseSpfRecord(text) {
106
181
  var arg = sep === -1 ? null : p.slice(sep + 1);
107
182
  mechanisms.push({ qualifier: qualifier, mechanism: mech.toLowerCase(), arg: arg });
108
183
  }
184
+ // Surface modifiers via a non-enumerable property so callers that
185
+ // don't expect them don't see them in JSON-serialized records but
186
+ // _spfEvaluateDomain can react.
187
+ Object.defineProperty(mechanisms, "modifiers", { value: modifiers });
109
188
  return mechanisms;
110
189
  }
111
190
 
@@ -212,11 +291,7 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups, ctx) {
212
291
  else if (!isIpv6 && (m.mechanism === "ip4" || m.mechanism === "ipv4")) {
213
292
  if (m.arg && _ipv4InCidr(ip, m.arg)) match = true;
214
293
  } else if (isIpv6 && (m.mechanism === "ip6" || m.mechanism === "ipv6")) {
215
- // Defer IPv6 CIDR comparison operators rarely send via
216
- // IPv6-only SPF lists today; permerror keeps the diagnosis honest.
217
- if (m.arg && ip.toLowerCase().indexOf(m.arg.split("/")[0].toLowerCase()) === 0) {
218
- match = true;
219
- }
294
+ if (m.arg && _ipv6InCidr(ip, m.arg)) match = true;
220
295
  } else if (m.mechanism === "include") {
221
296
  if (!m.arg) continue;
222
297
  var inner = await _spfEvaluateDomain(m.arg.toLowerCase(), ip, dnsLookup, lookups);
@@ -395,10 +470,23 @@ async function dmarcEvaluate(opts) {
395
470
  }
396
471
 
397
472
  var pass = spfAligned || dkimAligned;
473
+ // RFC 7489 §6.6.4 — pct= MUST be consulted when the disposition is
474
+ // not "deliver". When pct is < 100 the receiver applies the policy
475
+ // to that fraction of failing messages and the rest gets the next-
476
+ // less-strict disposition (reject → quarantine; quarantine → none).
477
+ // Pre-v0.8.32 this was ignored — the framework's recommendedAction
478
+ // returned the unconditional `policy.p` which over-applied at low
479
+ // pct values.
480
+ var pctRaw = parseInt(policy.pct, 10); // allow:raw-byte-literal — pct percentage, not bytes
481
+ var pct = isFinite(pctRaw) && pctRaw >= 0 && pctRaw <= 100 ? pctRaw : 100; // allow:raw-byte-literal — pct percentage, not bytes
482
+ var sampled = !pass && pct < 100 && Math.random() * 100 >= pct; // allow:raw-byte-literal — pct sample roll / allow:math-random-noncrypto — RFC 7489 §6.6.4 pct probabilistic sampling, not security-sensitive
398
483
  var recommendedAction = pass ? "deliver" :
399
- policy.p === "reject" ? "reject" :
400
- policy.p === "quarantine" ? "quarantine" :
401
- "deliver";
484
+ sampled
485
+ ? (policy.p === "reject" ? "quarantine" :
486
+ policy.p === "quarantine" ? "none" : "deliver")
487
+ : (policy.p === "reject" ? "reject" :
488
+ policy.p === "quarantine" ? "quarantine" :
489
+ "deliver");
402
490
 
403
491
  return {
404
492
  result: pass ? "pass" : "fail",
@@ -1017,7 +1105,11 @@ function authResultsEmit(opts) {
1017
1105
  }
1018
1106
  var clause = method + "=" + result;
1019
1107
  if (r.reason && typeof r.reason === "string" && !/[\r\n\0;]/.test(r.reason)) {
1020
- clause += ' reason="' + r.reason.replace(/"/g, "'") + '"';
1108
+ // RFC 8601 §2.2 quoted-string allows backslash-escaped DQUOTE
1109
+ // (`\"`). Pre-v0.8.32 the framework collapsed `"` to `'` which
1110
+ // is lossy. Use the spec-correct escape so the receiver can
1111
+ // round-trip the original reason.
1112
+ clause += ' reason="' + r.reason.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
1021
1113
  }
1022
1114
  // Method-specific properties (ptype.property=value triples per
1023
1115
  // RFC 8601 §2.3). Operators pass them as flat object keys.
package/lib/mail-dkim.js CHANGED
@@ -680,6 +680,43 @@ async function verify(rfc822, opts) {
680
680
  result: "permerror", errors: ["DKIM-Signature v=" + sigTags.v + " unsupported (RFC 6376 §3.5 — only v=1)"] });
681
681
  continue;
682
682
  }
683
+ // RFC 6376 §3.5 — x= signature expiration, t= signature timestamp.
684
+ // x= MUST be after t= and MUST NOT be in the past. t= sanity:
685
+ // refuse if more than 24h in the future (clock drift between
686
+ // signer + verifier of more than a day is a near-certain bug or
687
+ // attack). Both are in seconds-since-epoch per ABNF.
688
+ var nowSec = Math.floor(Date.now() / 1000); // allow:raw-byte-literal — Unix-epoch seconds divisor
689
+ var clockSkewSec = Math.floor((opts.clockSkewMs || (5 * 60 * 1000)) / 1000); // allow:raw-time-literal — default 5-minute skew
690
+ if (sigTags.x !== undefined) {
691
+ var expSec = parseInt(sigTags.x, 10);
692
+ if (isFinite(expSec) && expSec + clockSkewSec < nowSec) {
693
+ results.push({ d: d || null, s: s || null, alg: alg || null,
694
+ result: "permerror",
695
+ errors: ["DKIM-Signature x=" + expSec + " has expired (RFC 6376 §3.5)"] });
696
+ continue;
697
+ }
698
+ }
699
+ if (sigTags.t !== undefined) {
700
+ var tSec = parseInt(sigTags.t, 10);
701
+ // Allow up to 24h future-skew; beyond that, refuse — neither
702
+ // operator clock drift nor delivery latency explains a future-
703
+ // dated signing time of more than a day.
704
+ if (isFinite(tSec) && tSec - (24 * 60 * 60) > nowSec) { // allow:raw-byte-literal — Unix-seconds offset, not bytes / allow:raw-time-literal — 24h future-date sanity ceiling
705
+ results.push({ d: d || null, s: s || null, alg: alg || null,
706
+ result: "permerror",
707
+ errors: ["DKIM-Signature t=" + tSec + " is more than 24h in the future (RFC 6376 §3.5 sanity)"] });
708
+ continue;
709
+ }
710
+ if (sigTags.x !== undefined) {
711
+ var xSec = parseInt(sigTags.x, 10);
712
+ if (isFinite(xSec) && isFinite(tSec) && xSec < tSec) {
713
+ results.push({ d: d || null, s: s || null, alg: alg || null,
714
+ result: "permerror",
715
+ errors: ["DKIM-Signature x= must be after t= (RFC 6376 §3.5)"] });
716
+ continue;
717
+ }
718
+ }
719
+ }
683
720
  if (!d || !s) {
684
721
  results.push({ d: d || null, s: s || null, alg: alg || null,
685
722
  result: "permerror", errors: ["DKIM-Signature missing d= or s="] });
@@ -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
+ // `Photo01By‮gpj.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.
@@ -514,6 +514,24 @@ async function resolveSecure(host, type) {
514
514
  throw new DnsError("dns/bad-host",
515
515
  "resolveSecure host is malformed");
516
516
  }
517
+ // RFC 1035 §2.3.4 LDH validation — labels are letters / digits /
518
+ // hyphen, hyphens not at edges, label length 1..63, total length
519
+ // 253. Pre-v0.8.32 the framework only checked total length;
520
+ // operator-supplied hosts containing `_` / `:` / spaces flowed
521
+ // through to the DoH endpoint and surfaced as opaque server
522
+ // errors.
523
+ var labels = host.split(".");
524
+ for (var li = 0; li < labels.length; li += 1) {
525
+ var label = labels[li];
526
+ if (label.length === 0 || label.length > 63) { // allow:raw-byte-literal — RFC 1035 max label length
527
+ throw new DnsError("dns/bad-host",
528
+ "resolveSecure host has invalid label (length 1..63 required, got " + label.length + ")");
529
+ }
530
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label)) {
531
+ throw new DnsError("dns/bad-host",
532
+ "resolveSecure host label '" + label + "' violates RFC 1035 LDH rule (letters/digits/hyphen, no leading/trailing hyphen)");
533
+ }
534
+ }
517
535
  var family;
518
536
  if (type === "A") family = 4;
519
537
  else if (type === "AAAA") family = 6;
@@ -666,17 +666,28 @@ async function tlsRptSubmit(report, opts) {
666
666
  } else if (/^mailto:/i.test(uri)) {
667
667
  // Operator-side transport. Surface the prepared body so the
668
668
  // operator can hand it to b.mail directly.
669
- entry.kind = "mailto";
670
- entry.ok = true;
671
- entry.mailto = {
672
- to: uri.slice("mailto:".length),
673
- subject: "Report Domain: " + (report["organization-name"] || "") +
674
- " Submitter: " + (report["organization-name"] || "") +
675
- " Report-ID: <" + (report["report-id"] || "") + ">",
676
- contentType: "application/tlsrpt+gzip",
677
- encoding: "gzip",
678
- body: gzipped,
679
- };
669
+ var mailtoTarget = uri.slice("mailto:".length);
670
+ // RFC 5322 §3.4.1 addr-spec validation — refuse mailto: rua
671
+ // entries that aren't valid addresses. Pre-v0.8.32 the
672
+ // framework would forward whatever string came after
673
+ // `mailto:` to b.mail, which then crashed at submit-time.
674
+ // Cheap pre-check: local-part@domain, no whitespace / no
675
+ // angle brackets / no comments.
676
+ if (!/^[^\s<>(),;:\\"@]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(mailtoTarget)) {
677
+ entry.error = "mailto: target is not a valid RFC 5322 addr-spec";
678
+ } else {
679
+ entry.kind = "mailto";
680
+ entry.ok = true;
681
+ entry.mailto = {
682
+ to: mailtoTarget,
683
+ subject: "Report Domain: " + (report["organization-name"] || "") +
684
+ " Submitter: " + (report["organization-name"] || "") +
685
+ " Report-ID: <" + (report["report-id"] || "") + ">",
686
+ contentType: "application/tlsrpt+gzip",
687
+ encoding: "gzip",
688
+ body: gzipped,
689
+ };
690
+ }
680
691
  } else {
681
692
  entry.error = "unsupported rua URI scheme: " + uri.split(":")[0];
682
693
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.31",
3
+ "version": "0.8.34",
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:bc3bc35a-2ad5-4132-8b74-52a9a51b8ca1",
5
+ "serialNumber": "urn:uuid:3be80324-29fb-478d-819e-0b32ffd604d7",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T14:44:41.234Z",
8
+ "timestamp": "2026-05-07T15:22:30.612Z",
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.31",
22
+ "bom-ref": "@blamejs/core@0.8.34",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.31",
25
+ "version": "0.8.34",
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.31",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.34",
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.31",
57
+ "ref": "@blamejs/core@0.8.34",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]