@blamejs/core 0.18.39 → 0.18.41
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 +118 -0
- package/NOTICE +2 -2
- package/README.md +2 -2
- package/lib/acme.js +5 -7
- package/lib/app.js +14 -5
- package/lib/audit.js +4 -4
- package/lib/auth/dpop.js +6 -6
- package/lib/cert.js +12 -12
- package/lib/compliance-sanctions.js +4 -4
- package/lib/cookies.js +98 -14
- package/lib/file-upload.js +17 -3
- package/lib/gate-contract.js +159 -5
- package/lib/guard-filename.js +93 -18
- package/lib/guard-yaml.js +64 -0
- package/lib/http-client.js +1 -3
- package/lib/mail-auth.js +477 -78
- package/lib/mail.js +1 -2
- package/lib/middleware/csrf-protect.js +45 -51
- package/lib/migrations.js +22 -22
- package/lib/network-dns-resolver.js +23 -23
- package/lib/network-dns.js +205 -43
- package/lib/public-suffix.js +110 -24
- package/lib/seeders.js +15 -15
- package/lib/session.js +99 -8
- package/lib/vendor/MANIFEST.json +14 -14
- package/lib/vendor/blamejs-pki.cjs +1385 -402
- package/lib/vendor/public-suffix-list.dat +4 -3
- package/lib/vendor/public-suffix-list.data.js +2201 -2201
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,124 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.41 (2026-08-20) — **Logout can now clear the session cookie on a plain-HTTP origin, and every Set-Cookie the framework writes goes through one validating writer.** `b.session.logout` built its expiry cookie by string concatenation with `Secure` hardcoded. A browser refuses a `Secure` cookie that arrives over plain HTTP, so on a cleartext deployment the header was discarded and the session cookie the logout existed to clear stayed in the jar. `b.middleware.csrfProtect` had its own cookie formatter with the same shape and a different consequence: it interpolated the configured `cookie.path` into the response header with no CRLF scrub. Both now compose `b.cookies`, which is the framework's single Set-Cookie writer. **Added:** *`b.cookies.assertAppendable(res)`* — Throws unless a response can carry an appended `Set-Cookie` — the same check `appendSetCookie` performs, exposed so a caller can run it before doing something it cannot undo. A response has to be readable as well as writable to be appended to: without `appendHeader` the merge happens in the framework and needs to see what is already queued, and a response carrying only `setHeader` would have its existing cookies silently replaced. `b.session.logout` calls it up front, because it revokes the session row before queueing the expiry cookie — a refusal at queue time would leave the session destroyed, the browser still holding its cookie, and the request failing. · *`b.cookies.appendSetCookie(res, header)`* — Queues one `Set-Cookie` header without discarding the ones already queued. `res.setHeader("Set-Cookie", value)` replaces the header, so a route that issues a session cookie and then a CSRF cookie sends only the second; `Set-Cookie` is the one response header that is legitimately repeated. The appender uses `res.appendHeader` where the runtime offers it and array-merges where it does not.
|
|
12
|
+
|
|
13
|
+
It pairs with `b.cookies.serialize`, which validates and builds the header string. `b.cookies.create().write()` and `.clear()` compose both; reach for the two separately when you need to build the header at one point in a flow and queue it at another — validating early, emitting only once the side effect it accompanies has succeeded, which is what `b.session.logout` does with the session row. **Changed:** *`b.session.logout` queues its expiry cookie rather than overwriting the header* — It called `res.setHeader("Set-Cookie", ...)`, which discarded any cookie the route had already queued — a rotated CSRF token, a locale. It now appends. Code reading `res.getHeader("Set-Cookie")` after a logout sees an array of header strings rather than a single string; Node accepts either form on the way out.
|
|
14
|
+
|
|
15
|
+
`logout` also validates its options object, so a misspelled key is reported instead of silently ignored, and the expiry cookie is now built before the session row is revoked. Building it can fail — a `__Host-` name without `Secure` is refused — and a failure after the revoke would leave the session destroyed with the browser still holding its cookie. · *esbuild 0.28.1 to 0.28.2 (build tooling only)* — esbuild builds the single-file executable the bundler-output gate checks; it is a devDependency and does not ship. The published tarballs differ only in version strings, one new documented API option (`logStyle`), and the refreshed per-platform binary hash map, with a byte-identical `install.js` and an unchanged `postinstall` script. The reviewed SHA-256 for the two platforms the build runs on — CI's `linux-x64` and the maintainer host's `win32-x64` — are recorded in `scripts/esbuild-binary-pin.json` and verified against the binaries npm actually served. **Security:** *`b.session.logout` resolves the `Secure` attribute instead of hardcoding it* — The expiry cookie was emitted as `sid=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0`, with `Secure` present unconditionally. A user agent rejects a `Secure` cookie set from a non-secure origin, so on a plain-HTTP deployment neither this header nor the `Clear-Site-Data` queued beside it (also gated on a secure context) could clear the session cookie. The server-side row was already destroyed, so the revoked token was never usable — what remained was a cookie the user could not get rid of by logging out, sent on every later request.
|
|
16
|
+
|
|
17
|
+
`logout` now takes the transport as input. Pass `req` and the scheme is resolved through `b.requestHelpers.trustedProtocol`, which honours a forwarded scheme only from a peer you have declared trusted; pass `secure` to state it outright. With neither, the cookie is `Secure` — the previous behaviour, and still the default.
|
|
18
|
+
|
|
19
|
+
The cookie's scope is now describable too. A browser matches an expiry cookie on name, path and domain, so a session cookie written with `Domain=example.com` or `Path=/app` could not be cleared by an expiry cookie that named neither. `logout` accepts `path`, `domain` and `sameSite`. · *A configured CSRF cookie path can no longer split the response header* — `b.middleware.csrfProtect` formatted its own `Set-Cookie` and interpolated `cookie.path` into it directly. A configured path containing a bare CR or LF ended the header there and let whatever followed be read as a header of its own. The attributes now pass through `b.cookies.serialize`, which scrubs CR and NUL from `Domain` and `Path` before they reach the wire, validates the cookie name as an RFC 6265 token, and refuses CRLF, NUL, semicolon or comma in the value.
|
|
20
|
+
|
|
21
|
+
One further consequence of routing through it: a CSRF cookie configured `sameSite: "None"` now always carries `Secure`, which the SameSite specification requires and which the middleware's own formatter did not add. A `SameSite=None` cookie without `Secure` is rejected by browsers, so this affects a configuration that could not have been working. · *A `__Host-` or `__Secure-` CSRF cookie name now requires an explicit `cookie.secure: true`* — Those prefixes are a promise to the browser that the cookie is always `Secure`, and RFC 6265bis §4.1.3 has user agents drop a cookie that carries the prefix without it. Leaving `cookie.secure` to per-request auto-detection breaks that promise on any cleartext request: the cookie went out prefixed and without `Secure`, the browser discarded it, and the double-submit token silently never persisted — every request then looked like a first visit. The existing boot check could not see this, because the decision was made per request rather than at configuration time.
|
|
22
|
+
|
|
23
|
+
Configuring a prefixed name now fails at boot unless `cookie.secure: true` is set. A cookie name with no prefix keeps auto-detection, so the default configuration is unchanged. **Detectors:** *`Set-Cookie` may only be written by `b.cookies`* — A `codebase-patterns` entry refuses `setHeader`/`appendHeader` for `Set-Cookie` anywhere outside `lib/cookies.js`. Both defects above came from a file building the header itself and each losing a different guarantee, and one of them carried a comment recording that it did not route through `b.cookies.serialize`. · *A local may not reuse the name of a required module binding* — An eslint rule using scope analysis reports a `var`, `let` or `const` that takes the name of a module the file requires. `var` hoists, so such a local owns the name for the whole enclosing function — including the lines above its own declaration — and a call further down reads the local with no clue nearby that it is no longer the module. This is not hypothetical: it happened while making the change above, and surfaced as `cookies.serialize is not a function` where `cookies` was a parsed request jar.
|
|
24
|
+
|
|
25
|
+
Parameters are deliberately out of scope. They shadow the same way but are part of the signature the reader has just read, and naming a SQL-string parameter `sql` or a connection-handle parameter `db` is the clearest name available.
|
|
26
|
+
|
|
27
|
+
Seventeen further instances across `lib/` are resolved with it. Six were an inline `require` of a module the file already required at the top, and are removed rather than renamed. **References:** [RFC 6265bis §4.1.3 — Cookie Name Prefixes](https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis) · [W3C Clear-Site-Data](https://www.w3.org/TR/clear-site-data/)
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
|
|
31
|
+
The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
No framework call site changes; `b.mtlsCa` and `b.auth.passkey` behave as before.
|
|
38
|
+
|
|
39
|
+
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.
|
|
40
|
+
|
|
41
|
+
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`.
|
|
42
|
+
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
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.
|
|
46
|
+
|
|
47
|
+
`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.
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
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.
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
|
|
57
|
+
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.
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
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.
|
|
62
|
+
|
|
63
|
+
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.
|
|
64
|
+
|
|
65
|
+
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.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
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.
|
|
78
|
+
|
|
79
|
+
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.
|
|
80
|
+
|
|
81
|
+
`strip` now returns the repaired document, `audit` records without refusing, and `reject` refuses as before.
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
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.
|
|
88
|
+
|
|
89
|
+
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.
|
|
90
|
+
|
|
91
|
+
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.
|
|
92
|
+
|
|
93
|
+
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.
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
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.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
One trailing root dot remains legal and is still the only thing removed. Anything else is a `dns/bad-host` error.
|
|
108
|
+
|
|
109
|
+
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.
|
|
110
|
+
|
|
111
|
+
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".
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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.
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
121
|
+
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.
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
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)
|
|
128
|
+
|
|
11
129
|
- 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.
|
|
12
130
|
|
|
13
131
|
Binding the `=` to the whitespace after it leaves exactly one way to consume the run. Same statements refused, same statements ignored, measured flat.
|
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
|
@@ -88,7 +88,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
|
|
|
88
88
|
- Opaque-userId anonymous sessions via `create({ anonymous: true })`
|
|
89
89
|
- Idle / absolute timeouts, fingerprint drift detection + anomaly scoring, brute-force lockout
|
|
90
90
|
- Session-fixation rotation (`b.session.rotate`) re-keys the sid-bound device fingerprint to the new id — pass the same `{ req, fingerprintFields }` used at `create` (a fingerprint-bound session rotated without `req` is refused, so the binding can never silently break or false-drift)
|
|
91
|
-
- One-call secure logout (`b.session.logout(res, token)`) destroys the session AND wipes client-side state — emits
|
|
91
|
+
- One-call secure logout (`b.session.logout(res, token, { req })`) destroys the session AND wipes client-side state — revokes the row first, then emits a W3C Clear-Site-Data header (cookies + storage + cache) and an expiry cookie beside it. Pass `req` and the `Secure` attribute follows the request's scheme through `b.requestHelpers.trustedProtocol`, so the expiry cookie is not discarded by a browser on a plain-HTTP origin; `path` / `domain` / `sameSite` describe the cookie being cleared when it was written with a narrower scope
|
|
92
92
|
- **Authorization** — RBAC + per-role DB binding + role-spec `requireMfa` + per-route MFA freshness window + ABAC predicate registry (`b.permissions`); API keys with rotation (`b.apiKey`)
|
|
93
93
|
- **Workflow gates** — break-glass column gates with second-factor + audit (`b.breakGlass`); two-person-rule m-of-n approval with cooling-off lock + cancellation (`b.dualControl`)
|
|
94
94
|
- **Financial / Open Banking** — FAPI 2.0 Final composite posture (PAR + PKCE-S256 + DPoP-or-mTLS + RFC 9207); runtime enforcement helpers `b.fapi2.assertCallback` (refuses missing iss + bare-param under message-signing) and `b.fapi2.assertAuthzRequest` (refuses non-JAR); CFPB §1033 / FDX 6.0 consumer-financial-data-sharing wrapper (`b.fdx`)
|
|
@@ -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/acme.js
CHANGED
|
@@ -100,16 +100,16 @@ function _publicJwkFromKeyObject(keyObject) {
|
|
|
100
100
|
if (!keyObject || typeof keyObject.export !== "function") {
|
|
101
101
|
throw _err("acme/bad-account-key", "accountKey must expose a Node KeyObject (export)", true);
|
|
102
102
|
}
|
|
103
|
-
var
|
|
104
|
-
try {
|
|
103
|
+
var exported;
|
|
104
|
+
try { exported = keyObject.export({ format: "jwk" }); }
|
|
105
105
|
catch (e) { throw _err("acme/bad-account-key", "accountKey export(jwk) failed: " + e.message, true); }
|
|
106
|
-
if (!
|
|
106
|
+
if (!exported || exported.kty !== "EC" || exported.crv !== "P-256") {
|
|
107
107
|
throw _err("acme/bad-account-key",
|
|
108
108
|
"accountKey must be a P-256 EC keypair (RFC 8555 §6.2 ES256); got kty=" +
|
|
109
|
-
(
|
|
109
|
+
(exported && exported.kty) + " crv=" + (exported && exported.crv), true);
|
|
110
110
|
}
|
|
111
111
|
// RFC 7638 thumbprint inputs MUST be sorted alphabetically + minimal-JSON.
|
|
112
|
-
return Object.freeze({ crv:
|
|
112
|
+
return Object.freeze({ crv: exported.crv, kty: exported.kty, x: exported.x, y: exported.y });
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function _jwkThumbprint(publicJwk) {
|
|
@@ -1247,7 +1247,6 @@ function create(opts) {
|
|
|
1247
1247
|
throw _err("acme/bad-token", "tlsAlpn01KeyAuthorization: token must be a non-empty string", true);
|
|
1248
1248
|
}
|
|
1249
1249
|
var keyAuth = token + "." + _jwkThumbprint(publicJwk);
|
|
1250
|
-
var nodeCrypto = require("node:crypto");
|
|
1251
1250
|
return nodeCrypto.createHash("sha256").update(keyAuth, "utf8").digest();
|
|
1252
1251
|
}
|
|
1253
1252
|
|
|
@@ -1348,7 +1347,6 @@ function create(opts) {
|
|
|
1348
1347
|
throw _err("acme/bad-ttl",
|
|
1349
1348
|
"dnsAccount01ChallengeRecord: ttl must be a positive integer <= 86400 seconds", true);
|
|
1350
1349
|
}
|
|
1351
|
-
var nodeCrypto = require("node:crypto");
|
|
1352
1350
|
// Account label: lowercase base32 of first 10 bytes of SHA-256(accountUrl)
|
|
1353
1351
|
// (per draft-ietf-acme-dns-account-label §3.1 — 80-bit truncated label).
|
|
1354
1352
|
var hash = nodeCrypto.createHash("sha256").update(state.accountUrl, "utf8").digest();
|
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/audit.js
CHANGED
|
@@ -1963,8 +1963,8 @@ function generateActorBindingTriggerSql(opts) {
|
|
|
1963
1963
|
*/
|
|
1964
1964
|
async function assertSegregation(opts) {
|
|
1965
1965
|
opts = opts || {};
|
|
1966
|
-
var
|
|
1967
|
-
if (!
|
|
1966
|
+
var externalDb = opts.db || null;
|
|
1967
|
+
if (!externalDb || typeof externalDb.query !== "function") {
|
|
1968
1968
|
throw new AuditSegregationError("audit/segregation-no-db",
|
|
1969
1969
|
"audit.assertSegregation: opts.db with a query() method is required");
|
|
1970
1970
|
}
|
|
@@ -1976,11 +1976,11 @@ async function assertSegregation(opts) {
|
|
|
1976
1976
|
// Operator-DB system-catalog introspection (Postgres pg_proc / pg_trigger,
|
|
1977
1977
|
// $N-native, against the operator-supplied db.query) — not a framework
|
|
1978
1978
|
// table, so b.sql's verb builders don't apply.
|
|
1979
|
-
var fnRes = await
|
|
1979
|
+
var fnRes = await externalDb.query(
|
|
1980
1980
|
"SELECT 1 FROM pg_proc WHERE proname = $1 LIMIT 1", [fnName] // allow:hand-rolled-sql
|
|
1981
1981
|
);
|
|
1982
1982
|
var fnPresent = !!(fnRes && fnRes.rows && fnRes.rows.length > 0);
|
|
1983
|
-
var trigRes = await
|
|
1983
|
+
var trigRes = await externalDb.query(
|
|
1984
1984
|
"SELECT 1 FROM pg_trigger WHERE tgname = $1 LIMIT 1", [trigName] // allow:hand-rolled-sql
|
|
1985
1985
|
);
|
|
1986
1986
|
var trigPresent = !!(trigRes && trigRes.rows && trigRes.rows.length > 0);
|
package/lib/auth/dpop.js
CHANGED
|
@@ -219,16 +219,16 @@ async function buildProof(opts) {
|
|
|
219
219
|
"alg '" + alg + "' is not supported by DPoP");
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
-
var
|
|
222
|
+
var proofKey = opts.jwk || _publicJwkFromPrivate(key);
|
|
223
223
|
// Strip private parts from the embedded jwk if the operator passed a
|
|
224
224
|
// private JWK by accident — ONLY public components belong in the proof.
|
|
225
225
|
var pubJwk;
|
|
226
|
-
if (
|
|
227
|
-
else if (
|
|
228
|
-
else if (
|
|
229
|
-
else if (
|
|
226
|
+
if (proofKey.kty === "EC") pubJwk = { kty: "EC", crv: proofKey.crv, x: proofKey.x, y: proofKey.y };
|
|
227
|
+
else if (proofKey.kty === "OKP") pubJwk = { kty: "OKP", crv: proofKey.crv, x: proofKey.x };
|
|
228
|
+
else if (proofKey.kty === "RSA") pubJwk = { kty: "RSA", e: proofKey.e, n: proofKey.n };
|
|
229
|
+
else if (proofKey.kty === "AKP") pubJwk = { kty: "AKP", alg: proofKey.alg, pub: proofKey.pub };
|
|
230
230
|
else throw new AuthError("auth-dpop/refused-kty",
|
|
231
|
-
"jwk.kty='" +
|
|
231
|
+
"jwk.kty='" + proofKey.kty + "' is not allowed");
|
|
232
232
|
|
|
233
233
|
var jti = opts.jti || _b64urlEncode(nodeCrypto.randomBytes(C.BYTES.bytes(16)));
|
|
234
234
|
var nowMs = (typeof opts.iat === "number" ? opts.iat * C.TIME.seconds(1) : Date.now());
|
package/lib/cert.js
CHANGED
|
@@ -514,22 +514,22 @@ function create(opts) {
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
async function _issueCert(certManifest) {
|
|
517
|
-
var
|
|
517
|
+
var acmeClient = _bootAcme();
|
|
518
518
|
// 1. Fetch directory + ensure ACME account exists.
|
|
519
|
-
await
|
|
520
|
-
await
|
|
519
|
+
await acmeClient.fetchDirectory();
|
|
520
|
+
await acmeClient.newAccount({
|
|
521
521
|
contact: opts.acme.contactEmail ? ["mailto:" + opts.acme.contactEmail] : undefined,
|
|
522
522
|
termsOfServiceAgreed: true,
|
|
523
523
|
});
|
|
524
524
|
// 2. Create the order.
|
|
525
|
-
var order = await
|
|
525
|
+
var order = await acmeClient.newOrder({
|
|
526
526
|
identifiers: certManifest.domains.map(function (d) {
|
|
527
527
|
return { type: "dns", value: d };
|
|
528
528
|
}),
|
|
529
529
|
});
|
|
530
530
|
// 3. For each authorization, solve the operator-supplied challenge.
|
|
531
531
|
for (var ai = 0; ai < order.authorizations.length; ai += 1) {
|
|
532
|
-
var auth = await
|
|
532
|
+
var auth = await acmeClient.fetchAuthorization(order.authorizations[ai]);
|
|
533
533
|
if (auth.status === "valid") continue;
|
|
534
534
|
var challenge = auth.challenges.find(function (ch) {
|
|
535
535
|
return ch.type === certManifest.challenge.type;
|
|
@@ -541,8 +541,8 @@ function create(opts) {
|
|
|
541
541
|
}
|
|
542
542
|
// tls-alpn-01 has a different key-authorization shape (RFC 8737).
|
|
543
543
|
var keyAuth = certManifest.challenge.type === "tls-alpn-01"
|
|
544
|
-
?
|
|
545
|
-
:
|
|
544
|
+
? acmeClient.tlsAlpn01KeyAuthorization(challenge.token)
|
|
545
|
+
: acmeClient.keyAuthorization(challenge.token);
|
|
546
546
|
var provisionParams = {
|
|
547
547
|
domain: auth.identifier.value,
|
|
548
548
|
type: challenge.type,
|
|
@@ -551,8 +551,8 @@ function create(opts) {
|
|
|
551
551
|
};
|
|
552
552
|
await certManifest.challenge.provision(provisionParams);
|
|
553
553
|
try {
|
|
554
|
-
await
|
|
555
|
-
await
|
|
554
|
+
await acmeClient.notifyChallengeReady(challenge.url);
|
|
555
|
+
await acmeClient.waitForAuthorization(order.authorizations[ai]);
|
|
556
556
|
} finally {
|
|
557
557
|
try { await certManifest.challenge.cleanup(provisionParams); }
|
|
558
558
|
catch (cleanupErr) {
|
|
@@ -568,13 +568,13 @@ function create(opts) {
|
|
|
568
568
|
}
|
|
569
569
|
// 4. Generate leaf keypair + CSR + finalize.
|
|
570
570
|
var leafPair = _generateLeafKeypair(certManifest.keyAlg);
|
|
571
|
-
var csrPem =
|
|
571
|
+
var csrPem = acmeClient.buildCsr({
|
|
572
572
|
privateKey: leafPair.privateKey,
|
|
573
573
|
publicKey: leafPair.publicKey,
|
|
574
574
|
domains: certManifest.domains,
|
|
575
575
|
});
|
|
576
|
-
var finalized = await
|
|
577
|
-
var certPem = await
|
|
576
|
+
var finalized = await acmeClient.finalize(order, csrPem);
|
|
577
|
+
var certPem = await acmeClient.retrieveCert(finalized);
|
|
578
578
|
var privPem = leafPair.privateKey.export({ type: "pkcs8", format: "pem" });
|
|
579
579
|
return { certPem: certPem, keyPem: privPem };
|
|
580
580
|
}
|
|
@@ -200,15 +200,15 @@ function parseUn1267Entry(entry) {
|
|
|
200
200
|
if (!entry || typeof entry !== "object") return null;
|
|
201
201
|
var name = entry.NAME || entry.name || entry.FIRST_NAME || "";
|
|
202
202
|
if (!name) return null;
|
|
203
|
-
var
|
|
204
|
-
if (Array.isArray(entry.ALIASES))
|
|
203
|
+
var entryAliases = [];
|
|
204
|
+
if (Array.isArray(entry.ALIASES)) entryAliases = entry.ALIASES.slice();
|
|
205
205
|
else if (typeof entry.ALIAS_NAMES === "string") {
|
|
206
|
-
|
|
206
|
+
entryAliases = entry.ALIAS_NAMES.split(";").map(function (s) { return s.trim(); }).filter(Boolean);
|
|
207
207
|
}
|
|
208
208
|
return {
|
|
209
209
|
id: "UN-1267-" + String(entry.REFERENCE_NUMBER || entry.DATAID || ""),
|
|
210
210
|
primaryName: String(name).trim(),
|
|
211
|
-
aliases:
|
|
211
|
+
aliases: entryAliases,
|
|
212
212
|
type: entry.NAME_TYPE === "Entity" ? "entity" : "individual",
|
|
213
213
|
programs: ["UN-1267"],
|
|
214
214
|
country: entry.COUNTRY || entry.NATIONALITY || null,
|
package/lib/cookies.js
CHANGED
|
@@ -324,18 +324,100 @@ function serialize(name, value, attrs) {
|
|
|
324
324
|
return parts.join("; ");
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
|
|
328
|
-
|
|
327
|
+
/**
|
|
328
|
+
* @primitive b.cookies.appendSetCookie
|
|
329
|
+
* @signature b.cookies.appendSetCookie(res, header)
|
|
330
|
+
* @since 0.18.41
|
|
331
|
+
* @status stable
|
|
332
|
+
* @related b.cookies.serialize, b.cookies.create
|
|
333
|
+
*
|
|
334
|
+
* Queue one Set-Cookie header on a response without discarding the ones
|
|
335
|
+
* already queued. `res.setHeader("Set-Cookie", value)` REPLACES the header,
|
|
336
|
+
* so a second cookie written that way silently drops the first — a route
|
|
337
|
+
* that issues a session cookie and then a CSRF cookie ends up sending only
|
|
338
|
+
* the CSRF one. Set-Cookie is the one response header that is legitimately
|
|
339
|
+
* repeated, and this is the framework's single appender for it: it uses
|
|
340
|
+
* `res.appendHeader` where the runtime offers it, and falls back to reading
|
|
341
|
+
* the current value and array-merging where it doesn't.
|
|
342
|
+
*
|
|
343
|
+
* The header string must already be serialized — pair it with
|
|
344
|
+
* `b.cookies.serialize`, which validates the name, value and attributes.
|
|
345
|
+
* `b.cookies.create().write()` / `.clear()` compose both for you; reach for
|
|
346
|
+
* this directly when you need to build the header at one point in a flow and
|
|
347
|
+
* queue it at another (validating early, emitting only after the side effect
|
|
348
|
+
* it accompanies has succeeded).
|
|
349
|
+
*
|
|
350
|
+
* @example
|
|
351
|
+
* var header = b.cookies.serialize("sid", "", {
|
|
352
|
+
* httpOnly: true, secure: true, sameSite: "Strict", path: "/", maxAge: 0,
|
|
353
|
+
* });
|
|
354
|
+
* b.cookies.appendSetCookie(res, header);
|
|
355
|
+
* // → res now carries this expiry cookie alongside any already queued
|
|
356
|
+
*/
|
|
357
|
+
/**
|
|
358
|
+
* @primitive b.cookies.assertAppendable
|
|
359
|
+
* @signature b.cookies.assertAppendable(res)
|
|
360
|
+
* @since 0.18.41
|
|
361
|
+
* @status stable
|
|
362
|
+
* @related b.cookies.appendSetCookie, b.session.logout
|
|
363
|
+
*
|
|
364
|
+
* Throw unless `res` can carry an appended `Set-Cookie`. This is the same
|
|
365
|
+
* check `b.cookies.appendSetCookie` performs, exposed so a caller can run it
|
|
366
|
+
* BEFORE doing something it cannot undo.
|
|
367
|
+
*
|
|
368
|
+
* A response has to be readable as well as writable to be appended to: without
|
|
369
|
+
* `appendHeader`, the merge is done here and needs to see what is already
|
|
370
|
+
* queued. Discovering that late is the problem this exists to prevent —
|
|
371
|
+
* `b.session.logout` revokes the session row before it queues the expiry
|
|
372
|
+
* cookie, so a throw at queue time would leave the session destroyed, the
|
|
373
|
+
* client still holding its cookie, and the request failing. The response's
|
|
374
|
+
* shape is fixed for the life of the process and owned by the caller, not by
|
|
375
|
+
* the request, so it can and should be asserted up front.
|
|
376
|
+
*
|
|
377
|
+
* @example
|
|
378
|
+
* b.cookies.assertAppendable(res); // throws on a write-only response
|
|
379
|
+
* await doSomethingIrreversible();
|
|
380
|
+
* b.cookies.appendSetCookie(res, header);
|
|
381
|
+
* // → the append cannot fail for a reason that was knowable earlier
|
|
382
|
+
*/
|
|
383
|
+
function assertAppendable(res) {
|
|
329
384
|
if (!res || typeof res.setHeader !== "function") {
|
|
330
385
|
throw new CookieError("cookies/no-set-header",
|
|
331
386
|
"response object has no setHeader (not a Node http.ServerResponse?)");
|
|
332
387
|
}
|
|
333
|
-
|
|
334
|
-
|
|
388
|
+
// Without appendHeader the merge has to be done here, which means reading
|
|
389
|
+
// what is already queued. A response that can be written but not READ — a
|
|
390
|
+
// thin adapter or a test double carrying only setHeader — cannot be appended
|
|
391
|
+
// to at all: treating the unreadable value as absent would overwrite a cookie
|
|
392
|
+
// the route had already queued, which is the precise loss the appender exists
|
|
393
|
+
// to prevent. Refuse instead of silently doing the damage.
|
|
394
|
+
if (typeof res.appendHeader !== "function" && typeof res.getHeader !== "function") {
|
|
395
|
+
throw new CookieError("cookies/unreadable-response",
|
|
396
|
+
"response exposes setHeader but neither appendHeader nor getHeader, so an " +
|
|
397
|
+
"already-queued Set-Cookie cannot be read and would be replaced. Give the " +
|
|
398
|
+
"response a getHeader (or appendHeader) implementation.");
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function appendSetCookie(res, header) {
|
|
403
|
+
assertAppendable(res);
|
|
404
|
+
if (typeof header !== "string" || header.length === 0) {
|
|
405
|
+
throw new CookieError("cookies/invalid-header",
|
|
406
|
+
"appendSetCookie: header must be a non-empty serialized Set-Cookie string");
|
|
407
|
+
}
|
|
408
|
+
// Node >= 18 exposes appendHeader, which handles the multi-value merge
|
|
409
|
+
// itself; prefer it so the response's own bookkeeping stays authoritative.
|
|
410
|
+
if (typeof res.appendHeader === "function") {
|
|
411
|
+
res.appendHeader("Set-Cookie", header);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// assertAppendable has already established that getHeader exists when
|
|
415
|
+
// appendHeader does not, so the merge below can read the response.
|
|
416
|
+
var existing = res.getHeader("Set-Cookie");
|
|
335
417
|
var arr;
|
|
336
|
-
if (Array.isArray(existing))
|
|
337
|
-
else if (existing !== undefined)
|
|
338
|
-
else
|
|
418
|
+
if (Array.isArray(existing)) arr = existing.slice();
|
|
419
|
+
else if (existing !== undefined && existing !== null) arr = [existing];
|
|
420
|
+
else arr = [];
|
|
339
421
|
arr.push(header);
|
|
340
422
|
res.setHeader("Set-Cookie", arr);
|
|
341
423
|
}
|
|
@@ -402,7 +484,7 @@ function create(opts) {
|
|
|
402
484
|
|
|
403
485
|
function read(req, name) { return _readCookieFromReq(req, name); }
|
|
404
486
|
function write(res, name, value, attrs) {
|
|
405
|
-
|
|
487
|
+
appendSetCookie(res, serialize(name, value, _mergeAttrs(attrs)));
|
|
406
488
|
}
|
|
407
489
|
function clear(res, name, attrs) {
|
|
408
490
|
// Expire-now cookie. Domain + Path must match the original write
|
|
@@ -410,7 +492,7 @@ function create(opts) {
|
|
|
410
492
|
// attrs they used on write (or rely on the same defaults).
|
|
411
493
|
var attrsExp = Object.assign({}, _mergeAttrs(attrs), { maxAge: 0 });
|
|
412
494
|
delete attrsExp.expires;
|
|
413
|
-
|
|
495
|
+
appendSetCookie(res, serialize(name, "", attrsExp));
|
|
414
496
|
}
|
|
415
497
|
|
|
416
498
|
function _requireVault() {
|
|
@@ -575,9 +657,11 @@ function parseSafe(cookieHeader, opts) {
|
|
|
575
657
|
}
|
|
576
658
|
|
|
577
659
|
module.exports = {
|
|
578
|
-
create:
|
|
579
|
-
parse:
|
|
580
|
-
parseSafe:
|
|
581
|
-
serialize:
|
|
582
|
-
|
|
660
|
+
create: create,
|
|
661
|
+
parse: parse,
|
|
662
|
+
parseSafe: parseSafe,
|
|
663
|
+
serialize: serialize,
|
|
664
|
+
appendSetCookie: appendSetCookie,
|
|
665
|
+
assertAppendable: assertAppendable,
|
|
666
|
+
CookieError: CookieError,
|
|
583
667
|
};
|
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) {
|