@blamejs/core 0.18.34 → 0.18.36
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 +65 -0
- package/NOTICE +1 -1
- package/README.md +2 -2
- package/lib/auth/password.js +14 -5
- package/lib/crypto.js +74 -8
- package/lib/eat.js +7 -3
- package/lib/gate-contract.js +88 -3
- package/lib/guard-archive.js +7 -9
- package/lib/guard-email.js +45 -15
- package/lib/guard-json.js +56 -13
- package/lib/guard-markdown.js +6 -1
- package/lib/guard-xml.js +10 -1
- package/lib/guard-yaml.js +56 -11
- package/lib/http-message-signature.js +9 -5
- package/lib/middleware/dpop.js +10 -6
- package/lib/standard-webhooks.js +7 -5
- package/lib/totp.js +11 -2
- package/lib/vendor/MANIFEST.json +11 -11
- package/lib/vendor/blamejs-pki.cjs +10 -8
- 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,71 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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`.
|
|
20
|
+
|
|
21
|
+
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:
|
|
22
|
+
|
|
23
|
+
- `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.
|
|
24
|
+
- `b.eat` nonce verification — which verifier's nonce an attestation token carried, under a comment reading "Constant-time compare against each candidate".
|
|
25
|
+
- 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".
|
|
26
|
+
- `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.
|
|
27
|
+
- `b.httpMessageSignature.verify` — the position of the matching member in a `Content-Digest` header.
|
|
28
|
+
|
|
29
|
+
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:
|
|
30
|
+
|
|
31
|
+
- `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.
|
|
32
|
+
- `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.
|
|
33
|
+
|
|
34
|
+
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.
|
|
35
|
+
|
|
36
|
+
`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.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
An ordinary one-pair verifier stays quiet in both checks — one comparison has no position to report.
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
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)
|
|
55
|
+
|
|
56
|
+
- 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.
|
|
57
|
+
|
|
58
|
+
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.
|
|
59
|
+
|
|
60
|
+
**Read the behaviour changes below before upgrading if you rely on `b.guardEmail`, `b.guardXml.sanitize`, `b.guardMarkdown.sanitize` or `b.guardJson.parse`.** Two of them refuse more than before and three repair where they used to throw.
|
|
61
|
+
|
|
62
|
+
`b.guardJson` and `b.guardYaml` were `KIND: "content"` — whose contract is serve / audit-only / sanitize / refuse — and declared `strip` in six policy cells each while exporting no `sanitize` at all. Twelve settings that no wiring could honour. Both now have one. **Added:** *b.guardJson.sanitize and b.guardYaml.sanitize* — Both guards are content guards whose contract includes a repair action, and both declared `strip` policies with nothing to perform them. `b.guardJson.sanitize` applies the character-strip policies and then re-serializes, which is the repair its gate already ran — now shared, so the two cannot drift. `b.guardYaml.sanitize` repairs at the character level only: removing an invisible or control character is a text edit needing no re-emit, which is why it is safe here when re-serializing a YAML document is not. The tag, alias and multi-document shapes have no faithful round-trip and refuse through their own policies. · *b.gateContract.throwOnRefusedDisposition(issues, cfg)* — The refusal step for a guard whose findings carry a policy. Throws on the first finding whose disposition is `refuse`, and hands whatever the guard leaves unclassified to `throwOnRefusalSeverity`, so a structural finding with no policy — a parse failure, an alias explosion, a blown cap — keeps the conservative severity answer rather than a free pass. Replaces the hand-rolled loop this release would otherwise have grown in three places. **Fixed:** *A declared character policy is the policy that runs* — `b.gateContract.charThreatDisposition` binds each of the five shared character classes to its own policy, and the guards that never routed through it fell back to a severity rule where `critical` and `high` both refuse. `b.guardYaml` and `b.guardEmail` now route through it, so `strip` repairs and `audit` records instead of both refusing.
|
|
63
|
+
|
|
64
|
+
The same confusion sat one level down in the sanitize path. `b.guardXml` and `b.guardMarkdown` narrowed their refusal filter to `critical`, which catches a bidi control whatever its policy says — so `bidiPolicy: "strip"` threw from `sanitize()` while the gate, which bypasses that function precisely to avoid this, stripped the character and served the document. Two public entry points gave opposite answers for one setting. Refusal now comes from the policy in both.
|
|
65
|
+
|
|
66
|
+
`b.guardJson.parse` refused any `critical` pre-parse finding, so `bidiPolicy: "audit"` refused the document rather than recording it; it also stripped control, zero-width and Tags characters under `strip` but never bidi, so that policy left the character in place and then refused it for being critical. Both are fixed. · *b.guardEmail refuses a bidi override and a null byte at `balanced`* — **Behaviour change.** These two cells declared `strip` and refused anyway, because both findings are critical and the refusal ran before the strip table. Of the two ways to make the declaration and the behaviour agree, this is the one that does not weaken a guard, and it is the right reading for a message: a bidi override reorders how an address or a subject line renders, which is the mechanism behind a spoofed sender, and a null byte truncates a header at whichever parser reads it first, so two halves of a message can disagree about where a field ends.
|
|
67
|
+
|
|
68
|
+
Nothing that was accepted before is refused now — the profile text changed to match what the guard already did. Operators who set `bidiPolicy` or `nullBytePolicy` explicitly are unaffected. · *b.guardXml.sanitize, b.guardMarkdown.sanitize and b.guardJson.parse repair where they used to throw* — **Behaviour change.** Under `balanced` and `permissive` these declare `strip` or `audit` for the character classes, and all three now honour it: `sanitize()` returns the repaired document instead of raising, and `parse()` accepts a document carrying an audited character.
|
|
69
|
+
|
|
70
|
+
A caller that treated the exception as "this input is dangerous" now receives a repaired value instead. That is what `strip` asks for, and it is what the corresponding gates already did — the sanitize functions were the outliers. Every class set to `reject` still refuses, verified per guard per profile. · *guardJson.parse enforces its byte ceiling before it strips anything* — `maxBytes` was checked at the end of `parse`, after the character-strip policies had already run — so a small document padded with several hundred zero-width, Tags or bidi characters shrank under the limit on the way through and was accepted. A 911-byte input passed a 16-byte cap. A resource cap that an attacker removes by sending MORE input is pointing the wrong way, so the ceiling now binds the input as received, before any strip mutates it.
|
|
71
|
+
|
|
72
|
+
The hole was open for zero-width and Tags already; a `bidiPolicy: "strip"` pass added in this release made it reachable for a third class, which is how it was noticed. · *guardJson.sanitize repairs a mid-stream BOM under bomPolicy* — U+FEFF reached the strip table only as a zero-width character, and `parse` removed only a LEADING one — so with `bomPolicy: "strip"` and `zeroWidthPolicy: "allow"`, validation reported `bom-mid-stream` on a document `sanitize` then returned still carrying it. The repair follows the BOM's own policy now: `strip` removes it wherever it sits, `reject` refuses, `allow` keeps it. · *Vendored dependencies are current* — `@blamejs/pki` moves to 0.5.10, whose change is a spelling normalization across its documentation and source comments plus a gate that keeps them consistent. No functional change to the module, no lifecycle scripts, and its runtime dependency set stays empty. · *b.guardArchive drops four character policies it never read* — Every profile declared `bidiPolicy`, `controlPolicy`, `nullBytePolicy` and `zeroWidthPolicy`, and nothing in the guard consulted any of them — an operator passing `zeroWidthPolicy: "reject"` was configuring nothing and had no way to discover that. Entry-name character policy comes from `filenameProfile`, which routes the name through `b.guardFilename`; that is the setting to use, and it is unchanged. The four are removed rather than wired, because two spellings of one setting is how they come to disagree. **Detectors:** *A guard may only declare a policy it can perform, and must perform the one it declares* — Two family-wide invariants. The first is structural: for every registered guard, a policy that promises a repair requires a `sanitize` to perform it, and an `entries` guard — which cannot repair a hostile archive member — may not declare one at all. That is the check twelve unperformable settings shipped past.
|
|
73
|
+
|
|
74
|
+
The second drives the gate: for every content guard, profile and character class, the action taken must be the action the policy names. It compares against a baseline of the same document without the character and judges only where the injected character is the lone new finding, so a document broken by the injection is not mistaken for a policy failure. All five classes are covered, with no exemptions. · *A failing check prints the diagnostic it built* — The shared test assertion accepted a third argument carrying the offending items, and discarded it at all 1829 call sites that passed one, so a failing gate printed its label and nothing else. It is now included, flattened to one line and bounded, for the same reason the error formatter is: the text can carry a fixture's bytes verbatim. The first run after the change named a third affected guard that the label alone had hidden. **References:** [CVE-2021-42574 — Trojan Source, bidirectional override characters](https://nvd.nist.gov/vuln/detail/CVE-2021-42574) · [Unicode Technical Report #36 — Unicode Security Considerations](https://www.unicode.org/reports/tr36/) · [Unicode Standard Annex #9 — Unicode Bidirectional Algorithm](https://www.unicode.org/reports/tr9/)
|
|
75
|
+
|
|
11
76
|
- v0.18.34 (2026-08-17) — **Two policies the framework declared and did not apply: a wildcard registry's organizational domain, and a guard's refusal of invisible characters.** Eight guards resolved `zeroWidthPolicy: "reject"` under `profile: "strict"` and accepted a zero-width character anyway, because the shared scan was opt-in through an argument six callers never passed. `b.guardYaml`, `b.guardXml`, `b.guardMarkdown`, `b.guardShell`, `b.guardRegex`, `b.guardJsonpath`, `b.guardTemplate` and `b.guardJson` are affected, and `b.guardDomain` at every profile. The control table was also missing `U+007F`, contradicting the module's own predicate, and the zero-width table was missing `U+2061`-`U+2064`. **A strict profile now refuses input it previously served** — check that before upgrading if you validate operator-supplied text.
|
|
12
77
|
|
|
13
78
|
`b.publicSuffix` ranked a matching exact rule above a matching wildcard rule. The Public Suffix List ranks by label count instead, so `x.kawasaki.jp` takes `*.kawasaki.jp` and not `jp` — and under the old order every name in such a registry collapsed onto the registry's own organizational domain. 275 of the list's 283 wildcard rules resolved that way. Anything deriving a boundary from that answer inherited it: DMARC relaxed alignment, cookie scope, same-site decisions. If you serve or accept mail from names under a wildcard registry, this changes what counts as aligned.
|
package/NOTICE
CHANGED
|
@@ -68,7 +68,7 @@ Used for: FIPS 203 ML-KEM (ml_kem_512 / ml_kem_768 / ml_kem_1024),
|
|
|
68
68
|
reference implementation.
|
|
69
69
|
--------------------------------------------------------------------------------
|
|
70
70
|
Component: @blamejs/pki
|
|
71
|
-
Version: 0.5.
|
|
71
|
+
Version: 0.5.10
|
|
72
72
|
Source: https://github.com/blamejs/pki
|
|
73
73
|
License: Apache-2.0
|
|
74
74
|
Copyright: Copyright (c) blamejs contributors
|
package/README.md
CHANGED
|
@@ -322,7 +322,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
|
|
|
322
322
|
| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | 2.3.0 | [Paul Miller](https://github.com/paulmillr) | Browser (ESM) build only — SHAKE256 / SHA-3 / SHA-2 / HMAC / HKDF for the client half of a hybrid exchange. The server side reaches all of these through `node:crypto`, so there is no server bundle |
|
|
323
323
|
| [`@noble/curves`](https://github.com/paulmillr/noble-curves) | 2.3.0 (bundles @noble/hashes 2.3.0) | [Paul Miller](https://github.com/paulmillr) | RFC 9497 Oblivious Pseudo-Random Function (OPRF / VOPRF / POPRF) over ristretto255 / P-256 / P-384 / P-521, behind `b.crypto.oprf` |
|
|
324
324
|
| [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum) | 0.7.0 (bundles @noble/hashes, @noble/curves, @noble/ciphers 2.3.0) | [Paul Miller](https://github.com/paulmillr) | Pure-JS FIPS 203 ML-KEM (`ml_kem_512` / `ml_kem_768` / `ml_kem_1024`), FIPS 204 ML-DSA (`ml_dsa_44/65/87`), FIPS 205 SLH-DSA (`slh_dsa_*`). First-class on both server-side and client-side via `b.pqcSoftware` — security-first defaults pin to the highest cat-5 levels (ML-KEM-1024, ML-DSA-87, SLH-DSA-SHAKE-256f); interoperable with Node's built-in WebCrypto ML-KEM that `b.crypto.encrypt` / `b.middleware.apiEncrypt` use. A browser (ESM) build ships beside it carrying the KEM suites only — a client half encapsulates and does not sign |
|
|
325
|
-
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.
|
|
325
|
+
| [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.10 | [blamejs](https://github.com/blamejs) | Zero-dependency pure-JS X.509 / CRL / PKCS#12 / CSR / CMS toolkit backing `b.mtlsCa` — ML-DSA-87 (FIPS 204) post-quantum + ECDSA-P384 cert signing, PBMAC1 PKCS#12 packaging, chain validation (no openssl CLI) — and the WebAuthn attestation / assertion verification behind `b.auth.passkey` |
|
|
326
326
|
| [`SecLists` 10k-most-common.txt](https://github.com/danielmiessler/SecLists/blob/master/Passwords/Common-Credentials/10k-most-common.txt) | master snapshot | [Daniel Miessler / SecLists contributors](https://github.com/danielmiessler/SecLists) (CC-BY-3.0) | Top-10000 common-password dictionary read by `b.auth.password.policy()` for the NIST 800-63B §5.1.1.2 "previously breached" check |
|
|
327
327
|
| [`prismjs`](https://prismjs.com/) | 1.30.0 | [Lea Verou + contributors](https://github.com/PrismJS/prism) | Syntax highlighting in the example wiki's code blocks (browser-side) |
|
|
328
328
|
|
|
@@ -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
|
/**
|
package/lib/gate-contract.js
CHANGED
|
@@ -1285,6 +1285,71 @@ function resolveProfileName(opts, postures, defaultProfile) {
|
|
|
1285
1285
|
return opts.profile || postureProfile || defaultProfile;
|
|
1286
1286
|
}
|
|
1287
1287
|
|
|
1288
|
+
/**
|
|
1289
|
+
* @primitive b.gateContract.throwOnRefusedDisposition
|
|
1290
|
+
* @signature b.gateContract.throwOnRefusedDisposition(issues, cfg)
|
|
1291
|
+
* @since 0.18.35
|
|
1292
|
+
* @status stable
|
|
1293
|
+
* @related b.gateContract.throwOnRefusalSeverity, b.gateContract.charThreatDisposition, b.gateContract.defineGuard
|
|
1294
|
+
*
|
|
1295
|
+
* The refusal step for a guard whose findings carry a POLICY. Throws on the
|
|
1296
|
+
* first issue the guard's `dispositionFor` resolves to `refuse`, and hands
|
|
1297
|
+
* whatever it leaves unclassified to `throwOnRefusalSeverity`, so a kind with
|
|
1298
|
+
* no policy keeps the conservative severity answer instead of a free pass.
|
|
1299
|
+
*
|
|
1300
|
+
* Severity is the wrong axis for these guards and quietly so. `bidi-override`
|
|
1301
|
+
* and `null-byte` are stamped `critical` whatever their policy says, and
|
|
1302
|
+
* `b.guardJson` has eight kinds that never reach `critical` at all — so a
|
|
1303
|
+
* severity-gated refusal both refuses what the operator asked to record and
|
|
1304
|
+
* lets through what they asked to reject, in the same guard. Policy first,
|
|
1305
|
+
* severity only for what policy did not decide.
|
|
1306
|
+
*
|
|
1307
|
+
* @opts
|
|
1308
|
+
* dispositionFor: function, // (issue, opts) -> "refuse"|"sanitize"|"audit"|null
|
|
1309
|
+
* opts: object, // resolved guard opts handed to dispositionFor
|
|
1310
|
+
* errorClass: function, // the guard's FrameworkError subclass; required
|
|
1311
|
+
* codePrefix: string, // error-code namespace; the `.refused` fallback
|
|
1312
|
+
* op: string, // operation name in the message (default "sanitize")
|
|
1313
|
+
* severities: string[], // severities that refuse an UNCLASSIFIED finding
|
|
1314
|
+
* skipKinds: string[], // kinds the caller already handled itself
|
|
1315
|
+
*
|
|
1316
|
+
* @example
|
|
1317
|
+
* b.gateContract.throwOnRefusedDisposition(detect(input, opts), {
|
|
1318
|
+
* dispositionFor: _gateDispositionFor, opts: opts,
|
|
1319
|
+
* errorClass: GuardYamlError, codePrefix: "yaml",
|
|
1320
|
+
* });
|
|
1321
|
+
* // throws on a class whose policy is "reject"; a class set to "strip" or
|
|
1322
|
+
* // "audit" passes through to the caller's transform
|
|
1323
|
+
*/
|
|
1324
|
+
function throwOnRefusedDisposition(issues, cfg) {
|
|
1325
|
+
var unclassified = [];
|
|
1326
|
+
for (var i = 0; i < issues.length; i += 1) {
|
|
1327
|
+
var issue = issues[i];
|
|
1328
|
+
if (cfg.skipKinds && cfg.skipKinds.indexOf(issue.kind) !== -1) continue;
|
|
1329
|
+
var disp = typeof cfg.dispositionFor === "function"
|
|
1330
|
+
? cfg.dispositionFor(issue, cfg.opts || {}) : null;
|
|
1331
|
+
if (disp === "refuse") {
|
|
1332
|
+
throwOnRefusalSeverity([issue], {
|
|
1333
|
+
errorClass: cfg.errorClass,
|
|
1334
|
+
codePrefix: cfg.codePrefix,
|
|
1335
|
+
op: cfg.op,
|
|
1336
|
+
// A policy refusal is the operator's choice, not an impact judgement,
|
|
1337
|
+
// so it must fire whatever severity the finding happens to carry.
|
|
1338
|
+
severities: [issue.severity],
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
if (disp !== "sanitize" && disp !== "audit") unclassified.push(issue);
|
|
1342
|
+
}
|
|
1343
|
+
if (unclassified.length) {
|
|
1344
|
+
throwOnRefusalSeverity(unclassified, {
|
|
1345
|
+
errorClass: cfg.errorClass,
|
|
1346
|
+
codePrefix: cfg.codePrefix,
|
|
1347
|
+
op: cfg.op,
|
|
1348
|
+
severities: cfg.severities,
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1288
1353
|
/**
|
|
1289
1354
|
* @primitive b.gateContract.throwOnRefusalSeverity
|
|
1290
1355
|
* @signature b.gateContract.throwOnRefusalSeverity(issues, cfg)
|
|
@@ -2812,9 +2877,28 @@ function defineGuard(spec) {
|
|
|
2812
2877
|
issues[bi].snippet || "sanitize: input is not processable");
|
|
2813
2878
|
}
|
|
2814
2879
|
}
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2880
|
+
// A guard that classifies its findings by POLICY answers here with the
|
|
2881
|
+
// policy, not with severity. Severity is the wrong axis for these:
|
|
2882
|
+
// guard-json has eight finding kinds that are never `critical`
|
|
2883
|
+
// (bom-leading, comment-block, hex-literal, nan-infinity,
|
|
2884
|
+
// single-quoted-key, trailing-comma, numeric-precision-loss) and
|
|
2885
|
+
// guard-yaml scores its octal ambiguity `high` even under `reject`.
|
|
2886
|
+
// A severity filter narrowed to ["critical"] therefore lets a finding
|
|
2887
|
+
// the operator asked to REFUSE fall past the check and into a
|
|
2888
|
+
// transform that does not repair it — the caller gets the threat back
|
|
2889
|
+
// with no error, which is the exact shape this refusal exists to stop.
|
|
2890
|
+
// Policy decides what refuses; severity covers only what policy left
|
|
2891
|
+
// unclassified. A guard that classifies nothing keeps the pure
|
|
2892
|
+
// severity rule it always had.
|
|
2893
|
+
var refuseOpts = { errorClass: ErrorClass, codePrefix: prefix };
|
|
2894
|
+
if (sanitizeSeverities) refuseOpts.severities = sanitizeSeverities;
|
|
2895
|
+
if (typeof spec.dispositionFor === "function") {
|
|
2896
|
+
refuseOpts.dispositionFor = spec.dispositionFor;
|
|
2897
|
+
refuseOpts.opts = resolved;
|
|
2898
|
+
throwOnRefusedDisposition(issues, refuseOpts);
|
|
2899
|
+
} else {
|
|
2900
|
+
throwOnRefusalSeverity(issues, refuseOpts);
|
|
2901
|
+
}
|
|
2818
2902
|
}
|
|
2819
2903
|
var out = spec.sanitizeTransform(subject, resolved);
|
|
2820
2904
|
if (ampCapField) {
|
|
@@ -3256,6 +3340,7 @@ module.exports = {
|
|
|
3256
3340
|
makeProfileResolver: makeProfileResolver,
|
|
3257
3341
|
resolveProfileName: resolveProfileName,
|
|
3258
3342
|
throwOnRefusalSeverity: throwOnRefusalSeverity,
|
|
3343
|
+
throwOnRefusedDisposition: throwOnRefusedDisposition,
|
|
3259
3344
|
badInputResultIfNotStringOrBuffer: badInputResultIfNotStringOrBuffer,
|
|
3260
3345
|
aggregateIssues: aggregateIssues,
|
|
3261
3346
|
composeHooks: composeHooks,
|
package/lib/guard-archive.js
CHANGED
|
@@ -113,9 +113,15 @@ var MAGIC_SIGNATURES = Object.freeze([
|
|
|
113
113
|
|
|
114
114
|
// ---- Profile presets ----
|
|
115
115
|
|
|
116
|
+
// Character-class policy for an entry NAME comes from `filenameProfile`, which
|
|
117
|
+
// routes the name through b.guardFilename — this guard never reads a
|
|
118
|
+
// bidi/control/null/zero-width policy of its own. It used to declare them in
|
|
119
|
+
// every profile anyway, so an operator passing `zeroWidthPolicy` here was
|
|
120
|
+
// configuring nothing and had no way to find that out. The declarations are
|
|
121
|
+
// gone rather than wired, because `filenameProfile` is already the one place
|
|
122
|
+
// that decides it and two spellings of the same setting is how they disagree.
|
|
116
123
|
var PROFILES = Object.freeze({
|
|
117
124
|
"strict": {
|
|
118
|
-
...gateContract.CHAR_THREATS_REJECT_ALL,
|
|
119
125
|
traversalPolicy: "reject",
|
|
120
126
|
absolutePathPolicy: "reject",
|
|
121
127
|
symlinkPolicy: "reject",
|
|
@@ -134,10 +140,6 @@ var PROFILES = Object.freeze({
|
|
|
134
140
|
maxNestedDepth: 0, // recursion depth, not byte size
|
|
135
141
|
},
|
|
136
142
|
"balanced": {
|
|
137
|
-
bidiPolicy: "reject",
|
|
138
|
-
controlPolicy: "reject",
|
|
139
|
-
nullBytePolicy: "reject",
|
|
140
|
-
zeroWidthPolicy: "strip",
|
|
141
143
|
traversalPolicy: "reject",
|
|
142
144
|
absolutePathPolicy: "reject",
|
|
143
145
|
symlinkPolicy: "audit", // allowed within extraction root
|
|
@@ -156,10 +158,6 @@ var PROFILES = Object.freeze({
|
|
|
156
158
|
maxNestedDepth: 2, // recursion depth, not byte size
|
|
157
159
|
},
|
|
158
160
|
"permissive": {
|
|
159
|
-
bidiPolicy: "audit",
|
|
160
|
-
controlPolicy: "strip",
|
|
161
|
-
nullBytePolicy: "reject",
|
|
162
|
-
zeroWidthPolicy: "strip",
|
|
163
161
|
traversalPolicy: "reject",
|
|
164
162
|
absolutePathPolicy: "reject",
|
|
165
163
|
symlinkPolicy: "audit",
|
package/lib/guard-email.js
CHANGED
|
@@ -368,9 +368,22 @@ var PROFILES = Object.freeze({
|
|
|
368
368
|
mixedScriptPolicy: "reject",
|
|
369
369
|
displayNameSpoofPolicy: "audit",
|
|
370
370
|
bomPolicy: "strip",
|
|
371
|
-
|
|
371
|
+
// Refuse, not strip. `sanitize` already refuses a bidi override here — the
|
|
372
|
+
// finding is critical and the refusal filter catches it before the strip
|
|
373
|
+
// table runs — so `strip` named a repair that never happened. Of the two
|
|
374
|
+
// ways to make the two agree, this is the one that does not weaken a
|
|
375
|
+
// guard: an override reorders how an address or a subject line RENDERS
|
|
376
|
+
// (CVE-2021-42574), which is the whole mechanism behind a spoofed sender,
|
|
377
|
+
// and a message is the surface where that gets acted on. The other content
|
|
378
|
+
// guards repair it at this profile; email refuses it.
|
|
379
|
+
bidiPolicy: "reject",
|
|
372
380
|
controlPolicy: "strip",
|
|
373
|
-
|
|
381
|
+
// Refuse for the same reason, and by the same evidence: `sanitize` throws
|
|
382
|
+
// `email.null-byte` here rather than removing it, because the finding is
|
|
383
|
+
// critical. A NUL truncates a header at whichever parser reads it first,
|
|
384
|
+
// so the two halves of a message can disagree about where a field ends —
|
|
385
|
+
// that is a smuggling primitive, not a blemish to be edited out.
|
|
386
|
+
nullBytePolicy: "reject",
|
|
374
387
|
zeroWidthPolicy: "strip",
|
|
375
388
|
allowedScripts: ["latin", "cyrillic", "greek"],
|
|
376
389
|
maxLocalPartBytes: LIMIT_LOCAL_PART,
|
|
@@ -1001,11 +1014,20 @@ function sanitize(input, opts) {
|
|
|
1001
1014
|
if (typeof input !== "string") {
|
|
1002
1015
|
throw _err("email.bad-input", "sanitize requires string input");
|
|
1003
1016
|
}
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1017
|
+
// Shapes with no safe repair in email — smuggling / CRLF injection /
|
|
1018
|
+
// multi-@ / mixed-script — refuse here. Which those are is decided by the
|
|
1019
|
+
// operator's policy for the class, not by the finding's impact: bidi and
|
|
1020
|
+
// null-byte are stamped `critical` whatever their policy says, so reading
|
|
1021
|
+
// severity made `bidiPolicy: "audit"` throw instead of recording. A kind the
|
|
1022
|
+
// disposition map does not classify keeps the conservative severity answer.
|
|
1006
1023
|
var issues = _detectMessageIssues(input, opts);
|
|
1007
|
-
gateContract.
|
|
1008
|
-
|
|
1024
|
+
gateContract.throwOnRefusedDisposition(issues, {
|
|
1025
|
+
dispositionFor: _gateDispositionFor,
|
|
1026
|
+
opts: opts,
|
|
1027
|
+
errorClass: GuardEmailError,
|
|
1028
|
+
codePrefix: "email",
|
|
1029
|
+
severities: ["critical"],
|
|
1030
|
+
});
|
|
1009
1031
|
// A character class set to "reject" is refused here rather than by the
|
|
1010
1032
|
// severity filter above, which only refuses critical findings — a C0 control
|
|
1011
1033
|
// scores high, so it would pass the filter, and the strip table below only
|
|
@@ -1048,17 +1070,25 @@ function sanitize(input, opts) {
|
|
|
1048
1070
|
* rv.action; // → "serve"
|
|
1049
1071
|
* });
|
|
1050
1072
|
*/
|
|
1073
|
+
// _gateDispositionFor — the shared character classes answer from their own
|
|
1074
|
+
// policy; everything else returns null and takes the conservative severity
|
|
1075
|
+
// answer it always did. Deciding the whole finding vocabulary here would be a
|
|
1076
|
+
// second, silent policy change riding along with this one — the divergence
|
|
1077
|
+
// being fixed is that a profile asking to STRIP a control character refused the
|
|
1078
|
+
// message instead, and that is what this binds.
|
|
1079
|
+
function _gateDispositionFor(issue, opts) {
|
|
1080
|
+
return gateContract.charThreatDisposition(issue, opts);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1051
1083
|
function gate(opts) {
|
|
1052
1084
|
opts = _resolveOpts(opts);
|
|
1053
|
-
return gateContract.
|
|
1054
|
-
opts.name || "guardEmail:" + (opts.profile || "default"),
|
|
1055
|
-
opts,
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
return gateContract.severityDisposition(rv.issues);
|
|
1061
|
-
});
|
|
1085
|
+
return gateContract.buildContentGate({
|
|
1086
|
+
name: opts.name || "guardEmail:" + (opts.profile || "default"),
|
|
1087
|
+
opts: opts,
|
|
1088
|
+
validate: validateMessage,
|
|
1089
|
+
dispositionFor: _gateDispositionFor,
|
|
1090
|
+
produceSanitized: function (text, o) { return sanitize(text, o); },
|
|
1091
|
+
});
|
|
1062
1092
|
}
|
|
1063
1093
|
|
|
1064
1094
|
// buildProfile / compliancePosture / loadRulePack are assembled by
|
package/lib/guard-json.js
CHANGED
|
@@ -863,6 +863,14 @@ function parse(input, opts) {
|
|
|
863
863
|
throw _err("json.bad-input", "parse requires string input");
|
|
864
864
|
}
|
|
865
865
|
// Strip BOM if policy says strip.
|
|
866
|
+
// The byte ceiling binds the input the CALLER sent, before any strip shrinks
|
|
867
|
+
// it. safeJson.parse enforces maxBytes at the end of this function, by which
|
|
868
|
+
// point the strips below have already removed whatever the attacker padded
|
|
869
|
+
// with — so a small value plus 300 zero-width, Tags or bidi characters passed
|
|
870
|
+
// a 16-byte cap. The strip that made it reachable for bidi was added here;
|
|
871
|
+
// the same hole was already open for the other two classes, which is why the
|
|
872
|
+
// bound goes at the top rather than in front of one strip.
|
|
873
|
+
codepointClass.assertWithinMaxBytes(input, opts, _err, "json");
|
|
866
874
|
if (opts.bomPolicy === "strip" && input.indexOf(BOM_CHAR) === 0) {
|
|
867
875
|
input = input.slice(1);
|
|
868
876
|
}
|
|
@@ -871,6 +879,13 @@ function parse(input, opts) {
|
|
|
871
879
|
if (opts.controlPolicy === "strip") {
|
|
872
880
|
input = codepointClass.stripRanges(input, codepointClass.CTRL_RANGES);
|
|
873
881
|
}
|
|
882
|
+
// Bidi belongs in the same list. It was the one strip-able class this walk
|
|
883
|
+
// did not remove, so `bidiPolicy: "strip"` left the character in the source
|
|
884
|
+
// and the refusal below then threw on it for being critical — a strip policy
|
|
885
|
+
// that refused, in the one place a caller cannot see it happening.
|
|
886
|
+
if (opts.bidiPolicy === "strip") {
|
|
887
|
+
input = codepointClass.stripRanges(input, codepointClass.BIDI_RANGES);
|
|
888
|
+
}
|
|
874
889
|
// Zero-width AND Unicode Tags. Tags follows the zero-width policy when the
|
|
875
890
|
// guard names none of its own — a strip of one and not the other reports the
|
|
876
891
|
// character as a threat in `validate` and then hands it back inside the
|
|
@@ -891,16 +906,19 @@ function parse(input, opts) {
|
|
|
891
906
|
"(__proto__ / constructor / prototype)");
|
|
892
907
|
}
|
|
893
908
|
// Refuse on other critical pre-parse threats per policy.
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
909
|
+
// Refuse by the operator's POLICY for the class, not by the finding's impact.
|
|
910
|
+
// Severity is fixed at `critical` for bidi and null-byte whatever the policy
|
|
911
|
+
// says, so reading it here made `bidiPolicy: "audit"` refuse the document — a
|
|
912
|
+
// setting that asks to record something and refused it instead. A kind the
|
|
913
|
+
// disposition map does not classify keeps the conservative severity answer.
|
|
914
|
+
gateContract.throwOnRefusedDisposition(_scanRawSource(input, opts), {
|
|
915
|
+
dispositionFor: _gateDispositionFor,
|
|
916
|
+
opts: opts,
|
|
917
|
+
errorClass: GuardJsonError,
|
|
918
|
+
codePrefix: "json",
|
|
919
|
+
op: "parse",
|
|
920
|
+
skipKinds: ["prototype-pollution-key"], // refused above, with its own message
|
|
921
|
+
});
|
|
904
922
|
// safeJson.parse strips POISONED_KEYS via the reviver pass; this is
|
|
905
923
|
// the canonical strip path. allowProto=true preserves them for the
|
|
906
924
|
// permissive/audit path.
|
|
@@ -1013,6 +1031,22 @@ function _gateDispositionFor(issue, opts) {
|
|
|
1013
1031
|
}
|
|
1014
1032
|
}
|
|
1015
1033
|
|
|
1034
|
+
// _sanitizeTransform — the repair itself, shared by the gate's sanitize action
|
|
1035
|
+
// and the public `sanitize` the factory generates. One body so the two cannot
|
|
1036
|
+
// drift into repairing different things.
|
|
1037
|
+
function _sanitizeTransform(text, opts) {
|
|
1038
|
+
var subject = text;
|
|
1039
|
+
// BOM is repaired under its OWN policy. The strip table reaches U+FEFF only
|
|
1040
|
+
// as a zero-width character, and `parse` removes only a LEADING one — so with
|
|
1041
|
+
// `bomPolicy: "strip"` and `zeroWidthPolicy: "allow"` nothing removed a
|
|
1042
|
+
// mid-stream BOM, and validate reported `bom-mid-stream` on a document this
|
|
1043
|
+
// function then handed back carrying it.
|
|
1044
|
+
if (gateContract.policyDisposition(opts.bomPolicy) === "sanitize") {
|
|
1045
|
+
subject = codepointClass.stripRanges(subject, [0xFEFF]);
|
|
1046
|
+
}
|
|
1047
|
+
return JSON.stringify(parse(codepointClass.applyCharStripPolicies(subject, opts), opts));
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1016
1050
|
function gate(opts) {
|
|
1017
1051
|
opts = _resolveOpts(opts);
|
|
1018
1052
|
return gateContract.buildContentGate({
|
|
@@ -1027,9 +1061,7 @@ function gate(opts) {
|
|
|
1027
1061
|
// __proto__ / comments / NaN / trailing commas per the active policy. Under a
|
|
1028
1062
|
// reject policy the finding is already refuse-disposition, so this is not
|
|
1029
1063
|
// reached for that class.
|
|
1030
|
-
produceSanitized:
|
|
1031
|
-
return JSON.stringify(parse(codepointClass.applyCharStripPolicies(text, o), o));
|
|
1032
|
-
},
|
|
1064
|
+
produceSanitized: _sanitizeTransform,
|
|
1033
1065
|
});
|
|
1034
1066
|
}
|
|
1035
1067
|
|
|
@@ -1069,6 +1101,17 @@ module.exports = gateContract.defineGuard({
|
|
|
1069
1101
|
intOpts: ["maxBytes", "maxDepth", "maxKeysPerObject", "maxArrayLength",
|
|
1070
1102
|
"maxStringLength", "maxTotalNodes"],
|
|
1071
1103
|
gate: gate,
|
|
1104
|
+
// The same two-pass repair the gate already runs, exposed as the public
|
|
1105
|
+
// `sanitize` every content guard owes: char-strip policies remove the
|
|
1106
|
+
// classes set to a mitigation, then a parse + re-serialize drops
|
|
1107
|
+
// __proto__ / comments / NaN / trailing commas per the active policy.
|
|
1108
|
+
// Refusal is decided by `dispositionFor` rather than by severity, because
|
|
1109
|
+
// eight of this guard's finding kinds never reach `critical` — a severity
|
|
1110
|
+
// filter would hand back a document carrying a finding the operator set to
|
|
1111
|
+
// `reject`. Without a sanitize the profiles declared six strip policies the
|
|
1112
|
+
// guard could not perform, and the gate refused instead of repairing.
|
|
1113
|
+
sanitizeTransform: _sanitizeTransform,
|
|
1114
|
+
dispositionFor: _gateDispositionFor,
|
|
1072
1115
|
extra: {
|
|
1073
1116
|
_gateDispositionForTest: _gateDispositionFor,
|
|
1074
1117
|
parse: parse,
|
package/lib/guard-markdown.js
CHANGED
|
@@ -976,7 +976,12 @@ module.exports = gateContract.defineGuard({
|
|
|
976
976
|
integrationFixtures: INTEGRATION_FIXTURES,
|
|
977
977
|
detect: _detectIssues,
|
|
978
978
|
sanitizeTransform: _sanitizeTransform,
|
|
979
|
-
|
|
979
|
+
dispositionFor: _gateDispositionFor,
|
|
980
|
+
// No sanitizeSeverities — refusal comes from `dispositionFor` and each
|
|
981
|
+
// finding's own policy. This guard's gate already documents why it passes the
|
|
982
|
+
// raw strip transform rather than this function: the severity filter refused
|
|
983
|
+
// a critical bidi control even under `bidiPolicy: "strip"`, turning a
|
|
984
|
+
// policy-selected repair into a refusal. With policy deciding, the two agree.
|
|
980
985
|
intOpts: ["maxBytes", "maxLines", "maxLinks", "maxImages", "maxAutolinks",
|
|
981
986
|
"maxRefDefs", "maxListDepth", "maxBlockquoteDepth"],
|
|
982
987
|
gate: gate,
|
package/lib/guard-xml.js
CHANGED
|
@@ -745,7 +745,16 @@ var _guard = module.exports = gateContract.defineGuard({
|
|
|
745
745
|
integrationFixtures: INTEGRATION_FIXTURES,
|
|
746
746
|
detect: _detectIssues,
|
|
747
747
|
sanitizeTransform: _sanitizeTransform,
|
|
748
|
-
|
|
748
|
+
dispositionFor: _gateDispositionFor,
|
|
749
|
+
// No sanitizeSeverities: refusal is decided by `dispositionFor`, which reads
|
|
750
|
+
// each finding's own policy. The severity filter this replaces refused every
|
|
751
|
+
// critical finding, and a bidi control is critical whatever its policy says —
|
|
752
|
+
// so `bidiPolicy: "strip"` threw here while the gate, which bypasses this
|
|
753
|
+
// function precisely to avoid that, stripped it. Two public entry points gave
|
|
754
|
+
// opposite answers for one setting. The structural shapes still refuse, now
|
|
755
|
+
// because DOCTYPE / ENTITY / external-entity resolve to `refuse` by policy,
|
|
756
|
+
// and the capped shapes (element / depth / numeric-char-ref) keep the
|
|
757
|
+
// conservative severity answer through the unclassified fallback.
|
|
749
758
|
intOpts: ["maxBytes", "maxDepth", "maxElements", "maxAttrsPerElement",
|
|
750
759
|
"maxAttrValueBytes", "maxNumericCharRefs"],
|
|
751
760
|
gate: gate,
|
package/lib/guard-yaml.js
CHANGED
|
@@ -656,14 +656,17 @@ function parse(input, opts) {
|
|
|
656
656
|
});
|
|
657
657
|
}
|
|
658
658
|
|
|
659
|
-
// The gate is the standard serve -> audit-only -> refuse chain
|
|
660
|
-
// kind, reading ctx.bytes)
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
//
|
|
659
|
+
// The gate is the standard serve -> audit-only -> sanitize -> refuse chain
|
|
660
|
+
// (content kind, reading ctx.bytes), assembled by gateContract.defineGuard
|
|
661
|
+
// below with this guard's own disposition map.
|
|
662
|
+
//
|
|
663
|
+
// `sanitize` repairs at the CHARACTER level only. That distinction is what the
|
|
664
|
+
// "YAML cannot be sanitized" reading missed: there is indeed no safe re-emit
|
|
665
|
+
// for a tag-injection or alias-explosion shape, and those still refuse through
|
|
666
|
+
// their own policies — but removing an invisible or control character is a text
|
|
667
|
+
// edit that never re-serializes the document, so a profile asking to strip one
|
|
668
|
+
// can be honoured. Withholding the repair entirely meant six declared strip
|
|
669
|
+
// policies did nothing except refuse.
|
|
667
670
|
|
|
668
671
|
// buildProfile / compliancePosture / loadRulePack are assembled by
|
|
669
672
|
// gateContract.defineGuard below; their wiki sections render from the
|
|
@@ -689,9 +692,49 @@ var INTEGRATION_FIXTURES = Object.freeze({
|
|
|
689
692
|
// buildProfile / compliancePosture / loadRulePack wiring, plus the
|
|
690
693
|
// per-guard inspection surface (validate) and YAML extras
|
|
691
694
|
// (parse / DANGEROUS_TAG_PREFIXES / SAFE_CORE_TAGS) passed through
|
|
692
|
-
// verbatim. The gate is the factory
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
+
// verbatim. The gate is the factory chain for a content guard, dispatching on
|
|
696
|
+
// this guard's own disposition map, with a character-level sanitize.
|
|
697
|
+
|
|
698
|
+
// _gateDispositionFor — bind each finding to the operator's policy for it.
|
|
699
|
+
// Without this the gate fell back to severity, where `critical` and `high` both
|
|
700
|
+
// refuse, so a profile asking to strip a character class refused the document
|
|
701
|
+
// instead and an `audit` setting refused it too.
|
|
702
|
+
function _gateDispositionFor(issue, opts) {
|
|
703
|
+
var shared = gateContract.charThreatDisposition(issue, opts);
|
|
704
|
+
if (shared) return shared;
|
|
705
|
+
switch (issue.kind) {
|
|
706
|
+
case "core-tag":
|
|
707
|
+
case "custom-tag":
|
|
708
|
+
case "dangerous-tag": return gateContract.policyDisposition(opts.tagPolicy);
|
|
709
|
+
case "alias-disabled": return gateContract.policyDisposition(opts.aliasPolicy);
|
|
710
|
+
case "duplicate-key": return gateContract.policyDisposition(opts.duplicateKeyPolicy);
|
|
711
|
+
case "leading-zero-octal": return gateContract.policyDisposition(opts.leadingZeroPolicy);
|
|
712
|
+
case "merge-key": return gateContract.policyDisposition(opts.mergeKeyPolicy);
|
|
713
|
+
case "multi-document": return gateContract.policyDisposition(opts.multiDocPolicy);
|
|
714
|
+
case "norway-implicit-bool": return gateContract.policyDisposition(opts.norwayPolicy);
|
|
715
|
+
// An alias explosion, a blown anchor cap, an oversized document and a
|
|
716
|
+
// document that does not parse carry no policy and admit no repair. They
|
|
717
|
+
// are left unclassified on purpose: the caller then applies the
|
|
718
|
+
// conservative severity answer, which refuses them.
|
|
719
|
+
default: return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// _sanitizeTransform — repair at the CHARACTER level only. Stripping an
|
|
724
|
+
// invisible or control character is a text edit that needs no re-emit, which is
|
|
725
|
+
// why it is safe here when re-serializing a YAML document is not: the tag,
|
|
726
|
+
// alias and multi-document shapes have no faithful round-trip, and they refuse
|
|
727
|
+
// upstream through their own policies rather than being rewritten.
|
|
728
|
+
function _sanitizeTransform(input, opts) {
|
|
729
|
+
// scrubCharThreats, not a bare applyCharStripPolicies: the strip table
|
|
730
|
+
// removes only the classes set to `strip`, so calling it alone would hand a
|
|
731
|
+
// `reject` class back unrepaired and unreported. The factory's generated
|
|
732
|
+
// sanitize already bounds and asserts before this runs, but a transform whose
|
|
733
|
+
// safety depends on its caller is one refactor away from being wrong — this
|
|
734
|
+
// owns the whole ordering itself and is correct however it is reached.
|
|
735
|
+
return codepointClass.scrubCharThreats(input, opts, _err, "yaml");
|
|
736
|
+
}
|
|
737
|
+
|
|
695
738
|
module.exports = gateContract.defineGuard({
|
|
696
739
|
name: "yaml",
|
|
697
740
|
kind: "content",
|
|
@@ -705,6 +748,8 @@ module.exports = gateContract.defineGuard({
|
|
|
705
748
|
detect: _detectIssues,
|
|
706
749
|
intOpts: ["maxBytes", "maxDepth", "maxAnchors", "maxAliasDepth",
|
|
707
750
|
"maxDocuments", "maxNodes", "maxScalarLength"],
|
|
751
|
+
dispositionFor: _gateDispositionFor,
|
|
752
|
+
sanitizeTransform: _sanitizeTransform,
|
|
708
753
|
extra: {
|
|
709
754
|
parse: parse,
|
|
710
755
|
DANGEROUS_TAG_PREFIXES: DANGEROUS_TAG_PREFIXES,
|
|
@@ -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/standard-webhooks.js
CHANGED
|
@@ -158,17 +158,19 @@ function verify(opts) {
|
|
|
158
158
|
var toSign = id + "." + ts + "." + bodyBuf.toString("utf8");
|
|
159
159
|
var expected = nodeCrypto.createHmac("sha256", opts.secret).update(toSign).digest("base64");
|
|
160
160
|
// Multi-version: signature header is `v1,<sig> v2,<sig>` etc.
|
|
161
|
+
// Every v1 signature in the header is compared, with no break: stopping at
|
|
162
|
+
// the first match let the response time report the POSITION of the matching
|
|
163
|
+
// signature within a multi-version header, which is a property of the
|
|
164
|
+
// sender's key rotation and not something a verifier should hand back.
|
|
161
165
|
var parts = sigHeader.split(" ");
|
|
162
|
-
var
|
|
166
|
+
var offered = [];
|
|
163
167
|
for (var p = 0; p < parts.length; p += 1) {
|
|
164
168
|
var pair = parts[p].split(",");
|
|
165
169
|
if (pair.length !== 2) continue;
|
|
166
170
|
if (pair[0] !== "v1") continue;
|
|
167
|
-
|
|
168
|
-
any = true;
|
|
169
|
-
break;
|
|
170
|
-
}
|
|
171
|
+
offered.push(Buffer.from(pair[1], "utf8"));
|
|
171
172
|
}
|
|
173
|
+
var any = bCrypto.timingSafeEqualAny(Buffer.from(expected, "utf8"), offered);
|
|
172
174
|
if (!any) {
|
|
173
175
|
throw new StandardWebhooksError("standard-webhooks/bad-signature",
|
|
174
176
|
"verify: no v1 signature matched");
|
package/lib/totp.js
CHANGED
|
@@ -282,6 +282,12 @@ function verify(secret, code, opts) {
|
|
|
282
282
|
var userCode = String(code).replace(/[\s.\-_]/g, "").padStart(resolved.digits, "0");
|
|
283
283
|
var userBuf = Buffer.from(userCode);
|
|
284
284
|
|
|
285
|
+
// Every step in the drift window is compared, even after one matches. Returning
|
|
286
|
+
// from inside the loop would make the response time report WHICH step matched,
|
|
287
|
+
// and that is the clock offset between the authenticator and the server — a
|
|
288
|
+
// value derived from the shared secret's timeline that a caller submitting
|
|
289
|
+
// codes can otherwise only guess at.
|
|
290
|
+
var matchedStep = null;
|
|
285
291
|
for (var d = -resolved.driftSteps; d <= resolved.driftSteps; d++) {
|
|
286
292
|
var step = currentStep + d;
|
|
287
293
|
if (lastUsedStep !== null && step <= lastUsedStep) continue; // reject replays at-or-below the last accepted step
|
|
@@ -293,11 +299,14 @@ function verify(secret, code, opts) {
|
|
|
293
299
|
try { expected = _hotp(secret, step, resolved); }
|
|
294
300
|
catch (_e) { return false; }
|
|
295
301
|
var expectedBuf = Buffer.from(expected);
|
|
302
|
+
// The comparison is not guarded by `matchedStep === null` — that would
|
|
303
|
+
// short-circuit the call away once something matched, which is the early
|
|
304
|
+
// exit wearing a different syntax. The earliest match still wins.
|
|
296
305
|
if (timingSafeEqual(expectedBuf, userBuf)) {
|
|
297
|
-
|
|
306
|
+
if (matchedStep === null) matchedStep = step;
|
|
298
307
|
}
|
|
299
308
|
}
|
|
300
|
-
return false;
|
|
309
|
+
return matchedStep === null ? false : matchedStep;
|
|
301
310
|
}
|
|
302
311
|
|
|
303
312
|
function uri(secret, account, opts) {
|
package/lib/vendor/MANIFEST.json
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"server": "sha256:f3325f480cb8eb814fcb0baaa19336cbbf2b993f48624c6aa9600ffd69d0be5e",
|
|
22
22
|
"browser": "sha256:0ffd91540bcb586a29b56e52ee1c29df69097b50776beb4036a07558f7a4e12e"
|
|
23
23
|
},
|
|
24
|
-
"refreshedAt": "2026-08-
|
|
24
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
25
25
|
},
|
|
26
26
|
"@noble/hashes": {
|
|
27
27
|
"version": "2.3.0",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"hashes": {
|
|
49
49
|
"browser": "sha256:dfe4b7ae3c9880e388c8da4b68f44742b229b53afacd1e674179527e33da62b0"
|
|
50
50
|
},
|
|
51
|
-
"refreshedAt": "2026-08-
|
|
51
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
52
52
|
},
|
|
53
53
|
"@noble/curves": {
|
|
54
54
|
"version": "2.3.0",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"hashes": {
|
|
71
71
|
"server": "sha256:b5fe88d1ea780d0581dee6145d666f89d46fc9531b5db35db2e5b16627840890"
|
|
72
72
|
},
|
|
73
|
-
"refreshedAt": "2026-08-
|
|
73
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z",
|
|
74
74
|
"components": {
|
|
75
75
|
"@noble/hashes": {
|
|
76
76
|
"url": "https://github.com/paulmillr/noble-hashes",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"server": "sha256:fab7ebe5737793862c473444f4ee5912f79dd1edec86683acbb4eecbca0f5892",
|
|
115
115
|
"browser": "sha256:cae1d5bbdc7184b202b6ca68df6e1db7b0d0f668c77809ded189ca7f271accc9"
|
|
116
116
|
},
|
|
117
|
-
"refreshedAt": "2026-08-
|
|
117
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z",
|
|
118
118
|
"components": {
|
|
119
119
|
"@noble/hashes": {
|
|
120
120
|
"url": "https://github.com/paulmillr/noble-hashes",
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
},
|
|
149
149
|
"runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
|
|
150
150
|
"integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
|
|
151
|
-
"refreshedAt": "2026-08-
|
|
151
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
152
152
|
},
|
|
153
153
|
"bimi-trust-anchors": {
|
|
154
154
|
"version": "operator-managed",
|
|
@@ -173,7 +173,7 @@
|
|
|
173
173
|
},
|
|
174
174
|
"runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
|
|
175
175
|
"integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
|
|
176
|
-
"refreshedAt": "2026-08-
|
|
176
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
177
177
|
},
|
|
178
178
|
"publicsuffix-list": {
|
|
179
179
|
"version": "master",
|
|
@@ -193,10 +193,10 @@
|
|
|
193
193
|
},
|
|
194
194
|
"runtime_artifact": "lib/vendor/public-suffix-list.data.js",
|
|
195
195
|
"integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
|
|
196
|
-
"refreshedAt": "2026-08-
|
|
196
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
197
197
|
},
|
|
198
198
|
"@blamejs/pki": {
|
|
199
|
-
"version": "0.5.
|
|
199
|
+
"version": "0.5.10",
|
|
200
200
|
"license": "Apache-2.0",
|
|
201
201
|
"author": "blamejs",
|
|
202
202
|
"source": "https://github.com/blamejs/pki",
|
|
@@ -217,11 +217,11 @@
|
|
|
217
217
|
},
|
|
218
218
|
"bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
|
|
219
219
|
"bundledAt": "2026-08-18T00:00:00Z",
|
|
220
|
-
"cpe": "cpe:2.3:a:blamejs:pki:0.5.
|
|
220
|
+
"cpe": "cpe:2.3:a:blamejs:pki:0.5.10:*:*:*:*:node.js:*:*",
|
|
221
221
|
"hashes": {
|
|
222
|
-
"server": "sha256:
|
|
222
|
+
"server": "sha256:c7c7b82d0c5cd604e726c52332de66ff06ffb7877d4a44720d8bbd746c5d6023"
|
|
223
223
|
},
|
|
224
|
-
"refreshedAt": "2026-08-
|
|
224
|
+
"refreshedAt": "2026-08-18T14:13:33.454Z"
|
|
225
225
|
}
|
|
226
226
|
}
|
|
227
227
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @blamejs/pki v0.5.
|
|
1
|
+
// @blamejs/pki v0.5.10 — vendored (Apache-2.0). Zero-dep pure CJS.
|
|
2
2
|
// https://github.com/blamejs/pki Exports: x509, crl, pkcs12, key, webcrypto, schema, csr, cms, ...
|
|
3
3
|
// Backs lib/mtls-engine-default.js (PQC-capable CA + PKCS#12 engine).
|
|
4
4
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
@@ -141,7 +141,7 @@ var require_package = __commonJS({
|
|
|
141
141
|
"node_modules/@blamejs/pki/package.json"(exports2, module2) {
|
|
142
142
|
module2.exports = {
|
|
143
143
|
name: "@blamejs/pki",
|
|
144
|
-
version: "0.5.
|
|
144
|
+
version: "0.5.10",
|
|
145
145
|
description: "Pure-JavaScript PKI toolkit that owns its stack \u2014 X.509, ASN.1/DER, CMS, PQC-first.",
|
|
146
146
|
license: "Apache-2.0",
|
|
147
147
|
author: "blamejs contributors",
|
|
@@ -209,9 +209,11 @@ var require_package = __commonJS({
|
|
|
209
209
|
test: "node test/smoke.js",
|
|
210
210
|
lint: "eslint --max-warnings 0 .",
|
|
211
211
|
fuzz: "npm ci --prefix fuzz && npx --prefix fuzz jazzer fuzz/asn1-der.fuzz.js -- -max_total_time=60",
|
|
212
|
-
gates: "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js",
|
|
212
|
+
gates: "node test/layer-0-primitives/codebase-patterns.test.js && node scripts/validate-source-comment-blocks.js && node scripts/check-api-snapshot.js && node scripts/check-spelling-consistency.js",
|
|
213
|
+
"check:spelling": "node scripts/check-spelling-consistency.js",
|
|
213
214
|
coverage: "c8 --include=lib/** --include=index.js --reporter=text-summary --reporter=lcov node test/smoke.js",
|
|
214
215
|
"check:prose": "node scripts/check-operator-prose.js",
|
|
216
|
+
"check:prose:all": `node -e "process.env.PKI_PROSE_CHECKS='all';var r=require('child_process').spawnSync(process.execPath,['scripts/check-operator-prose.js'],{stdio:'inherit'});if(r.error)throw r.error;process.exit(r.signal?1:r.status)"`,
|
|
215
217
|
"check:swallows": "node scripts/check-swallow-coverage.js",
|
|
216
218
|
"coverage:gated": "npm run coverage && npm run check:swallows",
|
|
217
219
|
"coverage:unified": "node scripts/coverage-unified.js",
|
|
@@ -329,7 +331,7 @@ var require_constants = __commonJS({
|
|
|
329
331
|
// is copied/tokenized so a hostile server cannot drive unbounded work parsing it
|
|
330
332
|
// (CWE-770). 8 KiB clears any realistic multi-scheme Digest challenge.
|
|
331
333
|
HTTP_AUTH_HEADER_MAX_BYTES: BYTES.kib(8),
|
|
332
|
-
// Deterministic-CBOR codec ceilings (RFC 8949), the DER
|
|
334
|
+
// Deterministic-CBOR codec ceilings (RFC 8949), the DER neighbors' siblings:
|
|
333
335
|
// a whole-document cap refused before the walk, a nesting cap, and a per-value
|
|
334
336
|
// bignum ceiling the document cap can't provide. Unlike DER_MAX_INTEGER_BYTES,
|
|
335
337
|
// the bignum cap carries NO +1 sign octet -- a CBOR tag-2/3 bignum body is pure
|
|
@@ -24533,7 +24535,7 @@ var require_path_validate = __commonJS({
|
|
|
24533
24535
|
// The set of extension OIDs whose CRITICAL semantics this validator processes (RFC 5280 sec. 6.1).
|
|
24534
24536
|
// Exposed so a linter can distinguish "processed" from "merely decoded" and stay consistent with
|
|
24535
24537
|
// the path-validation verdict on a critical extension -- a decoder in certExtensionDecoders is NOT
|
|
24536
|
-
// by itself proof the criticality is
|
|
24538
|
+
// by itself proof the criticality is honored. Both sets are frozen so a caller cannot mutate them.
|
|
24537
24539
|
PROCESSED_EXTENSIONS,
|
|
24538
24540
|
// Extensions that ARE processed for an intermediate CA but are unprocessed on the target/leaf, so a
|
|
24539
24541
|
// critical instance on the target fails closed (RFC 5280 sec. 6.1.5(f)) -- policyMappings is
|
|
@@ -25581,7 +25583,7 @@ var require_cmc_build = __commonJS({
|
|
|
25581
25583
|
// The exchange binding (RFC 5272 sec. 6.6 / 6.4). First-class here because
|
|
25582
25584
|
// pki.cmc.verify names these same three when checking the response: a builder
|
|
25583
25585
|
// that made the caller hand-encode them while the verifier took them by name is
|
|
25584
|
-
// the asymmetry that lets a request ship with no replay
|
|
25586
|
+
// the asymmetry that lets a request ship with no replay defense.
|
|
25585
25587
|
transactionId: 1,
|
|
25586
25588
|
senderNonce: 1,
|
|
25587
25589
|
dataReturn: 1
|
|
@@ -26304,7 +26306,7 @@ var require_cmc_verify = __commonJS({
|
|
|
26304
26306
|
if (!bound.boundToRequest && sent.allowUnbound !== true) {
|
|
26305
26307
|
throw E(
|
|
26306
26308
|
"cmc/unbound-response",
|
|
26307
|
-
"nothing ties this response to a request. Pass what the request retained (`transactionId`, `senderNonce`, whose echo is the replay
|
|
26309
|
+
"nothing ties this response to a request. Pass what the request retained (`transactionId`, `senderNonce`, whose echo is the replay defense of RFC 5272 sec. 6.6, or `dataReturn`) so the echo can be checked, or `allowUnbound: true` to interpret a response that could be a replay of any earlier exchange with this CA"
|
|
26308
26310
|
);
|
|
26309
26311
|
}
|
|
26310
26312
|
return bound;
|
|
@@ -36973,7 +36975,7 @@ var require_webauthn_mds = __commonJS({
|
|
|
36973
36975
|
tooLarge: "webauthn/too-large",
|
|
36974
36976
|
badJson: "webauthn/bad-metadata-blob",
|
|
36975
36977
|
// Every code the guard can raise is named. An omitted one falls back to the framework default
|
|
36976
|
-
// and the module's own
|
|
36978
|
+
// and the module's own defenses -- duplicate-member smuggling, the depth cap -- surface under
|
|
36977
36979
|
// a generic code no webauthn/* consumer can switch on.
|
|
36978
36980
|
tooDeep: "webauthn/bad-metadata-blob",
|
|
36979
36981
|
duplicateMember: "webauthn/bad-metadata-blob",
|
package/lib/webhook-hmac.js
CHANGED
|
@@ -197,17 +197,19 @@ function verify(opts) {
|
|
|
197
197
|
var signed = Buffer.concat([Buffer.from(tsRaw + ".", "utf8"), bodyBuf]);
|
|
198
198
|
var expected = bCrypto.hmac(secretBuf, signed, nodeAlg);
|
|
199
199
|
var expectedBuf = Buffer.from(expected, "utf8");
|
|
200
|
-
|
|
200
|
+
// Every offered signature is compared. The `break` this replaced ended the
|
|
201
|
+
// loop at the first match, so a header whose first signature matched
|
|
202
|
+
// answered sooner than one whose last did — the position of the sender's
|
|
203
|
+
// current key inside its rotation, reported by timing.
|
|
204
|
+
//
|
|
205
|
+
// timingSafeEqualAny applies the same length pre-check per candidate; a
|
|
206
|
+
// wrong-length candidate cannot be the digest (the hex length is fixed by
|
|
207
|
+
// the algorithm, and is not secret), so it leaks nothing.
|
|
208
|
+
var offeredBufs = [];
|
|
201
209
|
for (var s = 0; s < sigs.length; s += 1) {
|
|
202
|
-
|
|
203
|
-
// cannot be the digest (the hex length is fixed by the algorithm, and is
|
|
204
|
-
// not secret), so the length pre-check leaks nothing.
|
|
205
|
-
if (sigs[s].length === expected.length &&
|
|
206
|
-
bCrypto.timingSafeEqual(expectedBuf, Buffer.from(sigs[s], "utf8"))) {
|
|
207
|
-
matched = true;
|
|
208
|
-
break;
|
|
209
|
-
}
|
|
210
|
+
offeredBufs.push(Buffer.from(sigs[s], "utf8"));
|
|
210
211
|
}
|
|
212
|
+
var matched = bCrypto.timingSafeEqualAny(expectedBuf, offeredBufs);
|
|
211
213
|
if (!matched) {
|
|
212
214
|
throw new WebhookHmacError("webhook-hmac/bad-signature",
|
|
213
215
|
"verify: no '" + sigField + "' signature matched");
|
package/package.json
CHANGED
package/sbom.cdx.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:f384e0aa-ad27-4c4f-a0b0-eceb0cac068e",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-18T23:20:05.652Z",
|
|
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.18.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.36",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.36",
|
|
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.18.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.18.36",
|
|
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.18.
|
|
57
|
+
"ref": "@blamejs/core@0.18.36",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|