@blamejs/core 0.18.35 → 0.18.37
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 +67 -0
- package/README.md +1 -1
- package/lib/auth/password.js +14 -5
- package/lib/crypto.js +74 -8
- package/lib/eat.js +7 -3
- package/lib/http-message-signature.js +9 -5
- package/lib/middleware/dpop.js +10 -6
- package/lib/parsers/safe-env.js +126 -17
- package/lib/parsers/safe-ini.js +115 -9
- package/lib/parsers/safe-toml.js +71 -16
- package/lib/parsers/safe-xml.js +7 -1
- package/lib/parsers/safe-yaml.js +213 -40
- package/lib/standard-webhooks.js +7 -5
- package/lib/totp.js +11 -2
- package/lib/webhook-hmac.js +11 -9
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,73 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.37 (2026-08-19) — **The no-regex rule for content-safety primitives never covered lib/parsers, where 55 patterns were screening adversarial input.** SECURITY.md states that no `b.guard*` or `b.safe*` primitive contains a regular expression, so a screen costs the length of its input and the byte cap that bounds the input bounds the screen. The build gate enforcing that selected files with `lib/(safe-|guard-)[^/]+\.js` — top level only. Every nested primitive was therefore outside it, and all five of them are the ones that parse untrusted bytes: `b.parsers.yaml`, `b.parsers.toml`, `b.parsers.ini`, `b.parsers.env` and `b.parsers.xml`. Between them they carried 55 pattern literals.
|
|
12
|
+
|
|
13
|
+
All 55 are gone, and the gate now covers nested paths. Nothing about the parsers' behaviour changes: each screen was differential-tested against the pattern it replaced before the call site was swapped.
|
|
14
|
+
|
|
15
|
+
Upgrade if you parse YAML, TOML, INI, `.env` or XML from anything you do not control. **Changed:** *The no-regex gate asks a parser instead of walking the source itself* — Deciding whether a `/` opens a regular expression or divides means implementing the ECMAScript lexical grammar, and the previous scanner kept meeting parts of it that were not implemented yet — template substitutions nest, `of` is contextual, a labelled `break` ends at the terminator after its label, `<!--` is a comment, a shebang is not JavaScript.
|
|
16
|
+
|
|
17
|
+
The check now runs as an ESLint rule, which sees the file after it has already been parsed. All of those questions are answered before the rule is asked. It needs no new dependency: ESLint already runs over the repository as the first static gate, and a flat config takes an inline rule.
|
|
18
|
+
|
|
19
|
+
Suppression, if a case ever warrants it, is the standard `// eslint-disable-next-line blamejs/no-regex-in-content-safety` with the reason on the line above, replacing a bespoke marker that was never used.
|
|
20
|
+
|
|
21
|
+
The hand-written scanner is gone with it — about 1,240 lines, including the fixture table that had grown to justify it. Leaving both in place would have meant a justified exception passing one gate and failing the other, since the old one honoured only its own marker.
|
|
22
|
+
|
|
23
|
+
The rule reports pattern literals. `new RegExp(...)` is not yet included: three call sites use it and they are not one thing — two compile a pattern in order to validate it, which is the opposite of matching against input, while `b.guardSql` builds its detectors that way and does match input. Reporting all three today would mean either an allowlist entry for the one that should be caught, or a rushed conversion of a set of injection detectors. It is tracked instead, and the rule stays exactly as strict as the scanner it replaces. **Fixed:** *A YAML tag beginning with a digit slipped past the tag ban* — `b.parsers.yaml` refuses anchors, aliases, tags and directives. The tag check accepted `!` followed by a letter or `<`, while the comment beside it described the rule as "alphanumeric or `<`" — and the code was the weaker of the two. `a: !123` is a local tag, and it parsed as the plain string `"!123"` rather than being refused.
|
|
24
|
+
|
|
25
|
+
It is now refused, along with `!!123`. A quoted `"!123"` is still an ordinary string, because a quoted scalar is not a tag. **Security:** *Every pattern literal is gone from the five parsers that consume adversarial bytes* — Fifty-five of them: `safe-yaml` 24, `safe-toml` 16, `safe-env` 8, `safe-ini` 7, `safe-xml` 1.
|
|
26
|
+
|
|
27
|
+
The replacements walk characters: YAML's core-schema scalar resolution is now exact-set membership and a left-to-right digit walk; TOML's date, time and offset fields are index comparisons against fixed widths; INI's integer, float and hex classification and its `[name "subsection"]` header are single passes; `.env`'s inline-comment split, `export ` prefix and `$VAR` refusal are scans.
|
|
28
|
+
|
|
29
|
+
Every replacement was compared against the pattern it replaced over exhaustive short strings and adversarial cases before the call site changed — between roughly 60,000 and 1,400,000 inputs each, all with zero divergence. Where a reported line number derives from a match position, the position was compared too, not just the verdict.
|
|
30
|
+
|
|
31
|
+
One honest note on scale: these patterns were not all hanging. `.env`'s inline-comment matcher looked like catastrophic backtracking and is not — its separator and token classes are disjoint, so the partition of a line is unique, and it measures flat to 40,000 characters. The rule removes it anyway, and that is the point: the cost of a screen in this family holds by construction, rather than by someone re-deriving the ambiguity of each pattern correctly every time one is edited. **References:** [ESTree — RegExpLiteral carries the pattern and flags as parsed data](https://github.com/estree/estree/blob/master/es5.md#regexpliteral) · [OWASP — Regular expression Denial of Service (ReDoS)](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
|
|
32
|
+
|
|
33
|
+
- v0.18.36 (2026-08-18) — **Six constant-time comparisons told you which candidate matched, by how long they took.** A comparison that runs in constant time answers one question without leaking anything. A loop of them that stops at the first match answers a second question nobody asked: how far down the list the match was. Six places in the framework compared a presented value against a list of candidates and returned as soon as one matched, so the response time reported the match's position — which deny-list entry a certificate hit, which verifier's nonce a token carried, where in a key rotation the sender currently sits.
|
|
34
|
+
|
|
35
|
+
Every one of them returned the correct answer. The defect is only in how long it took, which is why no test caught it and why the guard that replaces them is structural.
|
|
36
|
+
|
|
37
|
+
**Upgrade if you use `b.crypto.isCertRevoked`, `b.webhookHmac.verify`, `b.standardWebhooks.verify`, `b.httpMessageSignature.verify`, `b.eat` nonce verification, or the DPoP nonce middleware.** No API changes, no configuration changes — the comparisons simply stop reporting position. **Added:** *b.crypto.timingSafeEqualAny(presented, candidates)* — Returns `true` when `presented` equals any entry of `candidates`, comparing against every entry whatever the outcome. The loop has no `break` and no `return` inside it, and folds each result into an accumulator rather than writing `matched || compare(...)` — that short-circuits the call away once something matches, which is the early exit wearing different syntax.
|
|
38
|
+
|
|
39
|
+
A candidate that is neither a Buffer nor a string counts as a non-match rather than throwing, because a deny list assembled from operator configuration should not fail the request on one malformed row. The presented value and the array itself are entry-tier: passing the wrong shape is a caller bug that would otherwise answer `false` for every input. **Fixed:** *b.auth.totp gains a drift-window and replay suite* — Fixing the drift loop exposed that nothing tested it. TOTP had assertions elsewhere, but `driftSteps` and `lastUsedStep` appeared in none of them — so the window's width, which step `verify()` reports, and the replay guard were all unverified.
|
|
40
|
+
|
|
41
|
+
The new suite pins them against RFC 6238's Appendix B vectors, driven through the verify-only SHA-1 path an operator migrating from a legacy authenticator actually uses. It asserts that every step in the window verifies and reports its OWN step rather than the current one — reporting the current step would quietly make `lastUsedStep` replay-tracking useless — that a step outside the window is refused and the same code is accepted once the window widens, that a replayed step is refused while the next step still works, and that a malformed token reads as no-match while a misconfigured verifier still throws. · *Four NIST SP 800-53 control mappings corrected* — `docs/cis-sqlite-equivalent.md` maps the framework's posture to Revision 5 and cited three enhancements that Revision 5 withdrew: `AU-8(1)` for time-source synchronisation, now `SC-45(1)`; `IA-2(11)` for step-up authentication, now `IA-2(6)`; and `CP-2(8)` for recovery objectives, now folded into `CP-2`.
|
|
42
|
+
|
|
43
|
+
The fourth was a live enhancement claimed for a mechanism that does not meet it. `AU-9(1)` requires audit trails written to hardware-enforced write-once media; the framework's WORM tables are a database trigger refusing UPDATE and DELETE. That is software enforcement, and an assessor would reject the mapping. The row now cites `AU-9` and says what a deployment claiming the enhancement has to add underneath. **Security:** *Six multi-candidate comparisons no longer leak the matching candidate's position* — Each of these compared a presented value against a list and exited on the first hit:
|
|
44
|
+
|
|
45
|
+
- `b.crypto.isCertRevoked` — which entry of the deny list the certificate's fingerprint matched. Its own docstring promised the opposite: "every comparison runs through `crypto.timingSafeEqual` so the answer doesn't leak which entry matched", above a loop that returned inside itself.
|
|
46
|
+
- `b.eat` nonce verification — which verifier's nonce an attestation token carried, under a comment reading "Constant-time compare against each candidate".
|
|
47
|
+
- The DPoP nonce middleware — whether a client presented the current or the previous nonce, which locates it in the rotation window. Its comment claimed the compare existed "so server-issued nonce probing can't narrow the rolling-pair bytes via response-timing".
|
|
48
|
+
- `b.webhookHmac.verify` and `b.standardWebhooks.verify` — the position of the matching signature in a multi-signature header, which tracks the sender's key rotation.
|
|
49
|
+
- `b.httpMessageSignature.verify` — the position of the matching member in a `Content-Digest` header.
|
|
50
|
+
|
|
51
|
+
All six now compare against every candidate. A length mismatch still short-circuits a single comparison and still leaks nothing: the lengths involved are digest and fingerprint sizes fixed by an algorithm, not secrets. · *Two more, found by checking loop scope instead of matching text* — The six above were found by reading code. Replacing the text pattern that guarded them with one that actually resolves loop bodies — brace matching over comment-stripped source — turned up two the pattern could not have seen, because neither exits with the `return true` it was looking for:
|
|
52
|
+
|
|
53
|
+
- `b.auth.password` breach checking returned on the first HaveIBeenPwned range line that matched, so the response time reported WHERE in the range the hit was. The range is public; the position narrows the SHA-1 suffix, which is precisely the half of the hash that k-anonymity exists to withhold. Every line is now compared before the verdict.
|
|
54
|
+
- `b.totp.verify` returned on the first drift step that matched, reporting the clock offset between authenticator and server through timing. Every step in the window is now compared, and the earliest match still wins.
|
|
55
|
+
|
|
56
|
+
Neither is guarded by a `matched === null` condition on the comparison itself — that short-circuits the call away once something matches, which is the same early exit in different syntax.
|
|
57
|
+
|
|
58
|
+
`b.network.dns.dane.matchCertificate` also exits on its first match and is deliberately left alone: it compares a TLSA record published in DNS against a digest of the certificate presented in the handshake. Both operands are public, so the iteration count reveals nothing an observer could not compute, and reporting which record matched is the function's contract. **Detectors:** *A detector can pin the shapes it must NOT refuse* — A `codebase-patterns` entry may now carry `fixtures: { fires, quiet }`, and a check runs them: every listed shape must fire, every listed shape must stay quiet. Nothing enforced that before, so a pattern's precision lived in whatever its author happened to try by hand — which is one direction, against the code the pattern was written for.
|
|
59
|
+
|
|
60
|
+
The entry added in this release got both directions wrong before the fixtures existed. It refused a single negated comparison (`if (!timingSafeEqual(a, b)) return false; return true;` — one comparison, no position to leak), and separately refused a `.find()` locating a record by type merely because a comparison appeared nearby. Neither shape was visible from the code the pattern was aimed at. · *The live-integration runner no longer counts a file that asserted nothing as a pass* — The runner decided a file had passed from its exit status, then printed whatever `OK — <n> checks passed` line the file happened to emit. Four of the fifty integration files never emitted one, so their column was blank and nobody could tell the difference between a file that ran its assertions and a file whose `run()` resolved before reaching them. Between them those four carry 111 checks.
|
|
61
|
+
|
|
62
|
+
Exit status alone cannot answer the question: a file that stops asserting exits 0 exactly like a file that passed. The runner now requires the count line and requires it to be greater than zero — `OK — 0 checks passed` matches the old pattern while verifying nothing — and the four silent files report their counts.
|
|
63
|
+
|
|
64
|
+
A static check covers the same ground during the fast gates, since the runner needs the live backends to say anything. It reads the source with comment lines blanked: a commented-out success print contains every token the check looks for, and that is the likeliest way a file goes silent, so reading raw source would pass on exactly the state being guarded against. The same blanking now backs the fuzz-harness entry-point check, where a commented-out `module.exports.fuzz` would otherwise read as a wired harness. · *A constant-time compare in a loop may not exit early* — Two checks, split by what each can actually decide.
|
|
65
|
+
|
|
66
|
+
A pattern covers the loop-free shape: two comparisons in sequence, each guarding its own early exit — the DPoP current/previous nonce pair. That is a sequence of tokens, so a pattern decides it exactly.
|
|
67
|
+
|
|
68
|
+
Loop membership is decided by matching braces over comment-stripped source instead, because whether a comparison sits inside a particular loop body is a question about scope. Successive attempts to answer it with a pattern flagged an ordinary one-pair verifier that merely followed an unrelated loop — a text pattern stops at a brace in the first column, and loop bodies close indented — while missing `for await`, `do…while`, and brace-less single-statement bodies. Resolving the body outright covers all of them and found the two additional leaks above.
|
|
69
|
+
|
|
70
|
+
A `continue` is not an early exit for this purpose: it skips a candidate that was never eligible and the loop still runs to the end, which is what `timingSafeEqualAny` does. Counting it flagged the primitive written to fix the defect.
|
|
71
|
+
|
|
72
|
+
An ordinary one-pair verifier stays quiet in both checks — one comparison has no position to report.
|
|
73
|
+
|
|
74
|
+
This one is the primary guard rather than the usual second line, and the reason is worth stating: every affected site produced the correct output, so no assertion on a return value could observe the defect, and a wall-clock measurement cannot assert it reliably under a parallel test runner. The property is structural — the loop must have no exit to take — so the check is structural too, and the fix routes through a primitive that cannot be talked into returning early rather than asking each loop to be reviewed.
|
|
75
|
+
|
|
76
|
+
Its scope is deliberately narrow, and the boundary is recorded in the entry itself. A comparison inside a `.some` or `.find` callback is the same defect, and the check does not claim it: deciding whether a call sits inside a particular callback is a parsing question, and every attempt to answer it with a pattern either refused working code or missed a real shape. What it does cover — a positive comparison guarding an early exit — is the shape all six of these took. **References:** [NIST SP 800-53 Rev. 5 SC-45(1) — Synchronization with Authoritative Time Source](https://csf.tools/reference/nist-sp-800-53/r5/sc/sc-45/sc-45-1/) · [NIST SP 800-53 Rev. 5 AU-9(1) — Hardware Write-once Media](https://csf.tools/reference/nist-sp-800-53/r5/au/au-9/au-9-1/) · [RFC 9449 — OAuth 2.0 Demonstrating Proof of Possession (DPoP), nonce handling](https://www.rfc-editor.org/rfc/rfc9449.html) · [RFC 9421 — HTTP Message Signatures, Content-Digest verification](https://www.rfc-editor.org/rfc/rfc9421.html)
|
|
77
|
+
|
|
11
78
|
- v0.18.35 (2026-08-18) — **Thirteen character policies the guards declared and did not apply, and two content guards that could not perform the repair they promised.** A guard's profile names a policy per character class — `reject`, `strip`, `audit`, `allow`. Thirteen of those settings across `b.guardEmail`, `b.guardXml`, `b.guardMarkdown`, `b.guardJson` and `b.guardYaml` resolved to something other than what they named, because the code decided by the finding's SEVERITY rather than by the policy. Severity is fixed per class — a bidi control is `critical` however it is configured — so a severity-gated decision refuses what an operator asked to record and, in a guard whose findings never reach `critical`, lets through what they asked to reject. Both directions were live at once.
|
|
12
79
|
|
|
13
80
|
Every declared character policy now resolves to the action it names, and a family-wide test asserts it for all five classes across every content guard.
|
package/README.md
CHANGED
|
@@ -346,7 +346,7 @@ Because when something breaks, `blame` should know exactly where it lives. We ow
|
|
|
346
346
|
|
|
347
347
|
Every release passes a layered gate at `test/layer-0-primitives/codebase-patterns.test.js` that operates on lib/ source:
|
|
348
348
|
|
|
349
|
-
- **Bug-class detectors** — raw byte / time literals, `JSON.parse` on operator input without size cap, numeric opts that silently accept `Infinity` / `NaN`, ReDoS-risky regex without length cap, hash / token compares without `timingSafeEqual`, raw `new URL` skipping the SSRF gate, `Math.random()` in security-sensitive paths, and a couple dozen others — each a bug class the framework already swept once and won't re-introduce.
|
|
349
|
+
- **Bug-class detectors** — raw byte / time literals, `JSON.parse` on operator input without size cap, numeric opts that silently accept `Infinity` / `NaN`, ReDoS-risky regex without length cap, hash / token compares without `timingSafeEqual`, a constant-time compare in a loop that exits on the first match (which leaks the matching candidate's position however constant each comparison is), raw `new URL` skipping the SSRF gate, `Math.random()` in security-sensitive paths, and a couple dozen others — each a bug class the framework already swept once and won't re-introduce.
|
|
350
350
|
- **Inline-shape catalog (n=1)** — every primitive that's been extracted (`validateOpts.requireNonEmptyString`, `safeAsync.makeScheduledFlush`, `dbSchema.runInTransaction`, etc.) registers the inline shape it replaced; new code that re-implements the shape fails the gate even if it's the only file matching.
|
|
351
351
|
- **Cluster allowlist (n>=3)** — duplicate-block detection across files. Genuine new clusters get extracted; clusters that resist extraction (parser error class signature mismatches, framework-convention shapes, cross-domain coincidences) get an entry with a documented structural reason. No silent allowlisting.
|
|
352
352
|
|
package/lib/auth/password.js
CHANGED
|
@@ -460,6 +460,7 @@ function policy(opts) {
|
|
|
460
460
|
var lines = bodyText.split(/\r?\n/);
|
|
461
461
|
var goodLines = 0;
|
|
462
462
|
var badLines = 0;
|
|
463
|
+
var breachedCount = null;
|
|
463
464
|
for (var li = 0; li < lines.length; li++) {
|
|
464
465
|
var line = lines[li].trim();
|
|
465
466
|
if (line.length === 0) continue;
|
|
@@ -469,13 +470,21 @@ function policy(opts) {
|
|
|
469
470
|
var count = parseInt(line.slice(colon + 1), 10);
|
|
470
471
|
if (!isFinite(count)) { badLines += 1; continue; }
|
|
471
472
|
goodLines += 1;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
473
|
+
// Compare against every line, then decide. Returning on the first match
|
|
474
|
+
// made the response time report WHERE in the range response the hit was,
|
|
475
|
+
// and the range is public — so that position narrows the suffix, which is
|
|
476
|
+
// the half of the password's SHA-1 that k-anonymity exists to keep back.
|
|
477
|
+
// The condition order matters too: `count >= threshold &&` first would
|
|
478
|
+
// skip the comparison on low-count lines and leak through that instead.
|
|
479
|
+
if (timingSafeEqual(Buffer.from(hashSuffix, "utf8"), Buffer.from(suffix, "utf8"))) {
|
|
480
|
+
if (count >= p.breachThreshold && breachedCount === null) breachedCount = count;
|
|
477
481
|
}
|
|
478
482
|
}
|
|
483
|
+
if (breachedCount !== null) {
|
|
484
|
+
return _fail("breached",
|
|
485
|
+
"plaintext appears in HaveIBeenPwned with count " + breachedCount +
|
|
486
|
+
" (threshold " + p.breachThreshold + ")");
|
|
487
|
+
}
|
|
479
488
|
// If a hostile / poisoned mirror returned a response shaped like
|
|
480
489
|
// HIBP but with mostly-unparseable counts, the original loop
|
|
481
490
|
// skipped them silently and reported breachCheckCount=0 — i.e.
|
package/lib/crypto.js
CHANGED
|
@@ -570,6 +570,66 @@ function timingSafeEqual(a, b) {
|
|
|
570
570
|
return nodeCrypto.timingSafeEqual(bufA, bufB);
|
|
571
571
|
}
|
|
572
572
|
|
|
573
|
+
/**
|
|
574
|
+
* @primitive b.crypto.timingSafeEqualAny
|
|
575
|
+
* @signature b.crypto.timingSafeEqualAny(presented, candidates)
|
|
576
|
+
* @since 0.18.36
|
|
577
|
+
* @status stable
|
|
578
|
+
* @related b.crypto.timingSafeEqual, b.crypto.isCertRevoked, b.crypto.spkiPinVerifier
|
|
579
|
+
*
|
|
580
|
+
* Returns `true` when `presented` equals any entry of `candidates`, comparing
|
|
581
|
+
* against EVERY entry whatever the outcome. A per-candidate compare in a loop
|
|
582
|
+
* that stops at the first match answers a second question nobody asked — how
|
|
583
|
+
* far down the list the match was — and the caller's own timing then reports
|
|
584
|
+
* it: matching the first pinned key returns sooner than matching the last, and
|
|
585
|
+
* matching nothing runs the whole list. That is a position oracle over a secret
|
|
586
|
+
* ordering, so this loop has no `break`, no `return` inside it, and folds each
|
|
587
|
+
* result into an accumulator instead.
|
|
588
|
+
*
|
|
589
|
+
* A length mismatch short-circuits a single comparison, and that leaks nothing:
|
|
590
|
+
* the lengths compared here are digest and fingerprint sizes fixed by an
|
|
591
|
+
* algorithm, not secrets. `presented` and each candidate may be a Buffer or a
|
|
592
|
+
* string; a candidate that is neither counts as a non-match rather than
|
|
593
|
+
* throwing, because a deny list assembled from operator config should not fail
|
|
594
|
+
* the request on one malformed row.
|
|
595
|
+
*
|
|
596
|
+
* Entry-tier on `presented` and on the array itself: a caller that passes the
|
|
597
|
+
* wrong shape has a bug, and the compare would otherwise silently answer
|
|
598
|
+
* `false` for every input.
|
|
599
|
+
*
|
|
600
|
+
* @example
|
|
601
|
+
* var ok = b.crypto.timingSafeEqualAny(presentedKey, [primary, previous]);
|
|
602
|
+
* // → true when it matches either, in the same time either way
|
|
603
|
+
*/
|
|
604
|
+
function timingSafeEqualAny(presented, candidates) {
|
|
605
|
+
if (!Buffer.isBuffer(presented) && typeof presented !== "string") {
|
|
606
|
+
throw new TypeError(
|
|
607
|
+
"crypto.timingSafeEqualAny: argument 'presented' must be a Buffer or " +
|
|
608
|
+
"string, got " + (presented === null ? "null" : typeof presented)
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
if (!Array.isArray(candidates)) {
|
|
612
|
+
throw new TypeError(
|
|
613
|
+
"crypto.timingSafeEqualAny: argument 'candidates' must be an array, got " +
|
|
614
|
+
(candidates === null ? "null" : typeof candidates)
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
var presentedBuf = Buffer.isBuffer(presented)
|
|
618
|
+
? presented : Buffer.from(presented, "utf8");
|
|
619
|
+
var matched = false;
|
|
620
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
621
|
+
var candidate = candidates[i];
|
|
622
|
+
if (!Buffer.isBuffer(candidate) && typeof candidate !== "string") continue;
|
|
623
|
+
var candidateBuf = Buffer.isBuffer(candidate)
|
|
624
|
+
? candidate : Buffer.from(candidate, "utf8");
|
|
625
|
+
if (candidateBuf.length !== presentedBuf.length) continue;
|
|
626
|
+
// `matched ||` would short-circuit the call away once something matched,
|
|
627
|
+
// which is the early exit wearing a different syntax.
|
|
628
|
+
if (nodeCrypto.timingSafeEqual(presentedBuf, candidateBuf)) matched = true;
|
|
629
|
+
}
|
|
630
|
+
return matched;
|
|
631
|
+
}
|
|
632
|
+
|
|
573
633
|
// ===========================================================
|
|
574
634
|
// Public API — built on core primitives
|
|
575
635
|
// ===========================================================
|
|
@@ -2088,18 +2148,23 @@ function isCertRevoked(pemOrDer, denyList) {
|
|
|
2088
2148
|
var fp = hashCertFingerprint(pemOrDer);
|
|
2089
2149
|
var fpHex = Buffer.from(fp.hex, "hex");
|
|
2090
2150
|
var fpColon = Buffer.from(fp.colon);
|
|
2151
|
+
// The docstring above promises the answer does not leak which entry matched.
|
|
2152
|
+
// It did: the loop returned inside itself, so a hit on the first deny-list
|
|
2153
|
+
// row answered sooner than a hit on the last, and a miss ran the whole list.
|
|
2154
|
+
// Split by form and route each half through timingSafeEqualAny, which has no
|
|
2155
|
+
// exit to take. The two buckets are compared independently because the
|
|
2156
|
+
// colon form and the hex form are different byte lengths.
|
|
2157
|
+
var colonEntries = [];
|
|
2158
|
+
var hexEntries = [];
|
|
2091
2159
|
for (var i = 0; i < denyList.length; i++) {
|
|
2092
2160
|
var entry = denyList[i];
|
|
2093
2161
|
if (typeof entry !== "string" || entry.length === 0) continue;
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
var compareBuf = entry.indexOf(":") !== -1 ? fpColon : fpHex;
|
|
2097
|
-
if (normalizedBuf.length === compareBuf.length &&
|
|
2098
|
-
nodeCrypto.timingSafeEqual(normalizedBuf, compareBuf)) {
|
|
2099
|
-
return true;
|
|
2100
|
-
}
|
|
2162
|
+
if (entry.indexOf(":") !== -1) colonEntries.push(Buffer.from(entry.toUpperCase()));
|
|
2163
|
+
else hexEntries.push(Buffer.from(entry.toLowerCase(), "hex"));
|
|
2101
2164
|
}
|
|
2102
|
-
|
|
2165
|
+
var hitColon = timingSafeEqualAny(fpColon, colonEntries);
|
|
2166
|
+
var hitHex = timingSafeEqualAny(fpHex, hexEntries);
|
|
2167
|
+
return hitColon || hitHex;
|
|
2103
2168
|
}
|
|
2104
2169
|
|
|
2105
2170
|
// ---- RFC 7469 SubjectPublicKeyInfo pinning ----
|
|
@@ -2461,6 +2526,7 @@ module.exports = {
|
|
|
2461
2526
|
kdf: kdf,
|
|
2462
2527
|
// Comparison
|
|
2463
2528
|
timingSafeEqual: timingSafeEqual,
|
|
2529
|
+
timingSafeEqualAny: timingSafeEqualAny,
|
|
2464
2530
|
// Cert fingerprint helpers
|
|
2465
2531
|
hashCertFingerprint: hashCertFingerprint,
|
|
2466
2532
|
isCertRevoked: isCertRevoked,
|
package/lib/eat.js
CHANGED
|
@@ -88,13 +88,17 @@ function _nonceMatches(claimValue, expected) {
|
|
|
88
88
|
var exp = _toBuf(expected);
|
|
89
89
|
if (!exp) return false;
|
|
90
90
|
// eat_nonce may be a single byte string or an array of them (one per
|
|
91
|
-
// verifier).
|
|
91
|
+
// verifier). Compare against every candidate: the loop this replaced said
|
|
92
|
+
// "constant-time compare against each candidate" and then returned on the
|
|
93
|
+
// first hit, so the time reported WHICH verifier's nonce matched — an
|
|
94
|
+
// ordering the relying party did not agree to publish.
|
|
92
95
|
var candidates = Array.isArray(claimValue) ? claimValue : [claimValue];
|
|
96
|
+
var bufs = [];
|
|
93
97
|
for (var i = 0; i < candidates.length; i++) {
|
|
94
98
|
var c = _toBuf(candidates[i]);
|
|
95
|
-
if (c
|
|
99
|
+
if (c) bufs.push(c);
|
|
96
100
|
}
|
|
97
|
-
return
|
|
101
|
+
return bCrypto.timingSafeEqualAny(exp, bufs);
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/**
|
|
@@ -703,19 +703,23 @@ function verify(msg, opts) {
|
|
|
703
703
|
// buried inside another member's value or parameters. Peer-supplied
|
|
704
704
|
// sha-512 / sha-256 identifiers stay the operator's responsibility.
|
|
705
705
|
var expectedDigest = contentDigest(m.body); // "sha3-512=:<b64>:"
|
|
706
|
-
var matchedDigest = false;
|
|
707
706
|
var digestMembers = structuredFields.splitTopLevel(presented, ",");
|
|
707
|
+
var offeredDigests = [];
|
|
708
708
|
for (var di = 0; di < digestMembers.length; di++) {
|
|
709
709
|
var member = digestMembers[di].trim();
|
|
710
710
|
var deq = member.indexOf("=");
|
|
711
711
|
if (deq < 1) continue;
|
|
712
712
|
var dkv = structuredFields.parseKeyValuePiece(member);
|
|
713
713
|
if (dkv.key !== "sha3-512") continue;
|
|
714
|
-
|
|
715
|
-
// crypto.timingSafeEqual is the length-tolerant constant-time wrapper
|
|
716
|
-
// (returns false for unequal lengths without leaking via a length branch).
|
|
717
|
-
if (bCrypto.timingSafeEqual(memberCanonical, expectedDigest)) { matchedDigest = true; break; }
|
|
714
|
+
offeredDigests.push("sha3-512=" + dkv.value.trim());
|
|
718
715
|
}
|
|
716
|
+
// Every offered member is compared. The `break` this replaced stopped at
|
|
717
|
+
// the first match, so a Content-Digest header whose first sha3-512 member
|
|
718
|
+
// matched answered sooner than one whose last did — the position of the
|
|
719
|
+
// matching member, reported by timing. timingSafeEqualAny is the
|
|
720
|
+
// length-tolerant constant-time compare (an unequal length is a non-match
|
|
721
|
+
// without a branch that leaks anything: digest lengths are fixed).
|
|
722
|
+
var matchedDigest = bCrypto.timingSafeEqualAny(expectedDigest, offeredDigests);
|
|
719
723
|
if (!matchedDigest) {
|
|
720
724
|
return { valid: false, reason: "content-digest-mismatch" };
|
|
721
725
|
}
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -114,12 +114,16 @@ function _nonceManager(rotateSec) {
|
|
|
114
114
|
if (shutdown) return false;
|
|
115
115
|
_maybeRotate();
|
|
116
116
|
if (typeof n !== "string" || n.length === 0) return false;
|
|
117
|
-
// Constant-time compare so server-issued nonce probing can't
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
// Constant-time compare so server-issued nonce probing can't narrow the
|
|
118
|
+
// rolling-pair bytes via response-timing. Both halves of the pair are
|
|
119
|
+
// compared every time: returning on `current` answered a client holding
|
|
120
|
+
// the current nonce sooner than one holding the previous one, which
|
|
121
|
+
// reports where in the rotation window the caller sits — the property
|
|
122
|
+
// the comment above already claimed this had.
|
|
123
|
+
var accepted = [];
|
|
124
|
+
if (current) accepted.push(current.nonce);
|
|
125
|
+
if (previous) accepted.push(previous.nonce);
|
|
126
|
+
return bCrypto.timingSafeEqualAny(n, accepted);
|
|
123
127
|
},
|
|
124
128
|
// Hot-reload coexistence. Operators redeploying without
|
|
125
129
|
// a clean process restart need a way to drain in-flight clients
|
package/lib/parsers/safe-env.js
CHANGED
|
@@ -64,6 +64,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
64
64
|
var numericBounds = require("../numeric-bounds");
|
|
65
65
|
var safeBuffer = require("../safe-buffer");
|
|
66
66
|
var safeJson = require("../safe-json");
|
|
67
|
+
var codepointClass = require("../codepoint-class");
|
|
67
68
|
var { FrameworkError } = require("../framework-error");
|
|
68
69
|
var { boot } = require("../log");
|
|
69
70
|
|
|
@@ -90,10 +91,117 @@ class SafeEnvError extends FrameworkError {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
// Split an unquoted value at an inline `# comment`, returning the text BEFORE
|
|
95
|
+
// the comment, or null when there is no comment to strip. Replaces
|
|
96
|
+
// `/^([^\s#]*(?:[ \t]+[^#\s]+)*)\s+#.*$/`.
|
|
97
|
+
//
|
|
98
|
+
// That pattern was NOT catastrophic, and it is worth saying so rather than
|
|
99
|
+
// implying the family rule only ever removes live bugs. `[ \t]+` and `[^#\s]+`
|
|
100
|
+
// are disjoint, so the partition of a line into separators and tokens is unique
|
|
101
|
+
// and there is nothing for the engine to backtrack through — measured flat to
|
|
102
|
+
// 40k characters across every shape that defeats the match. It goes because the
|
|
103
|
+
// rule is uniform: a screen in this family costs the length of its input, and
|
|
104
|
+
// that holds by construction instead of by someone re-deriving the ambiguity of
|
|
105
|
+
// each pattern correctly, every time one is edited. The neighbouring
|
|
106
|
+
// trailing-whitespace strip is already a walk, for a reason that WAS measured —
|
|
107
|
+
// `.replace(/[ \t]+$/)` is quadratic in V8 and the parser caps total bytes, not
|
|
108
|
+
// bytes per line.
|
|
109
|
+
//
|
|
110
|
+
// Reading the pattern back gives a rule with no search in it. Group 1 admits no
|
|
111
|
+
// `#` at all, so the `#` the pattern finds is always the FIRST one; it must be
|
|
112
|
+
// preceded by whitespace; and everything before that whitespace run may only be
|
|
113
|
+
// separated by spaces and tabs (a `\v`, `\f` or U+00A0 is whitespace to `\s` but
|
|
114
|
+
// not to `[ \t]`, so the pattern would not match and neither does this).
|
|
115
|
+
function _splitInlineComment(rest) {
|
|
116
|
+
var hash = rest.indexOf("#");
|
|
117
|
+
if (hash <= 0) return null; // absent, or nothing before it
|
|
118
|
+
if (!codepointClass.inRanges(rest.charCodeAt(hash - 1), codepointClass.WHITESPACE_RANGES)) {
|
|
119
|
+
return null; // `color#red` keeps its `#`
|
|
120
|
+
}
|
|
121
|
+
// Walk back over the whitespace run that `\s+` consumed.
|
|
122
|
+
var end = hash - 1;
|
|
123
|
+
while (end >= 0 && codepointClass.inRanges(rest.charCodeAt(end), codepointClass.WHITESPACE_RANGES)) {
|
|
124
|
+
end -= 1;
|
|
125
|
+
}
|
|
126
|
+
var head = rest.slice(0, end + 1);
|
|
127
|
+
// The separators inside group 1 are `[ \t]` only.
|
|
128
|
+
for (var i = 0; i < head.length; i += 1) {
|
|
129
|
+
var cc = head.charCodeAt(i);
|
|
130
|
+
if (!codepointClass.inRanges(cc, codepointClass.WHITESPACE_RANGES)) continue;
|
|
131
|
+
if (cc !== 0x20 && cc !== 0x09) return null;
|
|
132
|
+
}
|
|
133
|
+
// `#.*$` cannot cross a line terminator, and `$` without `m` is end-of-string.
|
|
134
|
+
// Lines arrive split on LF and CR already, but U+2028 and U+2029 survive that
|
|
135
|
+
// and are terminators to the grammar, so a comment containing one meant the
|
|
136
|
+
// pattern did not match and the whole value was kept.
|
|
137
|
+
for (var t = hash; t < rest.length; t += 1) {
|
|
138
|
+
var tc = rest.charCodeAt(t);
|
|
139
|
+
if (tc === 0x0A || tc === 0x0D || tc === 0x2028 || tc === 0x2029) return null;
|
|
140
|
+
}
|
|
141
|
+
return head;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// `export FOO=bar` — the POSIX shell convention. Replaces a `/^export\s+/` test
|
|
145
|
+
// paired with an identical `.replace`, which walked the prefix twice.
|
|
146
|
+
// The default key shape, `^[A-Z_][A-Z0-9_]*$`, as a walk. The label is what the
|
|
147
|
+
// refusal message quotes, so an operator still sees the shape they violated
|
|
148
|
+
// rather than the word "function".
|
|
149
|
+
var _ASCII_UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
150
|
+
var _KEY_HEAD_CHARS = _ASCII_UPPER + "_";
|
|
151
|
+
var _KEY_TAIL_CHARS = _ASCII_UPPER + codepointClass.ASCII_DIGITS + "_";
|
|
152
|
+
var DEFAULT_KEY_SHAPE = "^[A-Z_][A-Z0-9_]*$";
|
|
153
|
+
|
|
154
|
+
function _matchesDefaultKeyShape(key) {
|
|
155
|
+
if (typeof key !== "string" || key.length === 0) return false;
|
|
156
|
+
if (_KEY_HEAD_CHARS.indexOf(key.charAt(0)) === -1) return false;
|
|
157
|
+
if (key.length === 1) return true; // isRunOf("") is false
|
|
158
|
+
return codepointClass.isRunOf(key.slice(1), _KEY_TAIL_CHARS);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
var _EXPORT = "export";
|
|
162
|
+
|
|
163
|
+
function _stripExportPrefix(line) {
|
|
164
|
+
if (line.slice(0, _EXPORT.length) !== _EXPORT) return line;
|
|
165
|
+
var i = _EXPORT.length;
|
|
166
|
+
var afterWord = i;
|
|
167
|
+
while (i < line.length &&
|
|
168
|
+
codepointClass.inRanges(line.charCodeAt(i), codepointClass.WHITESPACE_RANGES)) i += 1;
|
|
169
|
+
if (i === afterWord) return line; // `exported=1` is a key, not a prefix
|
|
170
|
+
return line.slice(i);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// `$VAR` and `${VAR}` references, which this parser refuses rather than expands.
|
|
174
|
+
// Replaces `/\$(\{[A-Za-z_]|[A-Za-z_])/` — a `$` followed either by a brace and
|
|
175
|
+
// an identifier head, or by an identifier head directly. A trailing `$`, `$1` or
|
|
176
|
+
// `$ ` is a literal dollar and stays one.
|
|
177
|
+
function _isIdentifierHead(ch) {
|
|
178
|
+
// The length guard is load-bearing: `charAt` past the end returns "", and
|
|
179
|
+
// `indexOf("")` is 0 on every string, so without it a trailing `$` reads as a
|
|
180
|
+
// reference and a literal dollar at end-of-value is refused.
|
|
181
|
+
if (ch.length !== 1) return false;
|
|
182
|
+
return ch === "_" || codepointClass.ASCII_ALPHA.indexOf(ch) !== -1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _hasVariableReference(text) {
|
|
186
|
+
for (var i = 0; i < text.length; i += 1) {
|
|
187
|
+
if (text.charAt(i) !== "$") continue;
|
|
188
|
+
var next = text.charAt(i + 1);
|
|
189
|
+
if (next === "{") {
|
|
190
|
+
if (_isIdentifierHead(text.charAt(i + 2))) return true;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (_isIdentifierHead(next)) return true;
|
|
194
|
+
}
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
93
198
|
var DEFAULTS = {
|
|
94
199
|
maxBytes: C.BYTES.kib(64),
|
|
95
200
|
maxKeys: 1_000,
|
|
96
|
-
|
|
201
|
+
// The DEFAULT shape is a walk, not a pattern. An operator may still pass a
|
|
202
|
+
// RegExp — there the pattern is the INPUT, which is the one case this family
|
|
203
|
+
// allows — and that override is honoured unchanged below.
|
|
204
|
+
keyShape: null,
|
|
97
205
|
applyToProcess: false,
|
|
98
206
|
allowOverwrite: false,
|
|
99
207
|
rejectUnknown: false,
|
|
@@ -120,7 +228,11 @@ function parse(input, opts) {
|
|
|
120
228
|
? Math.min(opts.maxBytes, C.BYTES.mib(1)) : DEFAULTS.maxBytes;
|
|
121
229
|
var maxKeys = opts.maxKeys !== undefined
|
|
122
230
|
? Math.min(opts.maxKeys, 100_000) : DEFAULTS.maxKeys;
|
|
123
|
-
var
|
|
231
|
+
var operatorKeyShape = opts.keyShape instanceof RegExp ? opts.keyShape : null;
|
|
232
|
+
var keyShapeLabel = operatorKeyShape ? String(operatorKeyShape) : DEFAULT_KEY_SHAPE;
|
|
233
|
+
function keyShapeAccepts(key) {
|
|
234
|
+
return operatorKeyShape ? operatorKeyShape.test(key) : _matchesDefaultKeyShape(key);
|
|
235
|
+
}
|
|
124
236
|
|
|
125
237
|
input = safeBuffer.normalizeText(input, {
|
|
126
238
|
maxBytes: maxBytes,
|
|
@@ -129,7 +241,9 @@ function parse(input, opts) {
|
|
|
129
241
|
sizeCode: "env/too-large",
|
|
130
242
|
});
|
|
131
243
|
|
|
132
|
-
|
|
244
|
+
// splitLinesAny breaks on LF, CR and CRLF-as-one — the same three the
|
|
245
|
+
// `/\r\n|\r|\n/` alternation covered, in the same order of preference.
|
|
246
|
+
var rawLines = codepointClass.splitLinesAny(input);
|
|
133
247
|
var values = Object.create(null);
|
|
134
248
|
var seen = new Set();
|
|
135
249
|
|
|
@@ -137,14 +251,12 @@ function parse(input, opts) {
|
|
|
137
251
|
var line = rawLines[i];
|
|
138
252
|
var lineNumber = i + 1;
|
|
139
253
|
// Trim leading whitespace (operators sometimes indent for readability)
|
|
140
|
-
var trimmed =
|
|
254
|
+
var trimmed = codepointClass.trimChars(line, " \t", { trailing: false });
|
|
141
255
|
if (trimmed.length === 0) continue;
|
|
142
256
|
if (trimmed.charAt(0) === "#") continue;
|
|
143
257
|
|
|
144
258
|
// Optional `export ` prefix (POSIX shell convention)
|
|
145
|
-
|
|
146
|
-
trimmed = trimmed.replace(/^export\s+/, "");
|
|
147
|
-
}
|
|
259
|
+
trimmed = _stripExportPrefix(trimmed);
|
|
148
260
|
|
|
149
261
|
var eqIdx = trimmed.indexOf("=");
|
|
150
262
|
if (eqIdx < 0) {
|
|
@@ -159,9 +271,9 @@ function parse(input, opts) {
|
|
|
159
271
|
if (pick.isPoisonedKey(key)) {
|
|
160
272
|
throw new SafeEnvError("forbidden key '" + key + "'", "env/poisoned-key", lineNumber);
|
|
161
273
|
}
|
|
162
|
-
if (!
|
|
274
|
+
if (!keyShapeAccepts(key)) {
|
|
163
275
|
throw new SafeEnvError(
|
|
164
|
-
"key '" + key + "' does not match keyShape " +
|
|
276
|
+
"key '" + key + "' does not match keyShape " + keyShapeLabel,
|
|
165
277
|
"env/bad-key-shape", lineNumber
|
|
166
278
|
);
|
|
167
279
|
}
|
|
@@ -188,18 +300,14 @@ function parse(input, opts) {
|
|
|
188
300
|
// Unquoted value: strip trailing whitespace + inline `# comment`.
|
|
189
301
|
// The comment marker MUST be preceded by whitespace to count
|
|
190
302
|
// (so a value like `KEY=color#red` keeps the literal `#`).
|
|
191
|
-
var
|
|
303
|
+
var beforeComment = _splitInlineComment(rest);
|
|
192
304
|
// stripTrailingHspace is a linear char-scan; .replace(/[ \t]+$/) is O(n^2)
|
|
193
305
|
// in V8 and the env parser only caps TOTAL bytes, not per-line, so a
|
|
194
306
|
// single huge-whitespace value line would otherwise hang the parser.
|
|
195
|
-
|
|
196
|
-
value = safeBuffer.stripTrailingHspace(commentMatch[1]);
|
|
197
|
-
} else {
|
|
198
|
-
value = safeBuffer.stripTrailingHspace(rest);
|
|
199
|
-
}
|
|
307
|
+
value = safeBuffer.stripTrailingHspace(beforeComment === null ? rest : beforeComment);
|
|
200
308
|
// Reject `$VAR` style references — explicit error so operators
|
|
201
309
|
// see the policy rather than silently getting unexpanded text.
|
|
202
|
-
if (
|
|
310
|
+
if (_hasVariableReference(value)) {
|
|
203
311
|
throw new SafeEnvError(
|
|
204
312
|
"$VAR / ${VAR} expansion not supported (escape with \\$ if literal, or quote and expand yourself)",
|
|
205
313
|
"env/expansion-banned", lineNumber
|
|
@@ -254,7 +362,8 @@ function _decodeDoubleQuoted(rest, lineNumber) {
|
|
|
254
362
|
// Optional inline comment after closing quote — ignore.
|
|
255
363
|
return out;
|
|
256
364
|
}
|
|
257
|
-
|
|
365
|
+
var afterDollar = rest.charAt(i + 1);
|
|
366
|
+
if (ch === "$" && (afterDollar === "{" || _isIdentifierHead(afterDollar))) {
|
|
258
367
|
throw new SafeEnvError(
|
|
259
368
|
"$VAR / ${VAR} expansion not supported in double-quoted value (use \\$ for literal $)",
|
|
260
369
|
"env/expansion-banned", lineNumber
|