@blamejs/core 0.18.50 → 0.18.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,60 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.51 (2026-08-22) — **Five parsers could be made to spend their time on the thing they were parsing.** Three prompt-injection detectors, the BIMI logo parser and the raw-write data-residency gate each ran a pattern whose cost grew with the square or the cube of its input while an ordinary input of the same length cost a millisecond. A 64 KiB prompt took up to 4.5 seconds to classify; a 32 KiB logo took 409 milliseconds to parse; a 4 KB raw UPDATE took 7 seconds to get a verdict from a gate that accepts statements twenty-four times longer. All five now cost what ordinary input of that length costs, and each was checked against a corpus to confirm it still decides what it decided before. Separately, a verified VMC logo was reported as absent when its SVG began with an XML declaration and a DOCTYPE. **Changed:** *`b.guardFilename` no longer repairs a null byte, and five options that accept one value now say so* — 0.18.47 accepted `nullBytePolicy: "strip"` on `b.guardFilename` and removed the null byte from the name. 0.18.48 stopped accepting it. That removal was correct and was not written down, which is the part being fixed here.
12
+
13
+ It was correct because a null byte in a filename is a truncation attack, not a typo: the name the check reads and the name the operating system acts on differ at the byte, so repairing it produces a name nobody validated. The guard refuses with `filename.null-byte` and the message has said `null-byte truncation is never sanitizable` throughout. There is no replacement policy value, because there is no safe repair. A caller that was asking for the strip should refuse the input instead, or rename before validating.
14
+
15
+ Separately, five options across three guards accept exactly one value: `nullBytePolicy` and `traversalPolicy` on `b.guardFilename`, `algNonePolicy` and `kidTraversalPolicy` on `b.guardJwt`, and `svgzPolicy` on `b.guardSvg`. Each is a check where any disposition other than refusing is a hole, so the single value is a deliberate lock. They were refusing a wrong value with `must be one of reject`, which reads as a vocabulary that lost its other members and sends the reader looking for them. The refusal now says the option is fixed and not configurable. Passing the legal value still resolves, so nothing that spells it out breaks; what is enforced is unchanged. · *Vendored `@blamejs/pki` refreshed to 0.5.28* — The bundle behind `b.mtlsCa` and `b.auth.passkey`, moved on from 0.5.25 across three releases.
16
+
17
+ The one that matters is in 0.5.28: a `CryptoKey`'s `algorithm` is now immutable once the key exists. The engine reads `key.algorithm.hash` at sign time, and while that object could still be replaced — including from a microtask during the signing await — the hash checked against a JWS `alg` header could be rewritten between the check and the signature, producing a JWS whose signature does not match the algorithm it advertises.
18
+
19
+ 0.5.27 makes an unrecognized option to `pki.trust.anchor` or the `pki.acme.client` constructor a named error rather than a silent default, so a misspelled security-relevant setting can no longer read as absent. 0.5.26 adds the RFC 9483 revocation and support-message exchanges to the CMP session, with each response bound to the request that asked for it. 0.5.28 also stops the toolkit printing Node experimental-feature warnings when it loads.
20
+
21
+ Nothing in this framework's surface changes; `lib/vendor/MANIFEST.json` carries the new version and hash. **Fixed:** *A VMC logo was not extracted when the SVG began with a declaration and a DOCTYPE* — `b.mail.bimi.fetchAndVerifyMark` documents that `mark` carries the SVG whenever the certificate's RFC 3709 logotype extension is present. It decided whether an extension payload was an SVG by looking for `<svg` in the first 64 characters, and a conformant SVG may put an XML declaration, a DOCTYPE and a comment ahead of its root element. In a document with the usual SVG 1.1 DOCTYPE the root sits 154 characters in, so the logo was signed into the certificate, served, verified, and then reported as absent: `mark.svg` came back null.
22
+
23
+ The scanner now steps over what XML actually permits ahead of a root element — whitespace and a byte-order mark, the declaration, processing instructions, comments, and a DOCTYPE including an internal subset — and then asks whether `<svg` begins there. A prologue has no length limit, so no fixed window is the right answer for this; whichever number were chosen, a legal document could put its root past it.
24
+
25
+ The check is also stricter than the one it replaces in two ways. A document that merely mentions `<svg` inside a comment is no longer taken for a logo, and a root whose name merely begins with those letters — `<svgfoo`, or `<svg.foo`, since `.` is a legal name character — is no longer taken for `<svg`. A payload with no root element at all still yields null however long it is.
26
+
27
+ The alternative pattern that stood beside the original search looked like it was meant for exactly this case, but it required `<svg` inside the same 64 characters the search already covered, so it could never have supplied an answer the search had not. It is gone. **Security:** *`b.ai.input.classify` could be made to spend seconds on a single 64 KiB prompt* — Three of the injection detectors ran a pattern whose leading run could begin at every offset inside a stretch of characters it accepts. At each offset the engine consumed the whole run before the part that had to follow it failed to arrive, so one call cost the length of the input squared.
28
+
29
+ `classify` accepts 64 KiB by default. Measured at that size, against 1 millisecond for ordinary text of exactly the same length:
30
+
31
+ - The base64-marker detector looks for a long base64-looking run followed by a word like `means` or `decodes to`. `/` is inside the run's character class, so text such as `a/a/a/…` feeds it: **4,536ms**.
32
+ - The role-tag detector matched `<`, optional whitespace, an optional `/`, optional whitespace. Two whitespace runs either side of an optional character means every way of dividing a run of spaces between them is retried: **1,642ms** on `<` followed by spaces.
33
+ - The event-handler detector matched `on` followed by word characters. `o` and `n` are themselves word characters, so `onononon…` begins a match at every other offset and each one walks to the end of the input: **870ms**.
34
+
35
+ All three cost the shape rather than the size, and an operator who raises `maxBytes` raises the cost with the square of it. This is the primitive whose entire premise is that its input is hostile, which is what makes it worth naming plainly: the scan meant to catch an attack was one.
36
+
37
+ Each run may now only begin where the preceding character is not one the run itself accepts, and the optional `/` in the role tag carries the whitespace that follows it so there is only one way to divide any input. All three now classify a hostile-shaped 64 KiB prompt in 1.3 to 1.5 milliseconds, which is what benign input of that length costs.
38
+
39
+ The role-tag detector accepts exactly the inputs it accepted before, confirmed across 5,214 combinations of spacing, slash placement and tag name. The base64 detector was checked against fifteen cases covering padding, the three trailing keywords, and blobs at the start of a string, after a newline, after a letter, after a slash and after an `=`.
40
+
41
+ One deliberate narrowing comes with the event-handler detector. It no longer matches an `on` that begins in the middle of a word, so `fooonclick="fetch(1)"` is no longer reported. An event-handler attribute begins a word, so that was a false report rather than coverage; `onclick=` still matches at the start of the input and after any character that is not a word character, which covers every place an attribute can actually appear, and every `<script` form is untouched. · *Compiling a JSON Schema could be made to hang before any instance was validated* — 0.18.50 added a pre-screen that walks every subschema position so a `pattern` is checked before it can run. The walk was bounded only by depth, and a depth bound limits how far one path runs while saying nothing about how many paths there are. A schema graph that reaches one object through a branching position was therefore walked once per path rather than once per object.
42
+
43
+ Building a schema out of reused JavaScript objects produces exactly that shape and needs no `$ref`, so nothing about the source reads as unusual: `anyOf: [shared, shared]` at each of twenty levels is twenty-one objects and a million paths. Measured, twenty-one objects took 2.6 seconds and each added level doubled it; the depth cap allowed 256. A schema that referred to itself never finished at all.
44
+
45
+ The same shape was present, and older, in the walk that indexes subschemas for `$ref` resolution, where it was the larger cost. That one cannot skip a repeated object, because it indexes each position by pointer and a shared object legitimately has one entry per pointer that reaches it. So the screen now visits each object once, and the index carries a ceiling on how many positions it will register, refusing beyond it with `json-schema/schema-too-large`. The ceiling is far above any authored schema: a thousand-endpoint OpenAPI document is tens of thousands of positions.
46
+
47
+ This is worth naming plainly. `SECURITY.md` promises that a content screen costs the length of its input and never a function of its shape, and names this call site as one of the places that promise is kept. The screen added to keep it was itself a function of shape.
48
+
49
+ Reported privately from downstream vendoring during a routine dependency refresh. · *A raw write to a data-residency table could hang the process in its own residency gate* — `b.db.runSql`, `execRaw` and `b.db.prepare(sql).run()` bypass the structured builder, so a residency gate parses the statement itself and refuses anything it cannot read. Both of its body patterns ended `\s*;?\s*$`, and the UPDATE one put a lazy run in front of that. The lazy run and both whitespace runs can all absorb the same trailing spaces, with an optional semicolon between two of them, so every division of a trailing whitespace run is a distinct path and the lazy run retries all of them at every length it takes.
50
+
51
+ The growth is cubic: 117 milliseconds on a 1 KB statement, 6.8 seconds on 4 KB, roughly eight times the cost for each doubling. Driven through `b.db.runSql` against a residency table, a 4 KB `UPDATE` carrying trailing whitespace took 7,071 milliseconds before the gate reached a verdict.
52
+
53
+ The gate caps its parse input at 100,000 characters and the comment above that cap says it exists to bound these scans. It does the opposite: it is the ceiling that lets a single statement reach a cost measured in hours. A statement long enough to matter is not exotic either, since the body is whatever SQL the application wrote.
54
+
55
+ The trailing semicolon and the whitespace around it are now walked off the end before either pattern runs, which leaves one greedy run with nothing after it to divide. The same parse costs 0.04 milliseconds at the full 100,000-character cap. Captures are unchanged across a corpus of qualified names, quoted identifiers, embedded semicolons inside string literals, repeated terminators and mixed casing.
56
+
57
+ One shape now parses differently: `UPDATE t SET` followed only by whitespace, which has no SET body at all. It previously parsed with a whitespace body and is now refused as unparseable, which is the direction this gate already documents for anything it cannot read.
58
+
59
+ This affects deployments that have declared per-row or per-column residency; a table without a residency declaration never enters the gate. · *A BIMI logo could cost 409 milliseconds to parse* — The Tiny-PS attribute parser reads names matching `[A-Za-z_:][A-Za-z0-9:._-]*`, and the same restart applied: inside a long run of name characters the name could begin at every offset, consuming the run before `\s*=\s*` failed.
60
+
61
+ A logo is fetched from a URL published in the sending domain's own DNS record, so those bytes are chosen by whoever controls the domain. `validateTinyPsSvg` refuses anything over 32 KiB before it parses, and at that size a hostile-shaped document took 409ms against 0.6ms for a well-formed SVG of the same size.
62
+
63
+ An attribute name may now only begin after a character that is not a name character, which is what XML requires in any case. Checked against a fifteen-case corpus covering namespaced names, dotted and dashed names, bare-token values, single quotes, empty values, extra whitespace around `=`, and attributes with no space between them: the same attributes are read, with the same values.
64
+
11
65
  - v0.18.50 (2026-08-22) — **Five places let someone else choose how much CPU your server spends on one match.** A Sieve `:matches` wildcard, a JSONPath `match()` filter and a JSON Schema `pattern` each took a pattern from outside the framework and ran it on a backtracking engine. A Sieve rule with five wildcards spent 452ms on a 60-character Subject; a JSONPath filter spent 7 seconds on a 29-character value; a schema pattern spent 1.5 seconds on 28 characters and doubled with every two more. Two more were the same cost from the other direction, a fixed pattern against attacker-shaped input: a DKIM tag scan and a scanner-reply trim, both quadratic. All five now refuse the shape or match without backtracking. **Security:** *`b.mail.sieve` matches `:matches` wildcards without a regular expression* — RFC 5228 `:matches` uses `*` for any sequence and `?` for one character. Translating that into a regular expression turns each `*` into `.*`, and a backtracking engine facing several of them tries every way of dividing the subject between them. The cost is polynomial in the subject length with degree equal to the number of wildcards.
