@blamejs/core 0.18.40 → 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 +18 -0
- package/README.md +1 -1
- 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/csrf-protect.js +45 -51
- package/lib/migrations.js +22 -22
- package/lib/seeders.js +15 -15
- package/lib/session.js +99 -8
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,24 @@ 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
|
+
|
|
11
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.
|
|
12
30
|
|
|
13
31
|
The walk is now implemented, including the spec's denial-of-service bound and its rules for choosing the Organizational Domain.
|
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`)
|
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);
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
* }
|
|
70
70
|
*/
|
|
71
71
|
var C = require("../constants");
|
|
72
|
+
var cookies = require("../cookies");
|
|
72
73
|
var lazyRequire = require("../lazy-require");
|
|
73
74
|
var pick = require("../pick");
|
|
74
75
|
var forms = require("../forms");
|
|
@@ -130,33 +131,6 @@ function _parseCookieHeader(header) {
|
|
|
130
131
|
return Object.assign(Object.create(null), Object.fromEntries(pairs));
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
function _formatSetCookie(name, value, opts) {
|
|
134
|
-
var parts = [name + "=" + value];
|
|
135
|
-
parts.push("Path=" + (opts.path || "/"));
|
|
136
|
-
parts.push("SameSite=" + (opts.sameSite || "Lax"));
|
|
137
|
-
if (opts.httpOnly) parts.push("HttpOnly");
|
|
138
|
-
if (opts.secure) parts.push("Secure");
|
|
139
|
-
if (opts.maxAge != null) parts.push("Max-Age=" + opts.maxAge);
|
|
140
|
-
return parts.join("; ");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function _appendSetCookie(res, value) {
|
|
144
|
-
// Don't clobber other Set-Cookie headers the route may have already
|
|
145
|
-
// queued (login session cookie, etc.). Use res.appendHeader when
|
|
146
|
-
// available; else array-merge manually.
|
|
147
|
-
if (typeof res.appendHeader === "function") {
|
|
148
|
-
res.appendHeader("Set-Cookie", value);
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
var existing = typeof res.getHeader === "function" ? res.getHeader("Set-Cookie") : undefined;
|
|
152
|
-
if (existing == null) {
|
|
153
|
-
res.setHeader("Set-Cookie", value);
|
|
154
|
-
} else if (Array.isArray(existing)) {
|
|
155
|
-
res.setHeader("Set-Cookie", existing.concat(value));
|
|
156
|
-
} else {
|
|
157
|
-
res.setHeader("Set-Cookie", [existing, value]);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
134
|
|
|
161
135
|
// csrf-protect does NOT buffer or parse the request body itself.
|
|
162
136
|
// Operators who use form-urlencoded POSTs MUST register
|
|
@@ -431,30 +405,44 @@ function create(opts) {
|
|
|
431
405
|
if (["Lax", "Strict", "None"].indexOf(cookieCfg.sameSite) === -1) {
|
|
432
406
|
throw new Error("middleware.csrfProtect: opts.cookie.sameSite must be Lax|Strict|None");
|
|
433
407
|
}
|
|
434
|
-
// Cookie name-prefix safety (RFC 6265bis §4.1.3).
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
// `
|
|
441
|
-
//
|
|
442
|
-
//
|
|
408
|
+
// Cookie name-prefix safety (RFC 6265bis §4.1.3). b.cookies.serialize
|
|
409
|
+
// enforces the same invariants when the header is built, but that is a
|
|
410
|
+
// per-request throw inside the middleware; catching the bad configuration
|
|
411
|
+
// at boot turns it into a startup error the operator can read. §5.4
|
|
412
|
+
// requires user agents to apply the prefix test case-INSENSITIVELY (the
|
|
413
|
+
// server-side §4.1.3 description reads "case-sensitive", but the UA is what
|
|
414
|
+
// drops the cookie), so `__host-`/`__SECURE-` get the same browser
|
|
415
|
+
// enforcement as `__Host-`/`__Secure-` -- case-sensitive matching was itself
|
|
416
|
+
// CVE-2024-5699. Compare a lowercased copy so a case-variant name can't
|
|
417
|
+
// dodge the invariant here and then be silently rejected by the browser.
|
|
443
418
|
// __Host-* — Path must be "/", no Domain (we never set one), Secure.
|
|
444
419
|
// __Secure-* — Secure.
|
|
445
420
|
if (cookieCfg.name) {
|
|
446
421
|
var lowerCookieName = cookieCfg.name.toLowerCase();
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
422
|
+
var isHostPrefix = lowerCookieName.indexOf("__host-") === 0;
|
|
423
|
+
var isSecurePrefix = lowerCookieName.indexOf("__secure-") === 0;
|
|
424
|
+
if (isHostPrefix && cookieCfg.path !== "/") {
|
|
425
|
+
throw new Error("middleware.csrfProtect: __Host-* cookie name requires path='/'");
|
|
426
|
+
}
|
|
427
|
+
if (isHostPrefix && cookieCfg.secure === false) {
|
|
428
|
+
throw new Error("middleware.csrfProtect: __Host-* cookie name requires secure (cannot be explicit false)");
|
|
429
|
+
}
|
|
430
|
+
if (isSecurePrefix && cookieCfg.secure === false) {
|
|
431
|
+
throw new Error("middleware.csrfProtect: __Secure-* cookie name requires secure (cannot be explicit false)");
|
|
432
|
+
}
|
|
433
|
+
// A prefixed name is a promise to the browser that the cookie is always
|
|
434
|
+
// Secure. Leaving `secure` to per-request auto-detection breaks that
|
|
435
|
+
// promise on any cleartext request: the cookie goes out prefixed and
|
|
436
|
+
// WITHOUT Secure, the browser drops it, and the double-submit token
|
|
437
|
+
// silently never persists. The name and the auto-detect cannot both
|
|
438
|
+
// stand — the operator picking the prefix is the one asserting HTTPS,
|
|
439
|
+
// so make them say it.
|
|
440
|
+
if ((isHostPrefix || isSecurePrefix) && cookieCfg.secure == null) {
|
|
441
|
+
throw new Error("middleware.csrfProtect: " +
|
|
442
|
+
(isHostPrefix ? "__Host-*" : "__Secure-*") +
|
|
443
|
+
" cookie name requires an explicit cookie.secure: true — " +
|
|
444
|
+
"auto-detected secure emits the prefix without Secure on a plain-HTTP " +
|
|
445
|
+
"request, which browsers reject");
|
|
458
446
|
}
|
|
459
447
|
}
|
|
460
448
|
}
|
|
@@ -485,8 +473,8 @@ function create(opts) {
|
|
|
485
473
|
function _issueIfNeeded(req, res) {
|
|
486
474
|
if (!cookieCfg) return null;
|
|
487
475
|
var cookieName = _resolveCookieName(req);
|
|
488
|
-
var
|
|
489
|
-
var existing =
|
|
476
|
+
var requestCookies = _parseCookieHeader(req.headers && req.headers.cookie);
|
|
477
|
+
var existing = requestCookies[cookieName];
|
|
490
478
|
// Strict 64-hex-char check matches the byte-length of every token
|
|
491
479
|
// forms.generateCsrfToken() produces (CSRF_TOKEN_BYTES = 32 bytes
|
|
492
480
|
// → 64 hex chars). The previous {2,} floor accepted any 2-char
|
|
@@ -522,14 +510,20 @@ function create(opts) {
|
|
|
522
510
|
} catch (_e) { /* drop-silent */ }
|
|
523
511
|
}
|
|
524
512
|
var fresh = forms.generateCsrfToken();
|
|
525
|
-
|
|
513
|
+
// b.cookies owns Set-Cookie: it validates the name as an RFC 6265 token,
|
|
514
|
+
// refuses CRLF / NUL in the value, scrubs the Path and Domain attributes
|
|
515
|
+
// before they reach a response header, and enforces the RFC 6265bis
|
|
516
|
+
// prefix invariants. The token is 64 hex characters, so the percent-
|
|
517
|
+
// encoding serialize applies to the value is a no-op on it and the cookie
|
|
518
|
+
// the browser echoes back still matches the double-submit compare.
|
|
519
|
+
var setCookie = cookies.serialize(cookieName, fresh, {
|
|
526
520
|
path: cookieCfg.path,
|
|
527
521
|
sameSite: cookieCfg.sameSite,
|
|
528
522
|
secure: cookieCfg.secure == null ? _isHttps(req) : !!cookieCfg.secure,
|
|
529
523
|
httpOnly: cookieCfg.httpOnly,
|
|
530
524
|
maxAge: cookieCfg.maxAge,
|
|
531
525
|
});
|
|
532
|
-
|
|
526
|
+
cookies.appendSetCookie(res, setCookie);
|
|
533
527
|
req._csrfIssuedCookies[cookieName] = fresh;
|
|
534
528
|
req.csrfToken = fresh;
|
|
535
529
|
return fresh;
|
package/lib/migrations.js
CHANGED
|
@@ -297,12 +297,12 @@ function create(opts) {
|
|
|
297
297
|
var dir = opts.dir;
|
|
298
298
|
|
|
299
299
|
function _appliedRows() {
|
|
300
|
-
var
|
|
301
|
-
_ensureTable(
|
|
302
|
-
var q = sql.select(_migrationsTable(), _sqlOpts(
|
|
300
|
+
var conn = _resolveDb(opts);
|
|
301
|
+
_ensureTable(conn);
|
|
302
|
+
var q = sql.select(_migrationsTable(), _sqlOpts(conn))
|
|
303
303
|
.columns(["name", "description", "appliedAt"])
|
|
304
304
|
.orderBy("appliedAt", "asc").orderBy("name", "asc").toSql();
|
|
305
|
-
var stmt =
|
|
305
|
+
var stmt = conn.prepare(q.sql);
|
|
306
306
|
return stmt.all.apply(stmt, q.params);
|
|
307
307
|
}
|
|
308
308
|
|
|
@@ -319,11 +319,11 @@ function create(opts) {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
function up() {
|
|
322
|
-
var
|
|
323
|
-
_ensureTable(
|
|
324
|
-
return _withLock(
|
|
325
|
-
var namesQ = sql.select(_migrationsTable(), _sqlOpts(
|
|
326
|
-
var namesStmt =
|
|
322
|
+
var conn = _resolveDb(opts);
|
|
323
|
+
_ensureTable(conn);
|
|
324
|
+
return _withLock(conn, opts, function () {
|
|
325
|
+
var namesQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"]).toSql();
|
|
326
|
+
var namesStmt = conn.prepare(namesQ.sql);
|
|
327
327
|
var appliedSet = new Set(
|
|
328
328
|
namesStmt.all.apply(namesStmt, namesQ.params)
|
|
329
329
|
.map(function (r) { return r.name; })
|
|
@@ -336,12 +336,12 @@ function create(opts) {
|
|
|
336
336
|
if (appliedSet.has(file)) { skipped.push(file); continue; }
|
|
337
337
|
var mod = _loadMigration(file, dir);
|
|
338
338
|
try {
|
|
339
|
-
_txn(
|
|
340
|
-
mod.up(
|
|
341
|
-
var insQ = sql.insert(_migrationsTable(), _sqlOpts(
|
|
339
|
+
_txn(conn, function () {
|
|
340
|
+
mod.up(conn);
|
|
341
|
+
var insQ = sql.insert(_migrationsTable(), _sqlOpts(conn))
|
|
342
342
|
.values({ name: file, description: mod.description || "",
|
|
343
343
|
appliedAt: new Date().toISOString() }).toSql();
|
|
344
|
-
var insStmt =
|
|
344
|
+
var insStmt = conn.prepare(insQ.sql);
|
|
345
345
|
insStmt.run.apply(insStmt, insQ.params);
|
|
346
346
|
});
|
|
347
347
|
} catch (e) {
|
|
@@ -363,16 +363,16 @@ function create(opts) {
|
|
|
363
363
|
"down: steps must be a positive integer (got " + opts2.steps + ")",
|
|
364
364
|
true);
|
|
365
365
|
}
|
|
366
|
-
var
|
|
367
|
-
_ensureTable(
|
|
368
|
-
return _withLock(
|
|
366
|
+
var conn = _resolveDb(opts);
|
|
367
|
+
_ensureTable(conn);
|
|
368
|
+
return _withLock(conn, opts, function () {
|
|
369
369
|
// Most-recent applied first (reverse chronological by appliedAt
|
|
370
370
|
// then by name as a stable tiebreaker for fixtures with identical
|
|
371
371
|
// timestamps). steps is a validated positive integer, so b.sql
|
|
372
372
|
// inlines the LIMIT.
|
|
373
|
-
var downQ = sql.select(_migrationsTable(), _sqlOpts(
|
|
373
|
+
var downQ = sql.select(_migrationsTable(), _sqlOpts(conn)).columns(["name"])
|
|
374
374
|
.orderBy("appliedAt", "desc").orderBy("name", "desc").limit(steps).toSql();
|
|
375
|
-
var downStmt =
|
|
375
|
+
var downStmt = conn.prepare(downQ.sql);
|
|
376
376
|
var rows = downStmt.all.apply(downStmt, downQ.params);
|
|
377
377
|
|
|
378
378
|
var reverted = [];
|
|
@@ -386,10 +386,10 @@ function create(opts) {
|
|
|
386
386
|
true);
|
|
387
387
|
}
|
|
388
388
|
try {
|
|
389
|
-
_txn(
|
|
390
|
-
mod.down(
|
|
391
|
-
var delQ = sql.delete(_migrationsTable(), _sqlOpts(
|
|
392
|
-
var delStmt =
|
|
389
|
+
_txn(conn, function () {
|
|
390
|
+
mod.down(conn);
|
|
391
|
+
var delQ = sql.delete(_migrationsTable(), _sqlOpts(conn)).where("name", file).toSql();
|
|
392
|
+
var delStmt = conn.prepare(delQ.sql);
|
|
393
393
|
delStmt.run.apply(delStmt, delQ.params);
|
|
394
394
|
});
|
|
395
395
|
} catch (e) {
|
package/lib/seeders.js
CHANGED
|
@@ -452,11 +452,11 @@ function create(opts) {
|
|
|
452
452
|
function status(callerOpts) {
|
|
453
453
|
callerOpts = callerOpts || {};
|
|
454
454
|
_validateEnv("seeders.status: env", callerOpts.env);
|
|
455
|
-
var
|
|
456
|
-
_ensureTables(
|
|
455
|
+
var conn = _resolveDb(opts);
|
|
456
|
+
_ensureTables(conn);
|
|
457
457
|
var env = callerOpts.env;
|
|
458
458
|
var loaded = _loadAllForEnv(dir, env);
|
|
459
|
-
var applied = _appliedRows(
|
|
459
|
+
var applied = _appliedRows(conn, env);
|
|
460
460
|
var appliedNames = new Set(applied.map(function (r) { return r.name; }));
|
|
461
461
|
var pending = loaded.ordered.filter(function (n) {
|
|
462
462
|
var mod = loaded.modByName[n];
|
|
@@ -491,8 +491,8 @@ function create(opts) {
|
|
|
491
491
|
}
|
|
492
492
|
}
|
|
493
493
|
|
|
494
|
-
var
|
|
495
|
-
_ensureTables(
|
|
494
|
+
var conn = _resolveDb(opts);
|
|
495
|
+
_ensureTables(conn);
|
|
496
496
|
|
|
497
497
|
var loaded = _loadAllForEnv(dir, env);
|
|
498
498
|
|
|
@@ -504,11 +504,11 @@ function create(opts) {
|
|
|
504
504
|
var startedAt = clock();
|
|
505
505
|
observability().safeEvent("seeders.run.start", 1, { env: env, count: loaded.ordered.length });
|
|
506
506
|
|
|
507
|
-
var holder = _acquireLock(
|
|
507
|
+
var holder = _acquireLock(conn, lockStaleAfterMs, clock);
|
|
508
508
|
try {
|
|
509
|
-
var appliedSelBuilt = sql.select(_seedersTable(), _sqlOpts(
|
|
509
|
+
var appliedSelBuilt = sql.select(_seedersTable(), _sqlOpts(conn))
|
|
510
510
|
.columns(["name"]).where("env", env).toSql();
|
|
511
|
-
var appliedSelStmt =
|
|
511
|
+
var appliedSelStmt = conn.prepare(appliedSelBuilt.sql);
|
|
512
512
|
var appliedSet = new Set(
|
|
513
513
|
appliedSelStmt.all.apply(appliedSelStmt, appliedSelBuilt.params)
|
|
514
514
|
.map(function (r) { return r.name; })
|
|
@@ -539,26 +539,26 @@ function create(opts) {
|
|
|
539
539
|
// Per-seed transaction: SQLite txns are sync, but the seed's
|
|
540
540
|
// run() may be async — runInTransactionAsync wraps BEGIN/COMMIT
|
|
541
541
|
// around the awaited body and rolls back this seed only on failure.
|
|
542
|
-
await dbSchema.runInTransactionAsync(
|
|
543
|
-
await mod.run(
|
|
542
|
+
await dbSchema.runInTransactionAsync(conn, async function () {
|
|
543
|
+
await mod.run(conn, ctx);
|
|
544
544
|
var nowIso = new Date(clock()).toISOString();
|
|
545
545
|
var writeBuilt;
|
|
546
546
|
if (alreadyApplied && mod.rerunnable) {
|
|
547
|
-
writeBuilt = sql.update(_seedersTable(), _sqlOpts(
|
|
547
|
+
writeBuilt = sql.update(_seedersTable(), _sqlOpts(conn))
|
|
548
548
|
.set({ appliedAt: nowIso, description: mod.description || "",
|
|
549
549
|
rerunnable: mod.rerunnable ? 1 : 0 })
|
|
550
550
|
.where("env", env).where("name", name).toSql();
|
|
551
551
|
} else if (alreadyApplied && force) {
|
|
552
|
-
writeBuilt = sql.update(_seedersTable(), _sqlOpts(
|
|
552
|
+
writeBuilt = sql.update(_seedersTable(), _sqlOpts(conn))
|
|
553
553
|
.set({ appliedAt: nowIso, description: mod.description || "" })
|
|
554
554
|
.where("env", env).where("name", name).toSql();
|
|
555
555
|
} else {
|
|
556
|
-
writeBuilt = sql.insert(_seedersTable(), _sqlOpts(
|
|
556
|
+
writeBuilt = sql.insert(_seedersTable(), _sqlOpts(conn))
|
|
557
557
|
.values({ env: env, name: name, description: mod.description || "",
|
|
558
558
|
appliedAt: nowIso, rerunnable: mod.rerunnable ? 1 : 0 })
|
|
559
559
|
.toSql();
|
|
560
560
|
}
|
|
561
|
-
var writeStmt =
|
|
561
|
+
var writeStmt = conn.prepare(writeBuilt.sql);
|
|
562
562
|
writeStmt.run.apply(writeStmt, writeBuilt.params);
|
|
563
563
|
}, {
|
|
564
564
|
onRollbackFail: function (rollbackErr) {
|
|
@@ -629,7 +629,7 @@ function create(opts) {
|
|
|
629
629
|
}
|
|
630
630
|
return result;
|
|
631
631
|
} finally {
|
|
632
|
-
_releaseLock(
|
|
632
|
+
_releaseLock(conn, holder);
|
|
633
633
|
}
|
|
634
634
|
}
|
|
635
635
|
|
package/lib/session.js
CHANGED
|
@@ -54,6 +54,7 @@ var validateOpts = require("./validate-opts");
|
|
|
54
54
|
var cluster = require("./cluster");
|
|
55
55
|
var clusterStorage = require("./cluster-storage");
|
|
56
56
|
var C = require("./constants");
|
|
57
|
+
var cookies = require("./cookies");
|
|
57
58
|
var { generateToken, sha3Hash } = require("./crypto");
|
|
58
59
|
var cryptoField = require("./crypto-field");
|
|
59
60
|
var frameworkSchema = require("./framework-schema");
|
|
@@ -410,7 +411,9 @@ function _hashFingerprint(sid, inputs) {
|
|
|
410
411
|
* data: { roles: ["admin"] },
|
|
411
412
|
* ttlMs: b.constants.TIME.hours(8),
|
|
412
413
|
* });
|
|
413
|
-
*
|
|
414
|
+
* b.cookies.appendSetCookie(res, b.cookies.serialize("sid", s.token, {
|
|
415
|
+
* httpOnly: true, secure: true, sameSite: "Strict", path: "/",
|
|
416
|
+
* }));
|
|
414
417
|
* // → { token: "9f2c…", expiresAt: 1735689600000 }
|
|
415
418
|
*/
|
|
416
419
|
// Anonymous-session prefix. b.session.create({ anonymous: true })
|
|
@@ -770,7 +773,9 @@ async function verify(token, verifyOpts) {
|
|
|
770
773
|
*
|
|
771
774
|
* @example
|
|
772
775
|
* await b.session.destroy(req.cookies.sid);
|
|
773
|
-
* res.
|
|
776
|
+
* b.cookies.appendSetCookie(res, b.cookies.serialize("sid", "", {
|
|
777
|
+
* httpOnly: true, sameSite: "Strict", path: "/", maxAge: 0,
|
|
778
|
+
* }));
|
|
774
779
|
* res.end("logged out");
|
|
775
780
|
* // → true
|
|
776
781
|
*/
|
|
@@ -798,13 +803,29 @@ async function destroy(token) {
|
|
|
798
803
|
* this composes the secure-default logout the middleware otherwise had to be
|
|
799
804
|
* mounted by hand. Returns whether a session was destroyed. Leader-only.
|
|
800
805
|
*
|
|
806
|
+
* A browser deletes a cookie by MATCHING the expiry cookie's name, path and
|
|
807
|
+
* domain against the one in its jar, and it refuses a `Secure` cookie
|
|
808
|
+
* altogether when the response came over plain HTTP. So the expiry cookie has
|
|
809
|
+
* to describe the same scope the session cookie was written with, or the
|
|
810
|
+
* logout leaves it in place. Pass the `req` and the scheme is resolved through
|
|
811
|
+
* `b.requestHelpers.trustedProtocol` (a forwarded scheme counts only from a
|
|
812
|
+
* peer you declared trusted); pass `secure` to state it outright. With neither,
|
|
813
|
+
* the cookie is `Secure` — the secure default, unchanged.
|
|
814
|
+
*
|
|
801
815
|
* @opts
|
|
802
|
-
* cookieName:
|
|
803
|
-
* types:
|
|
816
|
+
* cookieName: string, // default: "sid" — the session cookie to expire
|
|
817
|
+
* types: string[], // default: the W3C Clear-Site-Data directive set
|
|
818
|
+
* req: object, // resolve Secure from this request's scheme
|
|
819
|
+
* secure: boolean, // default: true — state the scheme outright
|
|
820
|
+
* sameSite: string, // default: "Strict" — Strict / Lax / None
|
|
821
|
+
* path: string, // default: "/" — must match the cookie's Path
|
|
822
|
+
* domain: string, // must match the cookie's Domain, if it had one
|
|
823
|
+
* trustedProxies: string | string[], // CIDRs, for the `req` scheme resolve
|
|
824
|
+
* protocolResolver: function(req), // own the scheme decision instead
|
|
804
825
|
*
|
|
805
826
|
* @example
|
|
806
827
|
* app.post("/logout", async function (req, res) {
|
|
807
|
-
* await b.session.logout(res, req.cookies.sid);
|
|
828
|
+
* await b.session.logout(res, req.cookies.sid, { req: req });
|
|
808
829
|
* res.end("logged out");
|
|
809
830
|
* });
|
|
810
831
|
* // → emits Clear-Site-Data + expires the sid cookie + destroys the session
|
|
@@ -814,7 +835,19 @@ async function logout(res, token, opts) {
|
|
|
814
835
|
throw new SessionError("session/bad-res",
|
|
815
836
|
"b.session.logout: res must be an HTTP response with setHeader()");
|
|
816
837
|
}
|
|
838
|
+
// The expiry cookie is queued with b.cookies.appendSetCookie, which needs to
|
|
839
|
+
// READ the response as well as write it. Assert that contract here, with the
|
|
840
|
+
// other validation, rather than discovering it at queue time: the queue
|
|
841
|
+
// happens after destroy(), so a throw there would leave the session revoked,
|
|
842
|
+
// Clear-Site-Data queued, no expiry cookie and a failed request. The
|
|
843
|
+
// response's shape is the caller's and fixed for the process, so it is
|
|
844
|
+
// knowable before any of that.
|
|
845
|
+
cookies.assertAppendable(res);
|
|
817
846
|
opts = opts || {};
|
|
847
|
+
validateOpts(opts, [
|
|
848
|
+
"cookieName", "types", "req", "secure", "sameSite", "path", "domain",
|
|
849
|
+
"trustedProxies", "protocolResolver",
|
|
850
|
+
], "b.session.logout");
|
|
818
851
|
var cookieName = opts.cookieName === undefined ? "sid" : opts.cookieName;
|
|
819
852
|
if (typeof cookieName !== "string" || cookieName.length === 0) {
|
|
820
853
|
throw new SessionError("session/bad-cookie-name",
|
|
@@ -826,6 +859,19 @@ async function logout(res, token, opts) {
|
|
|
826
859
|
// unknown directive throws here, queuing nothing.
|
|
827
860
|
var clearSiteDataValue = csd.headerValue(types, "b.session.logout");
|
|
828
861
|
|
|
862
|
+
// Same ordering for the cookie: b.cookies.serialize validates the name, the
|
|
863
|
+
// attributes and the RFC 6265bis prefix invariants, so it is a throwing call
|
|
864
|
+
// and must run before the row is revoked — otherwise a `__Host-` typo leaves
|
|
865
|
+
// the session destroyed and the browser still holding its cookie.
|
|
866
|
+
var expiryCookie = cookies.serialize(cookieName, "", {
|
|
867
|
+
httpOnly: true,
|
|
868
|
+
secure: _logoutCookieSecure(opts, cookieName),
|
|
869
|
+
sameSite: opts.sameSite === undefined ? "Strict" : opts.sameSite,
|
|
870
|
+
path: opts.path === undefined ? "/" : opts.path,
|
|
871
|
+
domain: opts.domain,
|
|
872
|
+
maxAge: 0,
|
|
873
|
+
});
|
|
874
|
+
|
|
829
875
|
// Revoke the server-side session FIRST. If destroy() throws (a follower
|
|
830
876
|
// failing cluster.requireLeader(), or a store/DB error), no client-wipe
|
|
831
877
|
// headers have been queued — an error response can't then expire the
|
|
@@ -836,12 +882,55 @@ async function logout(res, token, opts) {
|
|
|
836
882
|
// Now wipe the client-side state: W3C Clear-Site-Data (cookies /
|
|
837
883
|
// storage / cache) + expire the session cookie (belt-and-suspenders with the
|
|
838
884
|
// "cookies" directive, and effective even if the client ignores the header).
|
|
885
|
+
// Append rather than set: a route that already queued a cookie of its own
|
|
886
|
+
// (a rotated CSRF token, a locale) keeps it.
|
|
839
887
|
res.setHeader("Clear-Site-Data", clearSiteDataValue);
|
|
840
|
-
|
|
841
|
-
cookieName + "=; Path=/; HttpOnly; Secure; SameSite=Strict; Max-Age=0");
|
|
888
|
+
cookies.appendSetCookie(res, expiryCookie);
|
|
842
889
|
return destroyed;
|
|
843
890
|
}
|
|
844
891
|
|
|
892
|
+
// Whether logout's expiry cookie carries Secure. Explicit `secure` wins; a
|
|
893
|
+
// `req` resolves through the peer-gated protocol helper; with neither the
|
|
894
|
+
// answer is the secure default. A browser drops a Secure cookie arriving over
|
|
895
|
+
// plain HTTP, so answering "true" for an HTTP deployment does not fail safe —
|
|
896
|
+
// it fails to clear the cookie at all.
|
|
897
|
+
function _logoutCookieSecure(opts, cookieName) {
|
|
898
|
+
if (opts.secure !== undefined) {
|
|
899
|
+
if (typeof opts.secure !== "boolean") {
|
|
900
|
+
throw new SessionError("session/bad-secure",
|
|
901
|
+
"b.session.logout: opts.secure must be a boolean");
|
|
902
|
+
}
|
|
903
|
+
// An explicit `false` against a `__Host-`/`__Secure-` name is a
|
|
904
|
+
// contradiction, and serialize() refuses it. That is deliberate: the
|
|
905
|
+
// operator stated both halves, it fails on the first logout in every
|
|
906
|
+
// environment, and no request can provoke or avoid it.
|
|
907
|
+
return opts.secure;
|
|
908
|
+
}
|
|
909
|
+
// A `__Host-` / `__Secure-` name is a statement about the COOKIE, not about
|
|
910
|
+
// this request: a browser will only ever have stored such a cookie on a
|
|
911
|
+
// secure origin, so on a cleartext request there is nothing of that name to
|
|
912
|
+
// clear. Letting the request's scheme resolve `secure` to false here would
|
|
913
|
+
// make serialize() refuse the name — and the cookie is built BEFORE the row
|
|
914
|
+
// is revoked, so that refusal would abort the logout entirely and whoever
|
|
915
|
+
// chose the scheme would decide whether the session died. The prefix wins.
|
|
916
|
+
var lowerName = cookieName.toLowerCase();
|
|
917
|
+
if (lowerName.indexOf("__host-") === 0 || lowerName.indexOf("__secure-") === 0) {
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
if (opts.req === undefined) return true;
|
|
921
|
+
if (opts.req === null || typeof opts.req !== "object") {
|
|
922
|
+
// trustedProtocol answers "http" for a non-request rather than throwing, so
|
|
923
|
+
// a mistyped `req` would quietly drop Secure. Refuse it instead.
|
|
924
|
+
throw new SessionError("session/bad-req",
|
|
925
|
+
"b.session.logout: opts.req must be an HTTP request object");
|
|
926
|
+
}
|
|
927
|
+
var resolver = requestHelpers.trustedProtocol({
|
|
928
|
+
trustedProxies: opts.trustedProxies,
|
|
929
|
+
protocolResolver: opts.protocolResolver,
|
|
930
|
+
});
|
|
931
|
+
return resolver.resolve(opts.req) === "https";
|
|
932
|
+
}
|
|
933
|
+
|
|
845
934
|
async function _deleteBySidHash(sidHash) {
|
|
846
935
|
var built = sql.delete(_sessionSqlTable(), _sessionSqlOpts())
|
|
847
936
|
.where("sidHash", sidHash)
|
|
@@ -1057,7 +1146,9 @@ async function touch(token, opts) {
|
|
|
1057
1146
|
* reason: "mfa",
|
|
1058
1147
|
* });
|
|
1059
1148
|
* if (rotated) {
|
|
1060
|
-
*
|
|
1149
|
+
* b.cookies.appendSetCookie(res, b.cookies.serialize("sid", rotated.token, {
|
|
1150
|
+
* httpOnly: true, secure: true, sameSite: "Strict", path: "/",
|
|
1151
|
+
* }));
|
|
1061
1152
|
* }
|
|
1062
1153
|
* // → { token: "7a1e…", expiresAt: 1735689600000 }
|
|
1063
1154
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blamejs/core",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.41",
|
|
4
4
|
"description": "The Node framework that owns its stack.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "blamejs contributors",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"check:vendor-currency": "node scripts/check-vendor-currency.js"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"esbuild": "0.28.
|
|
78
|
+
"esbuild": "0.28.2",
|
|
79
79
|
"postject": "1.0.0-alpha.6"
|
|
80
80
|
}
|
|
81
81
|
}
|
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:a9c950dd-01b0-4088-b83e-2dfd36c75675",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-20T14:26:30.885Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.18.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.18.41",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.18.
|
|
25
|
+
"version": "0.18.41",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.18.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.18.41",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.18.
|
|
57
|
+
"ref": "@blamejs/core@0.18.41",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|