@blamejs/core 0.18.40 → 0.18.42
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 +28 -0
- package/NOTICE +1 -1
- package/README.md +2 -2
- package/lib/acme.js +5 -7
- 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/http-client.js +1 -3
- package/lib/mail-auth.js +4 -5
- package/lib/mail.js +1 -2
- package/lib/middleware/bot-guard.js +33 -10
- package/lib/middleware/csrf-protect.js +45 -51
- package/lib/migrations.js +22 -22
- package/lib/seeders.js +15 -15
- package/lib/session.js +99 -8
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +104 -73
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,34 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.18.x
|
|
10
10
|
|
|
11
|
+
- v0.18.42 (2026-08-20) — **`b.middleware.botGuard` no longer refuses a request for omitting Accept-Language, which was returning 403 to every search-engine crawler.** `b.middleware.botGuard` treated a missing `Accept-Language` header as grounds for a 403 in its default blocking mode. Google documents that Googlebot sends requests without setting that header, and bingbot behaves the same, so every content page of a site running the default middleware chain answered 403 to a crawler while a browser was served normally. The header's absence is now an advisory signal, matching how the same middleware already treats a missing `Sec-Fetch-Mode`. **Changed:** *Vendored `@blamejs/pki` 0.5.16 to 0.5.17* — A failed integrity check now destroys the plaintext it had recovered rather than returning it, `AuthEnvelopedData` validates the attributes it is asked to authenticate, and the refusal for a content cipher in the wrong container cites the rule it actually applies — RFC 5083 §2 defines `AuthEnvelopedData` in terms of authenticated encryption and never names GCM, so the previous message sent operators to argue with a specification that says the opposite. **Fixed:** *A missing `Accept-Language` tags a request instead of refusing it* — The check was `if (!headers["accept-language"]) return "missing-accept-language"`, and `mode` defaults to `"block"`, so the request was answered 403. Whole client families omit the header — every major search-engine crawler, and with them uptime monitors, link previewers and feed readers — which made a site unreachable to all of them. On a deployment fronted by a cache this is easy to miss from the outside: the cached home page still answers 200, so the site looks partly indexed while every other URL is refused.
|
|
12
|
+
|
|
13
|
+
It now sets `req.suspectedBot` in `mode: "tag"` and never blocks, which is exactly how the middleware already treats a missing `Sec-Fetch-Mode` — absent for Safari before 16.4 and for every plain-HTTP non-localhost origin — and how `b.middleware.fetchMetadata` already treats a missing `Sec-Fetch-Site` through its `allowMissing` default. A header a whole client family omits is not evidence of automation.
|
|
14
|
+
|
|
15
|
+
Requests are still refused on positive evidence: the `User-Agent` deny-list (curl, wget, python-requests, axios, Go-http-client and the rest) is unchanged, and operators can extend it with `blockedAgents`. Separating a crawler from an abuser is a rate-limiting question rather than a header one, and `b.rateLimit` answers it.
|
|
16
|
+
|
|
17
|
+
If you were relying on the old behaviour, `mode: "tag"` surfaces the same signal on `req.suspectedBot` for you to act on. **Detectors:** *No single absent header may block, asserted against the middleware* — `bot-guard.test.js` now starts from a complete browser request and removes one header at a time, asserting that no single omission refuses the request. It covers the headers the middleware inspects — `User-Agent`, `Accept`, `Accept-Language`, `Accept-Encoding` and the three `Sec-Fetch-*` — so the rule holds for whichever one a future change reaches for, not only the one that was wrong.
|
|
18
|
+
|
|
19
|
+
It is a behavioural check rather than a source-pattern one deliberately. Written as a pattern check first, it was wrong six different ways: anchored to the first expression in the condition, bounded at the first `)`, bounded to a single line, defeated by a statement before the return, by a guard that was OR'd rather than AND'd, and by a `)` inside a string literal. Each fix was a step further into parsing JavaScript with text matching. Running the middleware decides all six, because it asks what the code does rather than how it is written. **References:** [How Google crawls locale-adaptive pages — Googlebot and Accept-Language](https://developers.google.com/search/docs/specialty/international/locale-adaptive-pages)
|
|
20
|
+
|
|
21
|
+
- 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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
`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.
|
|
26
|
+
|
|
27
|
+
`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.
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
35
|
+
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.
|
|
36
|
+
|
|
37
|
+
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/)
|
|
38
|
+
|
|
11
39
|
- 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
40
|
|
|
13
41
|
The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
|
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.17
|
|
72
72
|
Source: https://github.com/blamejs/pki
|
|
73
73
|
License: Apache-2.0
|
|
74
74
|
Copyright: Copyright (c) blamejs contributors
|
package/README.md
CHANGED
|
@@ -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.17 | [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/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/http-client.js
CHANGED
|
@@ -851,9 +851,7 @@ function _attachJarCookie(headers, jar, url) {
|
|
|
851
851
|
function _buildMultipartBody(spec) {
|
|
852
852
|
var boundary = "----blamejs-mp-" + bCrypto.generateToken(C.BYTES.bytes(16));
|
|
853
853
|
var CRLF = "\r\n";
|
|
854
|
-
var
|
|
855
|
-
var path = require("node:path"); // allow:inline-require — same
|
|
856
|
-
var nodeStream = require("node:stream"); // allow:inline-require — Readable subclass only when streaming
|
|
854
|
+
var path = require("node:path"); // allow:inline-require — only on multipart paths that touch the filesystem
|
|
857
855
|
|
|
858
856
|
// Each entry is { headerBytes, source } where source is one of:
|
|
859
857
|
// { kind: "buffer", buf: Buffer }
|
package/lib/mail-auth.js
CHANGED
|
@@ -334,11 +334,11 @@ function _ipv6Expand(ip) {
|
|
|
334
334
|
|
|
335
335
|
function _ipv6InCidr(ip, cidr) {
|
|
336
336
|
var slash = cidr.indexOf("/");
|
|
337
|
-
var
|
|
337
|
+
var networkAddr = slash === -1 ? cidr : cidr.slice(0, slash);
|
|
338
338
|
var mask = slash === -1 ? 128 : parseInt(cidr.slice(slash + 1), 10); // IPv6 max prefix
|
|
339
339
|
if (!isFinite(mask) || mask < 0 || mask > 128) return false; // IPv6 max prefix
|
|
340
340
|
var ipGroups = _ipv6Expand(ip);
|
|
341
|
-
var netGroups = _ipv6Expand(
|
|
341
|
+
var netGroups = _ipv6Expand(networkAddr);
|
|
342
342
|
if (!ipGroups || !netGroups) return false;
|
|
343
343
|
if (mask === 0) return true;
|
|
344
344
|
// Compare group-by-group up to the prefix boundary.
|
|
@@ -356,11 +356,11 @@ function _ipv6InCidr(ip, cidr) {
|
|
|
356
356
|
|
|
357
357
|
function _ipv4InCidr(ip, cidr) {
|
|
358
358
|
var slash = cidr.indexOf("/");
|
|
359
|
-
var
|
|
359
|
+
var networkAddr = slash === -1 ? cidr : cidr.slice(0, slash);
|
|
360
360
|
var mask = slash === -1 ? 32 : parseInt(cidr.slice(slash + 1), 10); // IPv4 max prefix
|
|
361
361
|
if (!isFinite(mask) || mask < 0 || mask > 32) return false; // IPv4 max prefix
|
|
362
362
|
var ipInt = _ipv4ToInt(ip);
|
|
363
|
-
var netInt = _ipv4ToInt(
|
|
363
|
+
var netInt = _ipv4ToInt(networkAddr);
|
|
364
364
|
if (ipInt === null || netInt === null) return false;
|
|
365
365
|
if (mask === 0) return true;
|
|
366
366
|
var bits = 32 - mask; // IPv4 max prefix
|
|
@@ -2395,7 +2395,6 @@ function _pemFromB64KeyMaterial(b64) {
|
|
|
2395
2395
|
}
|
|
2396
2396
|
|
|
2397
2397
|
function _runVerify(signedString, sigB64, algorithm, keyB64, label) {
|
|
2398
|
-
var nodeCrypto = require("node:crypto");
|
|
2399
2398
|
var pem = _pemFromB64KeyMaterial(keyB64);
|
|
2400
2399
|
var keyObj;
|
|
2401
2400
|
try { keyObj = nodeCrypto.createPublicKey(pem); }
|
package/lib/mail.js
CHANGED
|
@@ -248,10 +248,9 @@ async function reverseDns(ip) {
|
|
|
248
248
|
// the original input. RFC 8601 §3 says the forward query must use
|
|
249
249
|
// the same family as the source; mismatched families don't count
|
|
250
250
|
// as confirmation.
|
|
251
|
-
var net = require("node:net");
|
|
252
251
|
var forwardAddrs = [];
|
|
253
252
|
try {
|
|
254
|
-
if (net.isIPv6(ip)) {
|
|
253
|
+
if (net().isIPv6(ip)) {
|
|
255
254
|
forwardAddrs = await dns.resolveAaaa(ptrName);
|
|
256
255
|
} else {
|
|
257
256
|
forwardAddrs = await dns.resolve4(ptrName);
|
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
* authentication, but catches drive-by scrapers and most low-effort bots.
|
|
8
8
|
*
|
|
9
9
|
* Heuristics (all combined):
|
|
10
|
-
* - Missing Accept-Language header
|
|
10
|
+
* - Missing Accept-Language header — ADVISORY ONLY (never blocks). Tagged
|
|
11
|
+
* in mode:"tag". It cannot block because the header is absent for entire
|
|
12
|
+
* client families: every major search-engine crawler omits it (Google
|
|
13
|
+
* documents that Googlebot "sends HTTP requests without setting
|
|
14
|
+
* Accept-Language"), as do uptime monitors, link previewers and feed
|
|
15
|
+
* readers — a 403 on it alone made a site's every page unreachable to
|
|
16
|
+
* Googlebot while a browser sailed through.
|
|
11
17
|
* - Missing Sec-Fetch-Mode header — ADVISORY ONLY (never blocks). Tagged
|
|
12
18
|
* in mode:"tag" on secure-context HTML GETs where a modern browser
|
|
13
19
|
* would have sent it. It cannot block because the header is absent for
|
|
@@ -90,13 +96,15 @@ function _coerceAgentPattern(r, where) {
|
|
|
90
96
|
* Cheap fingerprint-based detection of obviously-non-browser requests.
|
|
91
97
|
* Constructed via `b.middleware.botGuard(opts)`; the resulting
|
|
92
98
|
* middleware has the `(req, res, next)` shape shown above.
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
99
|
+
* One blocking heuristic — a User-Agent regex match against a default
|
|
100
|
+
* list (curl / wget / python-requests / axios / etc.) — plus two
|
|
101
|
+
* advisory signals that set `req.suspectedBot` in `mode: "tag"` and
|
|
102
|
+
* NEVER block: a missing `Accept-Language` (absent from every major
|
|
103
|
+
* search-engine crawler, so blocking on it makes a site unreachable to
|
|
104
|
+
* them) and a missing `Sec-Fetch-Mode` on a secure-context HTML GET
|
|
105
|
+
* (absent for Safari < 16.4 and every plain-HTTP non-localhost origin).
|
|
106
|
+
* A header a whole client family omits is not evidence of automation.
|
|
107
|
+
* Not
|
|
100
108
|
* a substitute for proper authentication — catches drive-by scrapers
|
|
101
109
|
* and low-effort bots. In `mode: "block"` (default) the request is
|
|
102
110
|
* refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
|
|
@@ -206,14 +214,29 @@ function create(opts) {
|
|
|
206
214
|
// Skip browser-fingerprint checks for API routes
|
|
207
215
|
return null;
|
|
208
216
|
}
|
|
209
|
-
|
|
217
|
+
// Missing Accept-Language NEVER blocks, for the reason the Sec-Fetch-Mode
|
|
218
|
+
// check below does not either: the header is absent for entire CLIENT
|
|
219
|
+
// families, so a 403 on it alone refuses them wholesale. Every major
|
|
220
|
+
// search-engine crawler omits it — Google documents that Googlebot "sends
|
|
221
|
+
// HTTP requests without setting Accept-Language in the request header",
|
|
222
|
+
// and bingbot behaves the same — so blocking on it made every content page
|
|
223
|
+
// of a blamejs site answer 403 to a crawler while a browser sailed
|
|
224
|
+
// through. Measured on a live deployment: a 337-URL sitemap of which only
|
|
225
|
+
// the cached homepage was reachable. Uptime monitors, link previewers and
|
|
226
|
+
// feed readers are refused the same way.
|
|
227
|
+
//
|
|
228
|
+
// It survives as an advisory TAG, so an operator can still rate-limit or
|
|
229
|
+
// log on it. Automation libraries remain blocked by the User-Agent
|
|
230
|
+
// deny-list, which is what actually distinguishes them; separating a
|
|
231
|
+
// crawler from an abuser is a rate-limiting question, not a header one.
|
|
232
|
+
if (mode === "tag" && !headers["accept-language"]) return "missing-accept-language";
|
|
210
233
|
// Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
|
|
211
234
|
// browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
|
|
212
235
|
// and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
|
|
213
236
|
// reverse proxies), so a 403 on it alone refuses real users. It survives
|
|
214
237
|
// only as an advisory TAG in mode:"tag", and even then only in a secure
|
|
215
238
|
// context where a modern browser would have sent it. Drive-by bots are
|
|
216
|
-
// still blocked by
|
|
239
|
+
// still blocked by the User-Agent deny-list.
|
|
217
240
|
if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
|
|
218
241
|
return null;
|
|
219
242
|
}
|