12
66
 
13
67
  Measured on the shipped path, `b.mail.sieve.runScript` with `if header :matches "Subject" ...` and a subject that never supplies the pattern's trailing literal: three wildcards grew by a factor of 7.7 for each doubling of the subject, and ten wildcards — which a script author can type without meaning anything by it — did not finish on a 64-character Subject.
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.25
71
+ Version: 0.5.28
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
@@ -323,7 +323,7 @@ All runtime dependencies are committed to the repo — no transitive npm install
323
323
  | [`@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 |
324
324
  | [`@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` |
325
325
  | [`@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 |
326
- | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.25 | [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
+ | [`@blamejs/pki`](https://github.com/blamejs/pki) | 0.5.28 | [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` |
327
327
  | [`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 |
328
328
  | [`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) |
329
329
 
package/lib/ai-input.js CHANGED
@@ -44,22 +44,44 @@ var PATTERNS = [
44
44
  /\byou\s+(?:are|will\s+be|must\s+be)\s+(?:now|from\s+now\s+on)?\s*(?:a|an)\s+\w{2,40}/i },
45
45
  { id: "jailbreak-persona", severity: 3, re:
46
46
  /\b(?:DAN|do\s+anything\s+now|developer\s+mode|sudo\s+mode|jailbroken|unfiltered|uncensored|unrestricted)\b/i },
47
+ // The `/` carries the whitespace that follows it. Written as `\s*\/?\s*` the
48
+ // two runs sit either side of an OPTIONAL character, so on `<` and a long run
49
+ // of spaces the engine retries every way of dividing that run between them:
50
+ // 1,642ms on a 64 KiB input against 0.02ms for benign text of the same
51
+ // length. Moving the `/` and its trailing run into one optional group leaves
52
+ // a single way to divide any input and accepts exactly the same tags.
47
53
  { id: "role-reset-marker", severity: 3, re:
48
- /<\s*\/?\s*(?:system|user|assistant|sys|im_(?:start|end)|\|im_(?:start|end)\|)\s*>/i },
54
+ /<\s*(?:\/\s*)?(?:system|user|assistant|sys|im_(?:start|end)|\|im_(?:start|end)\|)\s*>/i },
49
55
  { id: "openai-system-tag", severity: 3, re:
50
56
  /\b(?:<\|im_start\|>|<\|im_end\|>|\[INST\]|\[\/INST\]|<\|user\|>|<\|assistant\|>|<\|system\|>)\b/ },
51
57
  { id: "tool-call-injection", severity: 3, re:
52
58
  /\b(?:tool|function|action)\s*[:=]\s*["']?(?:exec|eval|read_file|exfil|leak|extract)\b/i },
53
59
  { id: "exfil-callback", severity: 3, re:
54
60
  /\b(?:send|post|fetch|exfil|leak|paste|forward)\b[\s\S]{0,40}(?:secret|key|token|password|cred|env|\.ssh|private)/i },
61
+ // The lookbehind is what keeps this linear, and it is also what the detector
62
+ // meant: a base64 TOKEN, not any 40-character window inside a longer run.
63
+ // Without it the run can start at every offset, and at each one the engine
64
+ // consumes the whole run before `\s+` fails — O(n) starts times O(n) work.
65
+ // `/` is inside the class, so `a/` repeated feeds it: 4,536ms on a 64 KiB
66
+ // prompt, against 1ms for benign input of the same length. The lookbehind
67
+ // fails in constant time at every offset except the one after a delimiter.
68
+ // The exclusion is exactly the run's own character class and nothing more.
69
+ // Adding `=` to it also looked reasonable and silently dropped a real shape,
70
+ // `token=<blob> means ...`, where the blob begins right after an `=`.
55
71
  { id: "base64-marker-around-instructions", severity: 2, re:
56
- /(?:[A-Za-z0-9+/]{40,}={0,2})\s+(?:means|decodes?\s+to|=)/i }, // regex repetition floor, not bytes
72
+ /(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{40,}={0,2}\s+(?:means|decodes?\s+to|=)/i }, // regex repetition floor, not bytes
57
73
  { id: "rot13-shape", severity: 2, re:
58
74
  /\b(?:rot13|rotcipher|cipher|caesar)\s*[:=]\s*[a-zA-Z]{20,}/i },
59
75
  { id: "markdown-injection", severity: 2, re:
60
76
  /!\[[^\]]{0,40}\]\((?:javascript:|data:|file:)/i },
77
+ // `\w` covers `o` and `n`, so without the boundary an input of `on` repeated
78
+ // starts a match at every other offset and each one walks `\w+` to the end of
79
+ // the subject before the `=` fails: 870ms on 64 KiB against 0.03ms for benign
80
+ // text of the same length. The boundary is also what the detector meant — an
81
+ // event-handler attribute begins a word, so `foonclick="fetch()"` was a match
82
+ // it should never have had.
61
83
  { id: "html-script-shape", severity: 2, re:
62
- /<script[\s>]|on\w+\s*=\s*["'][^"']*\b(?:fetch|xhr|eval|location)\b/i },
84
+ /<script[\s>]|\bon\w+\s*=\s*["'][^"']*\b(?:fetch|xhr|eval|location)\b/i },
63
85
  { id: "stop-helping", severity: 2, re:
64
86
  /\b(?:stop|cease|quit)\s+(?:helping|assisting|following)\b/i },
65
87
  { id: "now-instead", severity: 2, re:
package/lib/db-query.js CHANGED
@@ -44,6 +44,7 @@
44
44
  */
45
45
  var { Readable } = require("node:stream");
46
46
  var C = require("./constants");
47
+ var codepointClass = require("./codepoint-class");
47
48
  var cryptoField = require("./crypto-field");
48
49
  var { generateToken } = require("./crypto");
49
50
  var safeJson = require("./safe-json");
@@ -1397,8 +1398,34 @@ function _validateField(field) {
1397
1398
  // and run it through the SAME gate; a write to a residency table the framework
1398
1399
  // cannot parse fails CLOSED (refused) - a raw write never skips the check.
1399
1400
  var _RAW_WRITE_KEYWORD_RE = /^\s*(?:INSERT|REPLACE|UPDATE)\b/i;
1400
- var _RAW_INSERT_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)\s*;?\s*$/i;
1401
- var _RAW_UPDATE_RE = /^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\s+([\s\S]+?)\s*;?\s*$/i;
1401
+ // Both bodies used to end `\s*;?\s*$`, which put two whitespace runs either
1402
+ // side of an optional semicolon and, for UPDATE, a lazy body run in front of
1403
+ // them. All three could absorb the same trailing spaces, so every division of
1404
+ // that run was a distinct path and the lazy run retried all of them at every
1405
+ // length it took: cubic, 117ms on a 1 KB statement and 6.8 seconds on 4 KB.
1406
+ // The 100,000-character ceiling below is what the parse is allowed, so it set
1407
+ // the cost rather than bounding it.
1408
+ //
1409
+ // The terminator is now walked off the end by _stripStatementTail before either
1410
+ // pattern runs, which leaves one greedy run with nothing after it to divide.
1411
+ var _RAW_INSERT_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s*\(([^)]+)\)\s*VALUES\s*\(([\s\S]+)\)$/i;
1412
+ var _RAW_UPDATE_RE = /^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?\s+SET\s+([\s\S]+)$/i;
1413
+
1414
+ // The trailing `;` and the whitespace around it, removed by walking backwards
1415
+ // so no pattern has to express it. WHITESPACE_RANGES is what a regular
1416
+ // expression means by `\s`, so the two agree on U+00A0 and U+3000 as well as
1417
+ // the ASCII five; a hand-listed "space and tab" would leave a terminator the
1418
+ // old pattern removed.
1419
+ function _stripStatementTail(s) {
1420
+ var ws = codepointClass.WHITESPACE_RANGES;
1421
+ var end = s.length;
1422
+ while (end > 0 && codepointClass.inRanges(s.charCodeAt(end - 1), ws)) end -= 1;
1423
+ if (end > 0 && s.charCodeAt(end - 1) === 0x3B /* ; */) {
1424
+ end -= 1;
1425
+ while (end > 0 && codepointClass.inRanges(s.charCodeAt(end - 1), ws)) end -= 1;
1426
+ }
1427
+ return end === s.length ? s : s.slice(0, end);
1428
+ }
1402
1429
  var _RAW_TABLE_RE = /^\s*(?:INSERT|REPLACE)\s+(?:OR\s+[A-Za-z]+\s+)?INTO\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?|^\s*UPDATE\s+(?:[\x22\x27\x60]?[A-Za-z_]\w*[\x22\x27\x60]?\s*\.\s*){0,3}[\x22\x27\x60]?([A-Za-z_]\w*)[\x22\x27\x60]?/i;
1403
1430
 
1404
1431
  function _unquoteIdent(s) {
@@ -1588,8 +1615,9 @@ function _assertRawWriteResidency(sql, boundParams) {
1588
1615
  norm.length + " chars) - use b.db.from(\"" + table + "\") so residency is validated", true);
1589
1616
  }
1590
1617
 
1591
- var mi = _RAW_INSERT_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1592
- var mu = mi ? null : _RAW_UPDATE_RE.exec(norm); // allow:regex-no-length-cap — input length-capped above
1618
+ var body = _stripStatementTail(norm);
1619
+ var mi = _RAW_INSERT_RE.exec(body); // allow:regex-no-length-cap — input length-capped above
1620
+ var mu = mi ? null : _RAW_UPDATE_RE.exec(body); // allow:regex-no-length-cap — input length-capped above
1593
1621
  if (!mi && !mu) {
1594
1622
  throw new DbQueryError("db-query/row-residency-raw-unparseable",
1595
1623
  "raw write to residency table '" + table + "' cannot be parsed to validate its " +
@@ -2745,9 +2745,18 @@ function resolveProfileAndPosture(opts, cfg) {
2745
2745
  var v = resolved[k];
2746
2746
  if (v === undefined || !Array.isArray(allowed)) return;
2747
2747
  if (typeof v !== "string" || allowed.indexOf(v) === -1) {
2748
+ // A vocabulary of ONE is not a vocabulary, and saying "must be one of
2749
+ // reject" invites the reader to go looking for the members that are
2750
+ // missing. These are the checks where every disposition except refusing
2751
+ // is a hole — a traversal sequence, a null byte in a filename, `alg:
2752
+ // none` — so the single value is a deliberate lock rather than a
2753
+ // vocabulary that lost its siblings. The message says which.
2748
2754
  throw ErrorClass.factory(prefix + ".bad-opt",
2749
- prefix + ": " + k + " must be one of " + allowed.join(", ") +
2750
- "; got " + JSON.stringify(v));
2755
+ allowed.length === 1
2756
+ ? prefix + ": " + k + " is fixed at " + allowed[0] +
2757
+ " and is not configurable; got " + JSON.stringify(v)
2758
+ : prefix + ": " + k + " must be one of " + allowed.join(", ") +
2759
+ "; got " + JSON.stringify(v));
2751
2760
  }
2752
2761
  });
2753
2762
  }
@@ -703,10 +703,14 @@ function _sanitize(input, opts) {
703
703
  * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
704
704
  * bidiPolicy: "reject"|"strip"|"allow",
705
705
  * controlPolicy: "reject"|"strip"|"allow",
706
- * nullBytePolicy: "reject", // always reject
706
+ * nullBytePolicy: "reject", // fixed; not configurable
707
+ * // null-byte truncation
708
+ * // is never sanitizable
707
709
  * zeroWidthPolicy: "reject"|"strip"|"allow",
708
710
  * homoglyphPolicy: "reject"|"audit"|"allow",
709
- * traversalPolicy: "reject", // always reject
711
+ * traversalPolicy: "reject", // fixed; not configurable
712
+ * // no disposition other
713
+ * // than refusing is safe
710
714
  * reservedCharPolicy: "reject"|"strip"|"allow",
711
715
  * reservedNamePolicy: "reject"|"audit"|"allow",
712
716
  * adsPolicy: "reject"|"allow", // reject here; "allow"
package/lib/guard-jwt.js CHANGED
@@ -441,9 +441,9 @@ function _detectIssues(input, opts) {
441
441
  * allowedAlgs: string[],
442
442
  * requiredClaims: string[],
443
443
  * knownCrit: string[],
444
- * algNonePolicy: "reject", // alg=none is always critical
444
+ * algNonePolicy: "reject", // fixed; alg=none is always critical
445
445
  * algAllowlistPolicy: "reject"|"audit"|"allow",
446
- * kidTraversalPolicy: "reject", // kid traversal is always critical
446
+ * kidTraversalPolicy: "reject", // fixed; kid traversal is always critical
447
447
  * typConfusionPolicy: "reject"|"audit"|"allow",
448
448
  * expSanityPolicy: "reject"|"audit"|"allow",
449
449
  * nbfSanityPolicy: "reject"|"audit"|"allow",
@@ -141,10 +141,52 @@ function _unescapePointerToken(t) { return t.replace(/~1/g, "/").replace(/~0/g,
141
141
 
142
142
  // --- registry: indexes every subschema by canonical URI + anchors ---
143
143
 
144
- function _Registry() { this.schemas = {}; this.dynamicAnchors = {}; this.baseByNode = new Map(); }
144
+ // A ceiling on how many subschema POSITIONS one registry will index, across
145
+ // every document added to it.
146
+ //
147
+ // This is the total-work half of the bound, and it is a different guarantee
148
+ // from a depth ceiling: depth limits how far one path runs, and says nothing
149
+ // about how many paths there are. The walk below cannot use identity tracking
150
+ // instead, because it indexes each node by `base#pointer` and a shared object
151
+ // legitimately has one entry per pointer that reaches it — skipping the second
152
+ // visit would drop a `$ref` target rather than save work. So what gets bounded
153
+ // is the number of entries, which is the thing that actually grows.
154
+ //
155
+ // A schema graph that reaches one object through a branching position —
156
+ // `anyOf: [shared, shared]`, which is what building a schema out of reused
157
+ // JavaScript objects produces, and which needs no `$ref` — has 2^depth pointers
158
+ // over a handful of objects. A cyclic one has no end at all. Neither is refused
159
+ // by a depth cap.
160
+ //
161
+ // Far above any authored schema: the JSON Schema meta-schema is on the order of
162
+ // a hundred positions, and an OpenAPI document of a thousand endpoints is tens
163
+ // of thousands. A schema that reaches this is not one someone wrote out.
164
+ var MAX_REGISTRY_NODES = 100000; // subschema positions, not bytes
165
+
166
+ // How deep a schema may nest before it is refused. This is the OTHER half of
167
+ // the bound, and it is not the node ceiling in disguise: a chain ten thousand
168
+ // levels deep is ten thousand positions, far under that ceiling, and still runs
169
+ // the JavaScript stack out. What comes back then is `RangeError: Maximum call
170
+ // stack size exceeded` — the engine's error, which says nothing about the
171
+ // schema and is not catchable as a JsonSchemaError.
172
+ //
173
+ // It THROWS rather than stopping the walk. A walk that silently returns at the
174
+ // ceiling leaves a node looking examined when its descendants were skipped,
175
+ // which is how a pattern below one escapes screening entirely. Refusing is the
176
+ // only reading that cannot be mistaken for completion.
177
+ //
178
+ // Well above anything authored — the JSON Schema meta-schema nests about a
179
+ // dozen levels and an OpenAPI document a few dozen — and well below where the
180
+ // stack gives out.
181
+ var MAX_SCHEMA_NESTING = 1000; // nesting levels, not bytes
182
+
183
+ function _Registry() {
184
+ this.schemas = {}; this.dynamicAnchors = {}; this.baseByNode = new Map();
185
+ this.nodesIndexed = 0;
186
+ }
145
187
 
146
188
  _Registry.prototype.add = function (schema, baseUri) {
147
- this._walk(schema, baseUri || "", "");
189
+ this._walk(schema, baseUri || "", "", null, 0);
148
190
  // A document retrieved from URI X is addressable by X even when its own
149
191
  // $id is a different (canonical) URI — register the retrieval URI too.
150
192
  if (baseUri && (_isObject(schema) || typeof schema === "boolean")) {
@@ -156,10 +198,43 @@ _Registry.prototype.add = function (schema, baseUri) {
156
198
  // Walk a schema document, registering $id base changes, $anchor and
157
199
  // $dynamicAnchor names, and indexing every subschema by its base URI +
158
200
  // JSON-pointer fragment.
159
- _Registry.prototype._walk = function (node, baseUri, pointer) {
201
+ _Registry.prototype._walk = function (node, baseUri, pointer, path, depth) {
160
202
  if (!_isObject(node) && typeof node !== "boolean") return;
203
+ if (depth > MAX_SCHEMA_NESTING) {
204
+ throw new JsonSchemaError("json-schema/schema-too-deep",
205
+ "jsonSchema: schema nests deeper than " + MAX_SCHEMA_NESTING +
206
+ " levels — deeper than the walk can index without exhausting the stack");
207
+ }
208
+ this.nodesIndexed += 1;
209
+ if (this.nodesIndexed > MAX_REGISTRY_NODES) {
210
+ throw new JsonSchemaError("json-schema/schema-too-large",
211
+ "jsonSchema: schema indexes more than " + MAX_REGISTRY_NODES +
212
+ " subschema positions — a shared or cyclic schema graph reaches one " +
213
+ "object through many pointers; give the reused subschema an $id and " +
214
+ "reference it with $ref instead of embedding the same object twice");
215
+ }
161
216
  if (typeof node === "boolean") { this.schemas[baseUri + "#" + pointer] = node; return; }
162
217
 
218
+ // A node already on the ACTIVE PATH is a cycle, and a cycle with one
219
+ // reference per level never reaches the ceiling above: it does not branch, so
220
+ // it recurses straight down and exhausts the JavaScript stack while the count
221
+ // is still in the thousands, surfacing as a RangeError from the engine rather
222
+ // than the refusal this validator documents.
223
+ //
224
+ // The test is the path and not a depth cap, because deep is not the same as
225
+ // cyclic: a schema nesting four hundred levels with no cycle and no `$ref`
226
+ // compiles today, and a depth cap would refuse it — under a `ref-loop` code
227
+ // naming a reference chain it does not have. The path set answers the
228
+ // question actually being asked, and leaves depth alone.
229
+ path = path || new Set();
230
+ var isOwnAncestor = path.has(node);
231
+ if (isOwnAncestor) {
232
+ throw new JsonSchemaError("json-schema/ref-loop",
233
+ "jsonSchema: schema contains a cycle — a subschema is its own ancestor, " +
234
+ "so indexing it has no end; break the cycle with $id + $ref");
235
+ }
236
+ path.add(node);
237
+
163
238
  var thisBase = baseUri;
164
239
  if (typeof node.$id === "string") {
165
240
  thisBase = _resolveUri(node.$id, baseUri);
@@ -191,7 +266,7 @@ _Registry.prototype._walk = function (node, baseUri, pointer) {
191
266
  // Recurse. Keywords whose values are schemas vs maps-of-schemas vs
192
267
  // arrays-of-schemas are walked with the right shape.
193
268
  var self = this;
194
- function child(key, sub, ptr) { self._walk(sub, thisBase, ptr); }
269
+ function child(key, sub, ptr) { self._walk(sub, thisBase, ptr, path, depth + 1); }
195
270
  SCHEMA_KEYWORDS.forEach(function (k) {
196
271
  if (node[k] !== undefined) child(k, node[k], pointer + "/" + k);
197
272
  });
@@ -207,6 +282,12 @@ _Registry.prototype._walk = function (node, baseUri, pointer) {
207
282
  node[k].forEach(function (sub, idx) { child(k, sub, pointer + "/" + k + "/" + idx); });
208
283
  }
209
284
  });
285
+
286
+ // Off the active path on the way out. A node reached again through a SIBLING
287
+ // branch is shared rather than cyclic, and indexing it under its second
288
+ // pointer is the correct thing to do — leaving it in the set would call that
289
+ // a cycle and refuse a schema that merely reuses a subschema.
290
+ path.delete(node);
210
291
  };
211
292
 
212
293
  _Registry.prototype.resolve = function (uri) {
@@ -255,7 +336,13 @@ var SCHEMA_KEYWORDS = ["additionalProperties", "propertyNames", "items",
255
336
  "unevaluatedProperties"];
256
337
  var SCHEMA_MAP_KEYWORDS = ["$defs", "definitions", "properties",
257
338
  "patternProperties", "dependentSchemas"];
258
- var SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems"];
339
+ // `items` appears in BOTH lists on purpose. Draft 2020-12 makes it a single
340
+ // schema, and the legacy tuple form makes it an array of them; each branch
341
+ // checks the type it wants, so exactly one of them walks any given `items`.
342
+ // Listed only as a single schema, an array-valued one was dropped at the
343
+ // `_isObject` test at the top of the walk and never indexed at all — so the
344
+ // subschemas inside a tuple were unreachable by `$ref`, and unbudgeted.
345
+ var SCHEMA_ARRAY_KEYWORDS = ["allOf", "anyOf", "oneOf", "prefixItems", "items"];
259
346
 
260
347
  module.exports = _buildModule();
261
348
 
@@ -315,25 +402,55 @@ var _SUBSCHEMA_MAP_KEYS = ["$defs", "definitions", "dependentSchemas",
315
402
 
316
403
  // Walk the subschema positions for the two keywords that carry a regular
317
404
  // expression, and hand each to the same screen the compile site uses.
318
- // Depth-bounded by the ceiling the validator applies to `$ref` chains, so a
319
- // deeply nested schema cannot spend the walk instead of the match.
320
- function _screenPatterns(node, depth) {
321
- depth = depth || 0;
322
- if (depth > MAX_REF_DEPTH || !_isObject(node)) return;
405
+ //
406
+ // Every object is visited ONCE, tracked by identity. The depth ceiling below is
407
+ // not a substitute for that and never was: it limits how far a single path runs
408
+ // and says nothing about how many paths there are. A schema graph reaching one
409
+ // object through a branching position — `anyOf: [shared, shared]`, which is what
410
+ // building a schema in JavaScript out of reused subschema objects produces, and
411
+ // which needs no `$ref` — was walked once per path. Twenty-one objects cost
412
+ // 2.6 seconds, doubling per added level, against a cap that allows 256; a cyclic
413
+ // schema never returned. This screen exists so a pattern cannot make the
414
+ // validator hang, and SECURITY.md promises a screen costs the length of its
415
+ // input and never a function of its shape, so a walk that is itself a function
416
+ // of shape contradicts the thing it was added to guarantee.
417
+ //
418
+ // Identity tracking bounds the total work by the number of distinct objects,
419
+ // which is the length of the schema — so the promise holds without a separate
420
+ // node budget.
421
+ //
422
+ // The nesting ceiling here THROWS, and that distinction is the whole point. A
423
+ // ceiling that silently stopped walking would leave a node looking examined
424
+ // when its descendants were skipped, so a later and shallower path would find
425
+ // it already marked and skip it too — and a catastrophic pattern below it would
426
+ // never be screened at all, which is the opposite of what this walk is for.
427
+ // Refusing cannot be mistaken for completion.
428
+ function _screenPatterns(node, seen, depth) {
429
+ if (!_isObject(node)) return;
430
+ if (depth > MAX_SCHEMA_NESTING) {
431
+ throw new JsonSchemaError("json-schema/schema-too-deep",
432
+ "jsonSchema: schema nests deeper than " + MAX_SCHEMA_NESTING +
433
+ " levels — deeper than the pattern screen can walk without exhausting " +
434
+ "the stack");
435
+ }
436
+ seen = seen || new WeakSet();
437
+ if (seen.has(node)) return;
438
+ seen.add(node);
323
439
 
324
440
  if (typeof node.pattern === "string") _compileRegex(node.pattern);
325
441
  if (_isObject(node.patternProperties)) {
326
442
  Object.keys(node.patternProperties).forEach(function (p) { _compileRegex(p); });
327
443
  }
328
444
 
329
- _SUBSCHEMA_KEYS.forEach(function (k) { _screenPatterns(node[k], depth + 1); });
445
+ var next = (depth || 0) + 1;
446
+ _SUBSCHEMA_KEYS.forEach(function (k) { _screenPatterns(node[k], seen, next); });
330
447
  _SUBSCHEMA_LIST_KEYS.forEach(function (k) {
331
448
  if (!Array.isArray(node[k])) return;
332
- node[k].forEach(function (sub) { _screenPatterns(sub, depth + 1); });
449
+ node[k].forEach(function (sub) { _screenPatterns(sub, seen, next); });
333
450
  });
334
451
  _SUBSCHEMA_MAP_KEYS.forEach(function (k) {
335
452
  if (!_isObject(node[k])) return;
336
- Object.keys(node[k]).forEach(function (name) { _screenPatterns(node[k][name], depth + 1); });
453
+ Object.keys(node[k]).forEach(function (name) { _screenPatterns(node[k][name], seen, next); });
337
454
  });
338
455
  }
339
456