@blamejs/core 0.18.38 → 0.18.40
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 +121 -1
- package/NOTICE +2 -2
- package/README.md +1 -1
- package/lib/app.js +14 -5
- package/lib/file-upload.js +17 -3
- package/lib/gate-contract.js +159 -5
- package/lib/guard-filename.js +93 -18
- package/lib/guard-sql.js +7 -1
- package/lib/guard-yaml.js +64 -0
- package/lib/mail-auth.js +473 -73
- package/lib/network-dns-resolver.js +23 -23
- package/lib/network-dns.js +205 -43
- package/lib/public-suffix.js +110 -24
- package/lib/vendor/MANIFEST.json +15 -15
- package/lib/vendor/blamejs-pki.cjs +2120 -508
- package/lib/vendor/public-suffix-list.dat +4 -3
- package/lib/vendor/public-suffix-list.data.js +2201 -2201
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,9 +8,129 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.40 (2026-08-19) — **DMARC policy discovery now walks the DNS tree, so a policy at an intermediate label is no longer missed.** `b.mailAuth` resolved DMARC policy with a two-step lookup — the Author Domain, then the organizational domain from the Public Suffix List. RFC 9989 §4.10 specifies a tree walk that queries every ancestor in turn, and the difference is not academic: for `a.b.example.com` a `p=reject` published at `_dmarc.b.example.com` was never queried, evaluated as `none`, and mail the domain owner intended to reject was delivered.
|
|
12
|
+
|
|
13
|
+
The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
|
|
14
|
+
|
|
15
|
+
The DNS query encoder underneath it is hardened in the same release: it now refuses a hostname it cannot express on the wire instead of encoding some other name.
|
|
16
|
+
|
|
17
|
+
Upgrade if you evaluate inbound DMARC, or if any hostname you resolve comes from a request. Senders publishing policy only at the Author or organizational domain see no change. **Changed:** *Vendored `@blamejs/pki` 0.5.11 to 0.5.16, and refreshed the Public Suffix List* — Adds the verifying half of three request and delegation formats: `pki.csr.verify` and `pki.crmf.verifyPop` check the proof of possession on an inbound certification request — the check `openssl req -verify` performs — and `pki.attrcert.verify` checks an attribute certificate against the RFC 5755 validation rules, so a consumer reading its privilege attributes is reading ones an issuer actually granted. The parsers now record the byte range a proof covers, so a rebuilt message cannot present a genuine signature beside a substituted subject.
|
|
18
|
+
|
|
19
|
+
No framework call site changes; `b.mtlsCa` and `b.auth.passkey` behave as before.
|
|
20
|
+
|
|
21
|
+
0.5.16 hardens `pki.cmp.verify` against a caller that changes its own options while they are being read: every option is reduced to a value the caller can no longer reach before verification begins, byte options are copied through the toolkit's own byte door rather than by a prototype check a caller can rewrite, and the guards capture the intrinsics they use at load rather than reading them off a mutable prototype at call time. `b.mtlsCa` and `b.auth.passkey` behave as before.
|
|
22
|
+
|
|
23
|
+
The vendored Public Suffix List is refreshed to its 2026-08-19 publication. **Security:** *A DMARC policy published at an intermediate label is now found and applied* — Discovery queries `_dmarc.` at the Author Domain and then at each ancestor, so `a.b.example.com` now considers `_dmarc.b.example.com` before `_dmarc.example.com`. A `p=reject` there is applied rather than evaluated as `none`.
|
|
24
|
+
|
|
25
|
+
The policy comes from the closest record the walk found. Naming the Organizational Domain is a separate question, answered by RFC 9989 §4.10.2: a record carrying `psd=n` names it directly; a `psd=y` record found above the starting domain names the domain one label below it; otherwise it is the record at the name with the fewest labels. A single valid record carrying either `psd` value stops the walk, so nothing above it is queried. Multiple DMARC records at one name are still all discarded and the walk continues past that name.
|
|
26
|
+
|
|
27
|
+
The two answers stay separate on purpose. Letting the Organizational Domain also pick the policy reproduces the downgrade in a narrower shape: a `p=reject` at `b.example.com` under a `p=none; psd=n` at `example.com` would take the `p=none` two labels up.
|
|
28
|
+
|
|
29
|
+
`b.publicSuffix` still reports the PSL organizational domain on the result for callers that surface it, but it no longer decides which record applies. · *The walk carries the spec's query bound* — The sender chooses the Author Domain, so an unbounded walk would let one pick a domain with hundreds of labels and turn each message into hundreds of DNS queries on the receiver. RFC 9989 builds the guard into the algorithm: with eight or more labels the second query drops straight to seven remaining labels, which caps any domain at eight lookups.
|
|
30
|
+
|
|
31
|
+
For the RFC's own example, `a.b.c.d.e.f.g.h.i.j.mail.example.com`, the queries are exactly the eight names it lists, ending at `_dmarc.com`. A regression check asserts the count and the shape rather than trusting the arithmetic. · *A declared `psd` boundary now constrains relaxed alignment, not just policy* — A record carrying `psd=n` declares its own name the Organizational Domain; one carrying `psd=y` declares its name a public suffix, which puts the Organizational Domain one label below. Applying either record's policy while still reducing both domains to the Public Suffix List answer for alignment leaves a hole: with the boundary at `b.example.com`, an authenticated `evil.example.com` reduces to the same `example.com` as `a.b.example.com`, aligns, and satisfies the `p=reject` published inside the boundary — turning a reject into a pass on exactly the mail the boundary exists to separate.
|
|
32
|
+
|
|
33
|
+
The `psd=y` case is where it bites hardest, because a multi-label public suffix is what a vendored list is most likely to be missing: an authenticated `evil.platform.example` satisfied the `p=reject` at `platform.example` for mail from `tenant.platform.example`, so any tenant could pass for any other.
|
|
34
|
+
|
|
35
|
+
A `psd=y` record at the Author Domain itself is the opposite case: it needs no boundary, because that name shares an organization with nothing. RFC 9989 §4.10 stops the walk on either `psd` value, so nothing above is queried and the "one label below" rule has nothing below to name — and the record says this name is a public suffix, which makes each immediate child a separately registrable name belonging to whoever registered it. The suffix operator and a registrant under them are two organizations, so relaxed alignment has nothing to join and only an exact match can hold. Reporting the name as a boundary its children sit inside would be worse than reporting none: every registrant under the suffix could then authenticate mail claiming to come from the registry itself.
|
|
36
|
+
|
|
37
|
+
Relaxed alignment is now bounded by a declared boundary when the walk finds one: both the From domain and the authenticated domain must sit at or under it. The comparison is label-wise, so `evil-b.example.com` does not read as being under `b.example.com`. A host that is genuinely under the boundary aligns as before.
|
|
38
|
+
|
|
39
|
+
The fewest-labels fallback deliberately does not narrow alignment. It is an inference from what a domain owner happened to publish rather than a declaration, and narrowing on it would refuse mail that aligns correctly today. · *The 253-octet domain ceiling is measured on the converted name, not the typed one* — RFC 1035 §2.3.4 bounds the wire form of a name at 253 octets, and `b.publicSuffix.canonicalDomain` was measuring the string it was handed instead. An internationalized name grows when its labels become `xn--` form, so five 44-character labels are 224 characters going in and 254 octets coming out — over the limit, with every individual label a legal 50 octets, so a per-label check does not catch it either.
|
|
40
|
+
|
|
41
|
+
Such a name was returned as though it were ordinary, and nothing downstream could tell: it is a name that cannot be put on the wire at all. In `b.mail.dmarc.evaluate` it reached the policy walk, whose first target was unqueryable and skipped, and a policy found at a shorter ancestor was applied to it.
|
|
42
|
+
|
|
43
|
+
The ceiling is now checked after conversion, so it measures what the wire carries. A trailing root marker still does not count toward it — the wire form carries the root as a zero-length label rather than a character. The pre-conversion bound remains as a cheap guard against a pathological input, and settles every ASCII name on its own, since conversion leaves those unchanged.
|
|
44
|
+
|
|
45
|
+
Because this is the one definition of a domain name the framework uses, the correction reaches every caller: DNS resolution, DMARC, BIMI certificate matching. · *An Author Domain with an over-long label is refused, whichever resolver is wired in* — RFC 1035 §2.3.4 bounds a single label at 63 octets as well as the whole name at 253. Only the second was enforced before policy discovery, so an Author Domain carrying a 64-octet label was accepted and the outcome then depended on the resolver: the built-in one refuses the name and the evaluation reports `temperror`, while a `dnsLookup` you supply answers normally and a policy is applied for a name that cannot exist.
|
|
46
|
+
|
|
47
|
+
Such a domain now raises `mail-auth/dmarc-bad-from` before any lookup, so the same message gets the same answer on every resolver. A 63-octet label is unaffected. · *An Author Domain written with a root dot or in mixed case applies its own policy* — The walk normalizes the names it queries — lowercased, root dot dropped. Matching the Author Domain against the raw From-header form instead meant `alice@example.com.` or `alice@EXAMPLE.com` never matched its own record: the record was treated as an ancestor's and `sp=` was applied where `p=` governs. A record reading `p=reject; sp=none` then permitted exactly the mail it rejects, and a trailing dot is something a sender chooses.
|
|
48
|
+
|
|
49
|
+
The comparison now uses the normalized name the walk actually queried. · *A malformed record at any name in the walk is a permanent error* — RFC 9989 §4.10.1 makes a syntactically invalid or policy-less record a permanent error, and that holds wherever the walk meets it — not only at the Author Domain. Reading one as a transient lookup failure would let the walk skip that name and apply a policy from higher in the tree, which is a weaker policy chosen by whoever published the broken record.
|
|
50
|
+
|
|
51
|
+
The classification now lives in one place, used both by the walk (to decide whether to keep going) and by the result (to choose between `permerror` and `temperror`). It previously existed twice, and the second copy listed an error code the parser never raises — so every malformed-tag record read as transient.
|
|
52
|
+
|
|
53
|
+
One case is exempt, and it is the same one the unresolvable-ancestor rule exempts: a record already found closer to the Author Domain. Its `p=` applies directly, and a domain owner controls what they publish rather than what their parent publishes, so a broken record in a parent zone no longer turns a `p=reject` that resolved cleanly into a `permerror` the receiver reads as no policy.
|
|
54
|
+
|
|
55
|
+
Alignment is not narrowed on account of a malformed record either. Withholding relaxed alignment is for a name the walk could not READ — such a name may publish `psd=n`, a boundary narrower than the Public Suffix List, and computing alignment without it would admit a sibling that boundary exists to separate. A malformed record is the opposite case: it was read, and a record that does not parse declares no boundary at all. Treating it as unread forced both alignment modes to strict and failed mail that aligns correctly under the closer record's own `aspf=r`. · *`orgDomain` still reports the Public Suffix List answer* — The walk can apply a record at an intermediate label, and that name is reported in `policyOriginDomain`. `orgDomain` continues to report what `b.publicSuffix` resolves, which is what the field has always meant — a caller reading it for reporting or alignment sees no change in its meaning. · *`b.fileUpload` stores the cleaned filename, so the repaired extension reaches its content gate* — When `filenameSafety` returns a `sanitize` verdict, the upload replaces the stored name with the repaired one. It was reading that name from a field of its own rather than the gate-contract's `sanitized`, and a gate's verdict carries only the fields the contract defines — so the repair was dropped and the original name kept.
|
|
56
|
+
|
|
57
|
+
The consequence is worst when the character being removed sits inside the extension. A file named `report.ht<U+200B>ml` keeps an extension that no `contentSafety` key matches, so the `.html` gate you configured never runs; HTML has no magic bytes, so the type-confusion fallback cannot catch it either, and the file is stored ungated under a name that renders as ordinary HTML.
|
|
58
|
+
|
|
59
|
+
The upload now reads the contract field. A gate of your own that returns a plain verdict object with `sanitizedFilename` is still honoured. · *`b.guardYaml` applies its character policies instead of refusing every class* — A profile names a policy per character class, and `balanced` and `permissive` ask for `strip` on several of them — remove the character, keep the document. `b.guardYaml.gate()` resolved its action from the finding's severity instead, so seven of the twelve declared cells refused: a document an operator configured to be cleaned was rejected.
|
|
60
|
+
|
|
61
|
+
The cause was a missing gate. Every other content guard builds one that consumes its own policy map; this one relied on the default, which ends at severity and never consults the map. It now builds the same gate as its siblings, and resolves the profile before doing so — an unresolved options bag carries no character policies at all, which produces the identical wrong answer by a different route.
|
|
62
|
+
|
|
63
|
+
`strip` now returns the repaired document, `audit` records without refusing, and `reject` refuses as before.
|
|
64
|
+
|
|
65
|
+
The family invariant that checks this across every guard was not reaching YAML: it appended its probe character after the document's trailing newline, which starts a new line and adds a parse failure alongside the character finding, so all twelve cells were recorded as unreachable while the suite stayed green. It now injects the character inside the document. · *`b.guardFilename` performs the repair its profiles declare, instead of refusing* — A filename guard profile names a policy per character class, and `balanced` and `permissive` ask for `strip` on the zero-width and control classes — remove the character, keep the file. The gate resolved its action from the finding's severity instead, and then guarded the repair with an all-or-nothing test: if any policy anywhere in the profile said `reject`, nothing was sanitized, whatever the other policies declared. Three declared cells therefore refused a filename the guard can clean.
|
|
66
|
+
|
|
67
|
+
Each finding is now dispositioned by its own policy, and the strongest answer across the findings decides. Nothing is weakened by that: a traversal or NTFS-ads finding still refuses on its own policy regardless of the character policies, which is what the all-or-nothing test was reaching for, and `strict` still refuses all four classes.
|
|
68
|
+
|
|
69
|
+
The `sanitize` verdict also now carries the cleaned name. It was returned under a field the verdict builder does not forward, so a caller who acted on `action: "sanitize"` received nothing to use — invisible while the branch was all but unreachable.
|
|
70
|
+
|
|
71
|
+
A double executable extension answers the same policy as a single one. `invoice.pdf.exe` raises both a `shell-exec-ext` and a `double-extension` finding on the one condition, and the second was refusing regardless of `shellExecExtPolicy`, so setting that policy to `audit` under `balanced` or `permissive` still produced a refusal.
|
|
72
|
+
|
|
73
|
+
The family invariant that checks this covers `filename`-kind guards as well as content ones, and records the cells it cannot reach rather than counting only the ones it can. It was passing on a floor of six probes while this guard sat outside its filter entirely. · *A guard gate owns its context instead of writing into yours* — `gate().check(ctx)` stamped a forensic id onto whatever it was handed. Two consequences followed from that one line.
|
|
74
|
+
|
|
75
|
+
A context that was not an object — a bare string, a number — failed with a raw `TypeError` naming one of the gate's own internal fields, which tells a caller nothing about their call and nothing a handler can branch on. It is now a `gate-contract/bad-context` error carrying a code, raised before any work is done. `null` and `undefined` remain the documented "no context" case.
|
|
76
|
+
|
|
77
|
+
A frozen context was refused for being frozen, which is the opposite of what freezing it is for: an operator freezes the request shape precisely so middleware cannot edit it. The gate now derives its own context from yours instead of writing into it, so a frozen context is accepted and your object is left alone.
|
|
78
|
+
|
|
79
|
+
The derived context reads through to yours rather than copying its properties, so a context that is a class instance — or anything else supplying fields from a prototype — reaches the guard with those fields intact, symbol keys included. A copy would have dropped them, and a guard that reads a missing subject as nothing to inspect would then serve bytes it previously examined.
|
|
80
|
+
|
|
81
|
+
One consequence is worth knowing if your context carries methods. A method called as `ctx.read()` runs against the derived context, so it observes a field a `beforeCheck` transform or a sanitize step replaced — a sanitize chain exists so that nothing downstream sees the original bytes, and a method reading around that would defeat it. The cost is that a method needing the original instance as its receiver, such as one reading a private field, no longer resolves; a context whose data is only reachable that way is refused rather than inspected against stale bytes. Pass those fields on the context directly. · *Turning off the default error handler is now audited* — Each middleware `createApp` mounts by default is a security default, and disabling one emits an `app.middleware.disabled` audit event naming it, so a weakened posture is visible rather than silent. The error handler was mounted by default like the rest but registered without its name, so `middleware: { errorHandler: false }` left no trace.
|
|
82
|
+
|
|
83
|
+
The check that covers this now reads the default list out of the framework instead of restating it, so a default added later is covered when it lands. · *A hostname can no longer put a forged compression pointer in a DNS query* — A label's length is written into one octet, and RFC 1035 §4.1.4 reserves the top two bits of that octet: `11` marks a compression pointer and `01` an unassigned label type. The query encoder wrote each label's real length with no ceiling, so resolving a name with a 192-octet label emitted a question section beginning with a pointer the hostname chose, aiming the upstream resolver's name parser at an offset rather than asking a question. A 64-to-191-octet label produced an unassigned label type.
|
|
84
|
+
|
|
85
|
+
Labels are now checked against the RFC's 1..63 range, and the encoded name against its 255-octet ceiling, before any bytes are written. `b.network.dns.resolveSecure` and `querySvcb` already applied the label range; `lookup`, `resolve` and `b.network.dns.resolver` did not.
|
|
86
|
+
|
|
87
|
+
An application that resolves a hostname taken from a request is the exposed case. A `dns/bad-host` error now surfaces where malformed bytes were sent before. · *A DNS name is refused rather than repaired into a neighbouring one* — The encoder dropped empty labels, so a query for `evil..example.com` went out as `evil.example.com` — a real, separately-owned name — and the answer was cached under the name the caller had asked for. `b.network.dns.resolver`, which backs DKIM TXT, MTA-STS, DANE TLSA and BIMI discovery, applied no shape check of its own, so this was the reachable path.
|
|
88
|
+
|
|
89
|
+
One trailing root dot remains legal and is still the only thing removed. Anything else is a `dns/bad-host` error.
|
|
90
|
+
|
|
91
|
+
The same code mangled an internationalized name, truncating each character to its low byte. It now converts the name through `b.publicSuffix.canonicalDomain` to the `xn--` form its owner published under, and strips the root marker after that conversion as well as before — an absolute internationalized name need not end in an ASCII dot, because UTS #46 maps U+3002 and its siblings to one. A name written with U+3002 as its root marker only grows its trailing ASCII dot when converted, and was refused as malformed before that. `b.mail.dmarc.evaluate` had the same trap in its From normalization. The canonical name is what every later reader sees, so `resolveSecure`, `querySvcb` and `queryHttps` no longer refuse at their LDH pass a domain that `resolve4` resolves; `b.network.dns.resolver` canonicalizes before it keys its cache, so the two spellings of one name share an entry instead of splitting into two; and `b.mail.dmarc.evaluate` converts the Author Domain the same way, so a From of `alice@münchen.example` queries `_dmarc.xn--mnchen-3ya.example` and finds the policy that is actually there.
|
|
92
|
+
|
|
93
|
+
Case is canonicalized for ASCII names too, not only for internationalized ones. `Example.COM` and `example.com` used to take separate resolver-cache entries and make separate upstream queries while putting byte-identical questions on the wire, because the encoder lowercases whatever it is given. Every name now leaves validation in one form, so the cache key is the name rather than a spelling of it. A trailing root marker is still carried across, since a resolver reads it as "already fully qualified, do not apply the search list".
|
|
94
|
+
|
|
95
|
+
The two encoders were byte-for-byte copies of each other. They are now one, so a rule added to it cannot hold in only half the framework. · *A policy name too long to query no longer ends the walk* — `_dmarc.` is seven octets the Author Domain did not choose. A domain close to the 253-octet ceiling of RFC 1035 §2.3.4 is a valid domain that can carry mail, but its generated policy name is over the ceiling — so no record can exist there, and nobody can publish one, including the domain's owner.
|
|
96
|
+
|
|
97
|
+
That first lookup was raising a `dns/bad-host` error which ended the evaluation with a temperror, and the ancestors were never asked. A `p=reject` published one label up was neither found nor applied. An unqueryable generated name is now stepped over and the walk continues, the same as a name that answers nothing.
|
|
98
|
+
|
|
99
|
+
It is deliberately not treated as a name the walk skipped: a name that cannot exist hides no `psd` boundary, so relaxed alignment is not withheld over it. The threshold matches the one the resolver itself refuses at, and a regression check drives both sides of the boundary rather than comparing the two constants.
|
|
100
|
+
|
|
101
|
+
The `explanation` on a result with no record anywhere now counts the names actually queried rather than the names generated. · *A failed lookup is no longer read as an absent policy* — RFC 9989 distinguishes an answer of "no such record" from a lookup that did not complete, and only the former means DMARC does not apply. A name that failed to resolve may be the one publishing the controlling policy, so answering with whatever the walk did find would downgrade an unknown `p=reject` to a `p=none` published higher in the tree. An incomplete walk is a temperror.
|
|
102
|
+
|
|
103
|
+
One case is exempt: a record at the Author Domain itself. That is the most specific name there is and its `p=` applies directly, so no name the walk failed to read can be more authoritative for the policy. RFC 9989 §4.10 leaves the handling of a DNS error during the walk to the receiver, and without this exemption every domain would be hostage to a flaky parent zone — a message whose own policy resolved cleanly would temperror because an ancestor lookup timed out. A failure at the Author Domain is still a temperror outright, since that is the authoritative lookup for the message. · *A policy found part-way up the tree survives a failure above it* — The walk queries the most specific name first, so once it has read a record every closer name has already answered. A name further up can still declare a boundary, but it cannot carry a policy that outranks the record already in hand.
|
|
104
|
+
|
|
105
|
+
A lookup failure or a malformed record above that point was nonetheless discarding it. A `p=reject` published at `_dmarc.b.example.com` was read cleanly and then thrown away because `_dmarc.example.com` timed out, and the message was answered with a temperror — no policy applied, from a domain that published one. The exemption existed only for a record at the Author Domain itself, which is the strongest case of the rule rather than a special one.
|
|
106
|
+
|
|
107
|
+
What matters is the direction of the gap. A name the walk could not read that is MORE specific than the closest record is still a temperror: its policy would have won, and there is no way to know what it said. Relaxed alignment stays withheld either way, because the unread name may have declared the boundary. · *An incomplete walk no longer grants relaxed alignment* — Keeping the Author Domain's policy across a name the walk could not read settles the policy. It does not settle alignment, and the two were being decided together: an unread name may publish `psd=n`, and relaxed alignment computed without that boundary reduces both domains to the Public Suffix List answer. An authenticated `evil.example.com` then aligned with `a.b.example.com` and satisfied its `p=reject` — a pass, and delivery, for the one message the boundary exists to separate.
|
|
108
|
+
|
|
109
|
+
While a name in the walk is unread, relaxed alignment is withheld and only strict applies. Every boundary the walk could have found lies at or above the Author Domain, so an exact match is aligned whichever one is hidden; a relaxed match is the case that cannot be decided, and it is the case now refused. A message aligned strictly still passes across the gap, and a completed walk is unaffected. **References:** [RFC 9989 §4.10 — DMARC Policy Discovery and the DNS Tree Walk](https://www.rfc-editor.org/rfc/rfc9989.html)
|
|
110
|
+
|
|
111
|
+
- v0.18.39 (2026-08-19) — **A `PRAGMA trusted_schema` detector could be made to cost 100 ms by a run of spaces.** `b.guardSql`'s `trusted-schema` detector matched the optional `=` with `\s*=?\s*`. With the `=` absent those two whitespace runs are adjacent, so a run can be divided between them in as many ways as it is long — and every division is retried when the value that follows is not one the detector wants. `PRAGMA trusted_schema` followed by 16,000 spaces and a non-value took 100 ms, growing fourfold for each doubling of the run.
|
|
112
|
+
|
|
113
|
+
Binding the `=` to the whitespace after it leaves exactly one way to consume the run. Same statements refused, same statements ignored, measured flat.
|
|
114
|
+
|
|
115
|
+
Upgrade if you screen SQLite statements through `b.guardSql`. **Fixed:** *Vendored `@blamejs/pki` moves to 0.5.11* — The bundle backing `b.mtlsCa` and `b.auth.passkey`. In 0.5.11 the `pki.key`, `pki.path`, `pki.lint` and `pki.ocsp` verbs refuse an option they do not read rather than ignoring it, so a misspelled `password` on key export can no longer leave a private key unprotected — and they refuse an options object whose values come from a getter, since a getter is asked afresh on every read and the value the check saw need not be the one the verb uses.
|
|
116
|
+
|
|
117
|
+
No framework call site passes an unread option, so nothing changes for callers going through `b.mtlsCa` or `b.auth.passkey`. Operators calling the vendored library directly should check their option names against the ones each verb documents. · *A sentence in the 0.18.38 notes described an implementation that was not shipped* — The summary said the `COPY` stream exclusion is "decided by reading the following word rather than by a lookahead". That describes an approach that was built and then withdrawn; the shipped code keeps the lookahead and moves it in front of the whitespace. The published release body was corrected at the time and the changelog now matches it. **Security:** *The trusted_schema detector is no longer quadratic in a whitespace run* — The pattern is now `\bPRAGMA\s+trusted_schema\s*(?:=\s*)?(?:on|1|true)\b`. The set of statements it refuses is unchanged — `=on`, `= on`, ` = 1`, a bare ` on`, `=TRUE` — and `PRAGMA trusted_schema = off` is still not a finding. Verified identical across 10,008 constructed inputs before the change landed.
|
|
118
|
+
|
|
119
|
+
Cost at the ambiguous position, `PRAGMA trusted_schema` + n spaces + a character no value starts with:
|
|
120
|
+
|
|
121
|
+
| n | before | after |
|
|
122
|
+
| --- | --- | --- |
|
|
123
|
+
| 4,000 | 6 ms | 0.01 ms |
|
|
124
|
+
| 8,000 | 25 ms | 0.01 ms |
|
|
125
|
+
| 16,000 | 100 ms | 0.02 ms |
|
|
126
|
+
|
|
127
|
+
A cost regression check ships with it, asserting the run does not grow quadratically rather than pinning a wall-clock number.
|
|
128
|
+
|
|
129
|
+
The other 31 detectors were swept the same way — every one stressed at its own ambiguous positions with six different fillers rather than at its trigger keyword — and none grows superlinearly. This was the only one. **References:** [SQLite PRAGMA trusted_schema](https://www.sqlite.org/pragma.html#pragma_trusted_schema) · [OWASP — Regular expression Denial of Service (ReDoS)](https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS)
|
|
130
|
+
|
|
11
131
|
- v0.18.38 (2026-08-19) — **`COPY ... TO STDIN` was reported as a server-side file access whenever it carried more than one space.** `b.guardSql`'s `copy-file` detector finds a `COPY` that reads or writes a file on the database server, and excludes the client-streaming `STDIN` and `STDOUT` forms because those touch no file. The exclusion was a negative lookahead, and it only worked when the whitespace before the keyword was exactly one character: `COPY t TO STDIN` was quiet, `COPY t TO STDIN` was reported critical.
|
|
12
132
|
|
|
13
|
-
The exclusion is now
|
|
133
|
+
The exclusion is now tested before the whitespace is consumed, so the spacing no longer changes the verdict.
|
|
14
134
|
|
|
15
135
|
No API changes. Upgrade if you pass SQL fragments through `b.guardSql`. **Changed:** *The content-safety gate now refuses a pattern built at runtime* — `blamejs/no-regex-in-content-safety` reported pattern literals only, so `new RegExp(source)` passed it unnoticed anywhere under `lib/**/safe-*.js` or `lib/**/guard-*.js`. It now reports `new RegExp(...)` and `RegExp(...)`, in both the bare and member spellings — `globalThis.RegExp(src)` puts a MemberExpression in the callee and an identifier-only check permits it silently.
|
|
16
136
|
|
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.16
|
|
72
72
|
Source: https://github.com/blamejs/pki
|
|
73
73
|
License: Apache-2.0
|
|
74
74
|
Copyright: Copyright (c) blamejs contributors
|
|
@@ -101,7 +101,7 @@ Used for: Top-10000 most-common (breach-derived) passwords. Loaded by
|
|
|
101
101
|
baseline.
|
|
102
102
|
--------------------------------------------------------------------------------
|
|
103
103
|
Component: publicsuffix-list (Mozilla Public Suffix List)
|
|
104
|
-
Version: master snapshot (bundled 2026-08-
|
|
104
|
+
Version: master snapshot (bundled 2026-08-19)
|
|
105
105
|
Source: https://publicsuffix.org/list/public_suffix_list.dat
|
|
106
106
|
License: MPL-2.0
|
|
107
107
|
Copyright: Copyright (c) Mozilla Foundation and Public Suffix List 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.16 | [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
|
|
package/lib/app.js
CHANGED
|
@@ -54,10 +54,19 @@
|
|
|
54
54
|
* operator routes registered
|
|
55
55
|
* error handler attached via router.onError()
|
|
56
56
|
*
|
|
57
|
-
* Default middleware: requestId
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* Default middleware, in mount order: requestId, securityHeaders,
|
|
58
|
+
* botGuard, cookies, cspNonce, fetchMetadata, bodyParser, csrfProtect,
|
|
59
|
+
* and errorHandler (attached as the route-error catcher). The order is
|
|
60
|
+
* load-bearing — cookies and cspNonce before fetchMetadata, bodyParser
|
|
61
|
+
* before csrfProtect so a token can be read from a form field.
|
|
62
|
+
*
|
|
63
|
+
* csrfProtect defaults to a double-submit cookie and skips validation
|
|
64
|
+
* for stateless requests, so a bearer-token API is not broken by a
|
|
65
|
+
* protection that has no cookie to compare against.
|
|
66
|
+
*
|
|
67
|
+
* cors and rateLimit are opt-in only — both require explicit
|
|
68
|
+
* configuration (origins, thresholds) that the framework can't
|
|
69
|
+
* sensibly default.
|
|
61
70
|
*
|
|
62
71
|
* Operators disable any default middleware by passing
|
|
63
72
|
* `middleware: { requestId: false, securityHeaders: false, ... }`.
|
|
@@ -273,7 +282,7 @@ async function createApp(opts) {
|
|
|
273
282
|
}
|
|
274
283
|
|
|
275
284
|
// ---- 7. Error handler — last so it catches everything ----
|
|
276
|
-
var errorHandlerOpts = _resolveMiddlewareOpt(mwConfig.errorHandler, true);
|
|
285
|
+
var errorHandlerOpts = _resolveMiddlewareOpt(mwConfig.errorHandler, true, "errorHandler");
|
|
277
286
|
if (errorHandlerOpts) {
|
|
278
287
|
router.onError(middleware.errorHandler(errorHandlerOpts));
|
|
279
288
|
}
|
package/lib/file-upload.js
CHANGED
|
@@ -1158,10 +1158,24 @@ function create(opts) {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
// sanitize: replace metadata.filename with the sanitized form so
|
|
1160
1160
|
// downstream code sees the cleaned name.
|
|
1161
|
-
|
|
1161
|
+
//
|
|
1162
|
+
// `sanitized` is the gate-contract field and the one a real guard fills;
|
|
1163
|
+
// the verdict builder forwards only the fields it knows, so a
|
|
1164
|
+
// filename-specific name of its own never reaches here. Reading only that
|
|
1165
|
+
// left the ORIGINAL name in place, which matters most when the repair is
|
|
1166
|
+
// inside the extension: `report.ht<U+200B>ml` keeps an extension no
|
|
1167
|
+
// `contentSafety` key matches, so the configured `.html` gate never runs
|
|
1168
|
+
// — and HTML has no magic bytes, so the type-confusion fallback below
|
|
1169
|
+
// cannot catch it either, and the file is stored ungated.
|
|
1170
|
+
//
|
|
1171
|
+
// `sanitizedFilename` is still honoured for an operator's own gate that
|
|
1172
|
+
// returns a plain object without going through the builder.
|
|
1173
|
+
var cleanedName = fnDecision.sanitized || fnDecision.sanitizedFilename;
|
|
1174
|
+
if (fnDecision.action === "sanitize" && cleanedName) {
|
|
1175
|
+
cleanedName = String(cleanedName);
|
|
1162
1176
|
meta.metadata = Object.assign({}, meta.metadata || {},
|
|
1163
|
-
{ filename:
|
|
1164
|
-
filename =
|
|
1177
|
+
{ filename: cleanedName });
|
|
1178
|
+
filename = cleanedName;
|
|
1165
1179
|
}
|
|
1166
1180
|
}
|
|
1167
1181
|
if (contentSafety) {
|
package/lib/gate-contract.js
CHANGED
|
@@ -85,7 +85,9 @@ var DEFAULT_CACHE_TTL_MS = C.TIME.minutes(5);
|
|
|
85
85
|
* shape violations: `gate-contract/bad-shape` from
|
|
86
86
|
* `validateGateShape`, `gate-contract/bad-opt` from `defineGate` /
|
|
87
87
|
* `cachingGate` / `workerThreadGate`, `gate-contract/profile-cycle`
|
|
88
|
-
* and `gate-contract/unknown-profile` from `buildProfile
|
|
88
|
+
* and `gate-contract/unknown-profile` from `buildProfile`, and
|
|
89
|
+
* `gate-contract/bad-context` from a gate's `check` when the context
|
|
90
|
+
* it is handed is neither an object nor absent.
|
|
89
91
|
* `alwaysPermanent` — never retried by `b.retry`.
|
|
90
92
|
*
|
|
91
93
|
* @example
|
|
@@ -290,6 +292,126 @@ function validateGateShape(gate, label, errorClass) {
|
|
|
290
292
|
* var d = await gate.check({ bytes: Buffer.from("name,age\nada,36") });
|
|
291
293
|
* d.action; // → "serve"
|
|
292
294
|
*/
|
|
295
|
+
// A gate context with some fields REPLACED, leaving the caller's object alone.
|
|
296
|
+
// Used by the two paths that genuinely change the context — a `beforeCheck`
|
|
297
|
+
// transform, and the sanitize rebind that feeds scrubbed bytes to the next gate
|
|
298
|
+
// in a composition. Module scope on purpose: `composeGates` is not inside
|
|
299
|
+
// defineGate's closure.
|
|
300
|
+
//
|
|
301
|
+
// A context is not required to be a plain object, and the obvious shortcuts
|
|
302
|
+
// each break a different kind of one. Every line here is a fix for a shape that
|
|
303
|
+
// was mishandled:
|
|
304
|
+
//
|
|
305
|
+
// - `Object.assign({}, base, ...)` copies OWN enumerable properties, so a
|
|
306
|
+
// class instance whose fields come from prototype getters arrives with them
|
|
307
|
+
// missing. A guard reading an absent subject as nothing-to-inspect then
|
|
308
|
+
// serves bytes it used to examine.
|
|
309
|
+
// - `Object.create(base)` fixes that but makes inherited getters run with the
|
|
310
|
+
// DERIVED object as `this`, so a getter returning a private field throws
|
|
311
|
+
// and a valid context is refused.
|
|
312
|
+
// - Assigning the overrides (including via `Object.assign`) performs [[Set]],
|
|
313
|
+
// which walks the chain: an inherited non-writable property throws, and an
|
|
314
|
+
// inherited setter runs and writes into the caller.
|
|
315
|
+
//
|
|
316
|
+
// So: forward each readable name to `base` with `base` as the receiver, and
|
|
317
|
+
// install the overrides as own data properties via [[DefineOwnProperty]]. The
|
|
318
|
+
// readable names are collected once, which is sound because both callers derive
|
|
319
|
+
// from a context that is already fully formed.
|
|
320
|
+
function _deriveContext(base, overrides) {
|
|
321
|
+
// Same prototype as the caller's object, so `ctx instanceof TheirClass` still
|
|
322
|
+
// answers what it did — a guard may dispatch or validate on the context's
|
|
323
|
+
// type, and a wrapper on a bare object would fail that while carrying every
|
|
324
|
+
// one of the instance's fields. The forwarding accessors below are installed
|
|
325
|
+
// as OWN properties, so they shadow the prototype for everything readable and
|
|
326
|
+
// the prototype serves only identity.
|
|
327
|
+
var derived = Object.create(Object.getPrototypeOf(base));
|
|
328
|
+
var seen = Object.create(null);
|
|
329
|
+
// Copy-on-write. The getter forwards to `base` with `base` as the receiver;
|
|
330
|
+
// the setter REPLACES itself with a plain own data property holding the new
|
|
331
|
+
// value, so a guard that normalizes its subject in place (`ctx.bytes = ...`)
|
|
332
|
+
// succeeds and every later read sees the update. Without the setter the
|
|
333
|
+
// assignment silently does nothing, or throws under strict mode and turns a
|
|
334
|
+
// completed check into a refusal. The caller's object is still never touched.
|
|
335
|
+
//
|
|
336
|
+
// Functions are forwarded AS THEY ARE — never bound. That is a deliberate
|
|
337
|
+
// choice between two things that cannot both hold, and it is worth stating
|
|
338
|
+
// because the losing side is a real capability:
|
|
339
|
+
//
|
|
340
|
+
// - Called as `ctx.read()`, an unbound method runs with the DERIVED context
|
|
341
|
+
// as `this`, so a method reading `this.bytes` sees an override a
|
|
342
|
+
// `beforeCheck` transform or a sanitize step installed. This is the whole
|
|
343
|
+
// point of a sanitize chain: nothing downstream may see the original
|
|
344
|
+
// bytes. Binding to the caller's object was measured handing back
|
|
345
|
+
// `ORIGINAL` from a method while direct access returned `SANITIZED`.
|
|
346
|
+
// - Bound to the caller's object instead, a method carrying a brand — one
|
|
347
|
+
// reading a private field, or a built-in like `Map#get` — works, but then
|
|
348
|
+
// every ordinary method reads around the overrides.
|
|
349
|
+
//
|
|
350
|
+
// A brand requires the original instance as the receiver; an override
|
|
351
|
+
// requires the derived one. No receiver satisfies both, so the security
|
|
352
|
+
// property wins: overrides are always visible. A context whose data is only
|
|
353
|
+
// reachable through a branded method is therefore REFUSED rather than
|
|
354
|
+
// inspected against stale bytes — fail-closed, and visible to the operator,
|
|
355
|
+
// who can pass the fields directly instead.
|
|
356
|
+
//
|
|
357
|
+
// Not binding also keeps every function's identity and lets a guard choose a
|
|
358
|
+
// receiver with `.call()`, which matters for a callback the caller stored on
|
|
359
|
+
// the context.
|
|
360
|
+
var forward = function (key, enumerable) {
|
|
361
|
+
Object.defineProperty(derived, key, {
|
|
362
|
+
get: function () { return base[key]; },
|
|
363
|
+
set: function (value) {
|
|
364
|
+
Object.defineProperty(derived, key, {
|
|
365
|
+
value: value,
|
|
366
|
+
writable: true,
|
|
367
|
+
enumerable: enumerable,
|
|
368
|
+
configurable: true,
|
|
369
|
+
});
|
|
370
|
+
},
|
|
371
|
+
// Mirror the source. Enumerability is observable — a guard that spreads
|
|
372
|
+
// the context, calls Object.keys, or serializes it would otherwise see
|
|
373
|
+
// fields the original hid, and serializing a hidden accessor RUNS a
|
|
374
|
+
// getter the guard never chose to read.
|
|
375
|
+
enumerable: enumerable,
|
|
376
|
+
configurable: true,
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
// Reflect.ownKeys, not getOwnPropertyNames: a guard may tag its context with
|
|
380
|
+
// a Symbol to avoid colliding with a caller's field names, and a
|
|
381
|
+
// string-key-only walk drops it silently.
|
|
382
|
+
for (var proto = base; proto && proto !== Object.prototype;
|
|
383
|
+
proto = Object.getPrototypeOf(proto)) {
|
|
384
|
+
var names = Reflect.ownKeys(proto);
|
|
385
|
+
for (var i = 0; i < names.length; i += 1) {
|
|
386
|
+
var key = names[i];
|
|
387
|
+
if (seen[key]) continue;
|
|
388
|
+
var desc = Object.getOwnPropertyDescriptor(proto, key);
|
|
389
|
+
// Skip a class prototype's own back-reference to its constructor, which
|
|
390
|
+
// is plumbing rather than context — but NOT a field the caller happens to
|
|
391
|
+
// have named `constructor`, which is theirs and carries their value.
|
|
392
|
+
if (key === "constructor" && proto !== base && desc &&
|
|
393
|
+
typeof desc.value === "function" && desc.value.prototype === proto) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
seen[key] = true;
|
|
397
|
+
forward(key, desc ? desc.enumerable : true);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (overrides) {
|
|
401
|
+
var keys = Reflect.ownKeys(overrides);
|
|
402
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
403
|
+
var od = Object.getOwnPropertyDescriptor(overrides, keys[k]);
|
|
404
|
+
Object.defineProperty(derived, keys[k], {
|
|
405
|
+
value: overrides[keys[k]],
|
|
406
|
+
writable: true,
|
|
407
|
+
enumerable: od ? od.enumerable : true,
|
|
408
|
+
configurable: true,
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return derived;
|
|
413
|
+
}
|
|
414
|
+
|
|
293
415
|
function defineGate(opts) {
|
|
294
416
|
validateOpts.requireObject(opts, "gateContract.defineGate", GateContractError);
|
|
295
417
|
validateOpts.requireNonEmptyString(opts.name, "gateContract.defineGate: name", GateContractError, "gate-contract/bad-opt");
|
|
@@ -350,8 +472,30 @@ function defineGate(opts) {
|
|
|
350
472
|
|
|
351
473
|
async function check(ctx) {
|
|
352
474
|
var startedAt = Date.now();
|
|
353
|
-
|
|
354
|
-
|
|
475
|
+
// The gate owns the context it works with; the caller's object is read, not
|
|
476
|
+
// written. Stamping the forensic id onto whatever arrived meant a string
|
|
477
|
+
// raised a raw TypeError — and `check` is async, so that surfaced as an
|
|
478
|
+
// unhandled rejection taking the process down rather than the request — and
|
|
479
|
+
// a frozen context, which an operator freezes precisely so middleware
|
|
480
|
+
// cannot edit the request shape, was refused for being frozen.
|
|
481
|
+
if (ctx === null || ctx === undefined) {
|
|
482
|
+
ctx = {};
|
|
483
|
+
} else if (typeof ctx !== "object") {
|
|
484
|
+
throw _err("gate-contract/bad-context",
|
|
485
|
+
opts.name + ".check: context must be an object (got " + typeof ctx + ")");
|
|
486
|
+
}
|
|
487
|
+
// A guard can read `ctx.forensicId` to correlate its own audit records, so
|
|
488
|
+
// the id stays visible on the context the check receives. What changes is
|
|
489
|
+
// how it gets there: an id the caller supplied is used as-is and the
|
|
490
|
+
// caller's object is passed straight through, and only when one has to be
|
|
491
|
+
// generated is a context derived to carry it — never by writing into the
|
|
492
|
+
// object the caller handed over.
|
|
493
|
+
// Derive unconditionally. Whether the caller supplied an id decides only
|
|
494
|
+
// whether one is generated — never whether the caller's object is isolated,
|
|
495
|
+
// because a hook or guard that assigns to the context would otherwise be
|
|
496
|
+
// writing into the caller's own state, and would throw against a frozen one.
|
|
497
|
+
var forensicId = ctx.forensicId || bCrypto.generateToken(FORENSIC_ID_BYTES);
|
|
498
|
+
ctx = _deriveContext(ctx, { forensicId: forensicId });
|
|
355
499
|
|
|
356
500
|
// Decision cache lookup (memoize per-forensicHash).
|
|
357
501
|
var bytes = ctx.bytes;
|
|
@@ -376,7 +520,11 @@ function defineGate(opts) {
|
|
|
376
520
|
return _build({ ok: true, action: "serve", forensicHash: forensicHash, runtimeMs: Date.now() - startedAt });
|
|
377
521
|
}
|
|
378
522
|
if (beforeRv && beforeRv.transform) {
|
|
379
|
-
|
|
523
|
+
// Extend the chain, never flatten it: the caller's fields are reached
|
|
524
|
+
// THROUGH `ctx` rather than owned by it, so rebuilding from own
|
|
525
|
+
// enumerable properties would drop the subject on exactly the gates an
|
|
526
|
+
// operator attached a hook to.
|
|
527
|
+
ctx = _deriveContext(ctx, beforeRv.transform);
|
|
380
528
|
}
|
|
381
529
|
|
|
382
530
|
// Run operator check with optional runtime cap.
|
|
@@ -455,6 +603,10 @@ function defineGate(opts) {
|
|
|
455
603
|
decision.forensicSnapshot = snippet;
|
|
456
604
|
if (forensicEvidenceStore && typeof forensicEvidenceStore.write === "function") {
|
|
457
605
|
await forensicEvidenceStore.write({
|
|
606
|
+
// Read from the context as it stands, not from the id computed
|
|
607
|
+
// before the hooks ran: a `beforeCheck` transform may replace the
|
|
608
|
+
// forensic id, and the guard saw the replacement. Recording the
|
|
609
|
+
// earlier one breaks exactly the correlation the id exists for.
|
|
458
610
|
forensicId: ctx.forensicId,
|
|
459
611
|
forensicHash: forensicHash,
|
|
460
612
|
ruleHash: ruleHash,
|
|
@@ -664,7 +816,9 @@ function composeGates(gates, opts) {
|
|
|
664
816
|
if (d.sanitized) sanitized = d.sanitized;
|
|
665
817
|
// Feeding the scrubbed bytes forward is what makes the chain a
|
|
666
818
|
// pipeline rather than N independent opinions on the same input.
|
|
667
|
-
|
|
819
|
+
// Same chain-extending rebind as the beforeCheck transform above —
|
|
820
|
+
// only `bytes` is being replaced, not the rest of the context.
|
|
821
|
+
if (firstRefusalWins) ctx = _deriveContext(ctx, { bytes: d.sanitized });
|
|
668
822
|
}
|
|
669
823
|
}
|
|
670
824
|
return _build({
|
package/lib/guard-filename.js
CHANGED
|
@@ -881,7 +881,17 @@ function sanitize(input, opts) {
|
|
|
881
881
|
* every reject-policy off — strip-eligible classes only) → `refuse`
|
|
882
882
|
* (any reject-policy active or sanitize fails). Path-traversal /
|
|
883
883
|
* null-byte / NTFS-ADS / UNC / overlong-UTF-8 always cause `refuse`
|
|
884
|
-
* — there is no `sanitize` action for those classes
|
|
884
|
+
* — there is no `sanitize` action for those classes, and no policy
|
|
885
|
+
* setting reaches them.
|
|
886
|
+
*
|
|
887
|
+
* Each finding is dispositioned by its own policy and the strongest
|
|
888
|
+
* answer across them decides the action. The repair itself is not
|
|
889
|
+
* per-finding: `sanitize` dispatches to `b.guardFilename.sanitize`,
|
|
890
|
+
* which applies every transform the profile declares, so a name that
|
|
891
|
+
* enters sanitization because one class asked to strip also has the
|
|
892
|
+
* profile's other repairs applied — including to a class whose own
|
|
893
|
+
* policy was `audit`. The verdict's `sanitized` is byte-identical to
|
|
894
|
+
* calling `b.guardFilename.sanitize(name, opts)` directly.
|
|
885
895
|
*
|
|
886
896
|
* @opts
|
|
887
897
|
* profile: "strict"|"balanced"|"permissive",
|
|
@@ -896,6 +906,52 @@ function sanitize(input, opts) {
|
|
|
896
906
|
* var ok = await fnGate.check({ filename: "report.txt" });
|
|
897
907
|
* ok.action; // → "serve"
|
|
898
908
|
*/
|
|
909
|
+
// Bind each finding to the operator's policy for it. The shared character
|
|
910
|
+
// classes resolve through the one family helper; the filename-specific kinds
|
|
911
|
+
// map to the policy this guard names for them. A kind with no policy returns
|
|
912
|
+
// null and the caller applies the conservative severity answer.
|
|
913
|
+
// The classes this guard refuses unconditionally, whatever any policy says.
|
|
914
|
+
// Documented on the gate itself: there is no `sanitize` action for them, and a
|
|
915
|
+
// name carrying one is not repairable into a safe name — a UNC prefix reaches
|
|
916
|
+
// another host, a traversal segment escapes the directory, a NUL truncates the
|
|
917
|
+
// name at whichever consumer reads it first, and an ADS suffix names a second
|
|
918
|
+
// stream on the same file. Reading these from `traversalPolicy` would let a
|
|
919
|
+
// profile that sets it to `audit` serve the name unchanged, which is the
|
|
920
|
+
// bypass the floor exists to prevent.
|
|
921
|
+
var ALWAYS_REFUSE_KINDS = Object.freeze({
|
|
922
|
+
"path-traversal": true,
|
|
923
|
+
"path-traversal-encoded": true,
|
|
924
|
+
"unc-path": true,
|
|
925
|
+
"ntfs-ads": true,
|
|
926
|
+
"null-byte": true,
|
|
927
|
+
"overlong-utf8": true,
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
function _gateDispositionFor(issue, opts) {
|
|
931
|
+
if (ALWAYS_REFUSE_KINDS[issue.kind]) return "refuse";
|
|
932
|
+
var shared = gateContract.charThreatDisposition(issue, opts);
|
|
933
|
+
if (shared) return shared;
|
|
934
|
+
switch (issue.kind) {
|
|
935
|
+
case "path-separator-in-leaf":
|
|
936
|
+
case "url-encoded-separator": return gateContract.policyDisposition(opts.pathSeparatorsPolicy);
|
|
937
|
+
case "reserved-char": return gateContract.policyDisposition(opts.reservedCharPolicy);
|
|
938
|
+
case "reserved-name": return gateContract.policyDisposition(opts.reservedNamePolicy);
|
|
939
|
+
case "leading-trailing-strip": return gateContract.policyDisposition(opts.leadingTrailingPolicy);
|
|
940
|
+
case "homoglyph": return gateContract.policyDisposition(opts.homoglyphPolicy);
|
|
941
|
+
case "non-ascii": return gateContract.policyDisposition(opts.nonAsciiPolicy);
|
|
942
|
+
// Both of these fire on the same condition — a last extension in
|
|
943
|
+
// SHELL_EXEC_EXTS — so they are one finding reported twice and answer to
|
|
944
|
+
// one policy. Mapping only the first left the second on the conservative
|
|
945
|
+
// severity default, where `critical` refuses, and a profile asking to
|
|
946
|
+
// audit a disguised executable refused it instead.
|
|
947
|
+
case "shell-exec-ext":
|
|
948
|
+
case "double-extension": return gateContract.policyDisposition(opts.shellExecExtPolicy);
|
|
949
|
+
// Length, extension allowlisting and the dot-shape findings carry no
|
|
950
|
+
// policy of their own and admit no repair that preserves intent.
|
|
951
|
+
default: return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
899
955
|
function gate(opts) {
|
|
900
956
|
opts = _resolveOpts(opts);
|
|
901
957
|
return gateContract.buildGuardGate(
|
|
@@ -907,27 +963,46 @@ function gate(opts) {
|
|
|
907
963
|
if (!name) return { ok: true, action: "serve" };
|
|
908
964
|
var rv = validate(name, opts);
|
|
909
965
|
if (rv.issues.length === 0) return { ok: true, action: "serve" };
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
966
|
+
|
|
967
|
+
// The action comes from what each finding's OWN policy asks, and the
|
|
968
|
+
// strongest answer across the findings wins. Resolving it from severity
|
|
969
|
+
// instead made `critical` and `high` both refuse, so a profile asking to
|
|
970
|
+
// strip a zero-width character refused the filename rather than cleaning
|
|
971
|
+
// it — and the sanitize-eligibility test below compounded that by being
|
|
972
|
+
// all-or-nothing: one `reject` policy anywhere in the profile made every
|
|
973
|
+
// OTHER policy refuse too, whatever it declared.
|
|
974
|
+
//
|
|
975
|
+
// Taking the strongest disposition keeps that from weakening anything: a
|
|
976
|
+
// traversal finding still refuses on its own policy no matter what the
|
|
977
|
+
// character policies say, which is the case the all-or-nothing test was
|
|
978
|
+
// reaching for.
|
|
979
|
+
var strongest = "serve";
|
|
980
|
+
var RANK = { serve: 0, "audit-only": 1, sanitize: 2, refuse: 3 };
|
|
981
|
+
for (var qi = 0; qi < rv.issues.length; qi += 1) {
|
|
982
|
+
var d = _gateDispositionFor(rv.issues[qi], opts);
|
|
983
|
+
if (d === "audit") d = "audit-only";
|
|
984
|
+
// A finding with no policy of its own carries no instruction, so it
|
|
985
|
+
// falls back to the conservative severity answer rather than serving.
|
|
986
|
+
if (!d) {
|
|
987
|
+
d = (rv.issues[qi].severity === "critical" || rv.issues[qi].severity === "high")
|
|
988
|
+
? "refuse" : "audit-only";
|
|
989
|
+
}
|
|
990
|
+
if (RANK[d] > RANK[strongest]) strongest = d;
|
|
991
|
+
}
|
|
992
|
+
if (strongest === "serve") return { ok: true, action: "serve", issues: rv.issues };
|
|
993
|
+
if (strongest === "audit-only") return { ok: true, action: "audit-only", issues: rv.issues };
|
|
994
|
+
|
|
995
|
+
if (strongest === "sanitize") {
|
|
926
996
|
try {
|
|
927
997
|
var clean = sanitize(name, opts);
|
|
998
|
+
// `sanitized` is the field the gate contract carries through; a
|
|
999
|
+
// guard-specific name is dropped by the verdict builder, which would
|
|
1000
|
+
// hand the caller `action: "sanitize"` with nothing to use. This
|
|
1001
|
+
// branch was all but unreachable while sanitize-eligibility was
|
|
1002
|
+
// all-or-nothing, so the wrong field name never showed.
|
|
928
1003
|
return {
|
|
929
1004
|
ok: true, action: "sanitize",
|
|
930
|
-
|
|
1005
|
+
sanitized: clean,
|
|
931
1006
|
issues: rv.issues,
|
|
932
1007
|
};
|
|
933
1008
|
} catch (_e) { /* fall through */ }
|
package/lib/guard-sql.js
CHANGED
|
@@ -378,7 +378,13 @@ var DETECTORS = [
|
|
|
378
378
|
reason: "PRAGMA writable_schema lets a write corrupt the schema table" },
|
|
379
379
|
{ code: "sql.privilege-pivot", severity: "critical", kind: "trusted-schema",
|
|
380
380
|
family: "floor", dialect: "sqlite",
|
|
381
|
-
|
|
381
|
+
// `\s*=?\s*` was quadratic: with the `=` absent the two runs are adjacent,
|
|
382
|
+
// so a run of whitespace can be split between them in as many ways as it is
|
|
383
|
+
// long, and every split is retried when the value that follows does not
|
|
384
|
+
// match. `PRAGMA trusted_schema` + 16k spaces + a non-value took 100 ms and
|
|
385
|
+
// grew 4x per doubling. Binding the `=` to the whitespace after it leaves
|
|
386
|
+
// exactly one way to consume the run. Same language, measured flat.
|
|
387
|
+
re: _re("\\bPRAGMA\\s+trusted_schema\\s*(?:=\\s*)?(?:on|1|true)\\b"),
|
|
382
388
|
reason: "PRAGMA trusted_schema=ON re-enables unsafe schema functions" },
|
|
383
389
|
{ code: "sql.privilege-pivot", severity: "critical", kind: "sqlite-key",
|
|
384
390
|
family: "floor", dialect: "sqlite",
|