@blamejs/core 0.12.66 → 0.12.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.12.x
10
10
 
11
+ - v0.12.69 (2026-05-26) — **`b.middleware.botGuard` no longer blocks browsers that omit Sec-Fetch-Mode.** b.middleware.botGuard treated a missing Sec-Fetch-Mode header as a bot signal and returned 403 Forbidden, which refused legitimate browsers on any origin where the browser does not emit Fetch Metadata: every plain-HTTP non-localhost origin (Umbrel apps, LAN and *.local reverse-proxy deployments) and Safari before 16.4 even over HTTPS. Browsers only send Sec-Fetch-* in a secure context, so its absence is normal there — not a bot. Sec-Fetch-Mode is now advisory only: it never blocks, and it sets req.suspectedBot in mode:"tag" only on a secure-context HTML GET where a modern browser would have sent it. Drive-by bots are still blocked by the missing-Accept-Language and User-Agent heuristics. No configuration change is needed; if you had widened skipPaths or disabled bot-guard to work around this, you can revert that. **Fixed:** *`b.middleware.botGuard` no longer 403s browsers over plain HTTP or older Safari* — A missing `Sec-Fetch-Mode` was a blocking heuristic, but browsers omit Fetch Metadata outside a secure context (every plain-HTTP non-localhost origin — Umbrel, LAN, `*.local` proxies) and Safari < 16.4 omits it even over HTTPS. Those legitimate browsers were refused with `403 Forbidden`. `Sec-Fetch-Mode` is now advisory: it never blocks, and only sets `req.suspectedBot` in `mode: "tag"` on a secure-context HTML GET. The `Accept-Language` and User-Agent heuristics (which catch the same bots) are unchanged. **Detectors:** *reserved-hostname trailing-dot detector recognizes regex strips* — The codebase-patterns gate that requires stripping the RFC 1034 trailing root-zone dot before a reserved-hostname comparison now also recognizes end-anchored regex strips (`.replace(/\.$/, …)`), not only the `charAt` / `while`-loop forms.
12
+
13
+ - v0.12.68 (2026-05-26) — **`b.jwk` — RFC 7638 JWK thumbprint.** Compute the RFC 7638 thumbprint of a JSON Web Key — the canonical base64url(SHA-256(canonical-JSON)) identifier used to name a key (DPoP jkt bindings, ACME account-key thumbprints, DBSC session pins, kid derivation). b.jwk.thumbprint(jwk) returns the digest; b.jwk.canonicalize(jwk) returns the exact JSON that is hashed — only the key-type's required members, member names in lexicographic order, no whitespace, so the same key always yields the same thumbprint regardless of how its JWK was serialized. The standard key types are supported (EC, RSA, oct, OKP per RFC 8037) plus AKP, the IANA key type Node uses for ML-DSA / SLH-DSA post-quantum public keys; SHA-256 is the default, with hash: "sha384" | "sha512" for RFC 9278 thumbprint-with-hash. Verified against the RFC 7638 §3.1 worked example. b.auth.dpop, b.acme, and b.dbsc now compute their thumbprints through this primitive. **Added:** *`b.jwk.thumbprint` / `b.jwk.canonicalize`* — RFC 7638 JWK thumbprint. `thumbprint(jwk, opts)` returns `base64url(hash(canonical-JSON))` — only the key-type's required members feed the hash, so optional fields (`kid`, `use`, `alg`, …) never change the result. `canonicalize(jwk)` returns the canonical JSON string itself. Supports EC / RSA / oct / OKP and the AKP post-quantum key type; SHA-256 default, `hash` selects SHA-384 / SHA-512. Throws `JwkError` on an invalid key or unknown hash. **Changed:** *DPoP, ACME, and DBSC compose `b.jwk`* — `b.auth.dpop` (the `jkt` proof-key thumbprint), `b.acme` (the RFC 8555 account-key authorization), and `b.dbsc` (the session-pin thumbprint) now compute RFC 7638 thumbprints through `b.jwk` instead of carrying their own implementations. Behavior is unchanged — DPoP still refuses symmetric key types, and each surface keeps its own error codes.
14
+
11
15
  - v0.12.66 (2026-05-26) — **`b.uriTemplate` — RFC 6570 URI Template expansion.** Expand RFC 6570 URI Templates — the {var} syntax that OpenAPI links, HAL _links, and hypermedia API clients use to turn a template plus a set of variables into a concrete URI. The full Level 4 grammar is supported: every operator ({+var} reserved, {#var} fragment, {.var} label, {/var} path, {;var} path-style parameters, {?var} query, {&var} query continuation), the {var:3} prefix modifier, and the {var*} explode modifier for lists and associative arrays. b.uriTemplate.expand(template, vars) returns the expanded string; b.uriTemplate.compile(template) parses once for templates applied to many variable sets. A malformed template (unclosed expression, reserved operator, non-numeric prefix, unmatched brace) throws UriTemplateError. Verified against the official uritemplate-test conformance suite (all 135 spec, extended, and negative cases). **Added:** *`b.uriTemplate.expand` / `b.uriTemplate.compile`* — RFC 6570 URI Template expansion, full Level 4. `expand(template, vars)` substitutes variables into a template and returns the URI; `compile(template)` returns a reusable `{ expand }` for repeated use. Variable values may be strings, numbers, booleans, arrays (lists), or plain objects (associative arrays); undefined, null, and empty list/map variables are omitted. All eight operators, the `:N` prefix modifier, and the `*` explode modifier follow §3.2, including reserved-set encoding for `{+var}` / `{#var}`. Composes naturally with `b.hal`, `b.linkHeader`, and `b.openapi` link objects. A malformed template throws `UriTemplateError`.
12
16
 
13
17
  - v0.12.65 (2026-05-26) — **`b.base32` — RFC 4648 Base32 encode / decode.** Encode and decode RFC 4648 Base32 — the case-insensitive alphabet behind TOTP / 2FA secrets, DNSSEC NSEC3 hashes, and human-transcribable identifiers. Both RFC 4648 variants are supported: the standard alphabet (the default) and the extended-hex alphabet. b.base32.encode pads to an 8-character boundary by default (pass padding: false for the bare form TOTP key URIs use); b.base32.decode is strict by default but accepts the real-world shapes humans produce — lower-case, embedded spaces and dashes, missing padding — under loose: true. Verified against the RFC 4648 §10 test vectors for both alphabets. The TOTP primitive now composes this codec instead of carrying its own Base32 implementation. **Added:** *`b.base32.encode` / `b.base32.decode`* — RFC 4648 Base32 codec. `encode(buf, opts)` takes a Buffer or Uint8Array and returns a Base32 string, padded to an 8-character boundary unless `padding: false`; `decode(str, opts)` returns a Buffer. The `variant` option selects the standard (`"rfc4648"`, default) or extended-hex (`"rfc4648-hex"`) alphabet. Decoding is strict by default — any character outside the alphabet throws `Base32Error` — and `loose: true` up-cases the input and ignores embedded spaces, dashes, and missing padding, which is how copied TOTP keys and hand-typed codes arrive. **Changed:** *TOTP composes `b.base32`* — `b.auth.totp` now encodes and decodes its secrets through `b.base32` rather than a private Base32 implementation. Behavior is unchanged — secrets are still emitted unpadded and parsed leniently (case-insensitive, ignoring spaces and dashes).
package/README.md CHANGED
@@ -71,6 +71,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
71
71
 
72
72
  - **Passwords** — Argon2id + policy primitive (`b.auth.password`); NIST 800-63B / PCI-DSS 4.0 / HIPAA-AAL2 profiles; HaveIBeenPwned k-anonymity breach check; length / context / dictionary / complexity rules; rotation + history
73
73
  - **Multi-factor + WebAuthn** — passkeys (WebAuthn), TOTP, JWT (PQ-default)
74
+ - **JWK thumbprint** — RFC 7638 `base64url(SHA-256(canonical-JSON))` key identifier (`b.jwk.thumbprint` / `canonicalize`): EC / RSA / oct / OKP + the AKP post-quantum key type, SHA-256/384/512; the canonical key name behind DPoP `jkt`, ACME account keys, and DBSC session pins
74
75
  - **OAuth / OIDC RP** — `b.auth.oauth`
75
76
  - RP-Initiated / Front-Channel / Back-Channel Logout 1.0 (`parseFrontchannelLogoutRequest` + `verifyBackchannelLogoutToken` with jti-replay defense)
76
77
  - RFC 9207 AS Issuer Identifier validation on callbacks (`parseCallback` — refuses iss mismatch + OP `error=` redirect)
package/index.js CHANGED
@@ -406,6 +406,7 @@ var jtd = require("./lib/jtd");
406
406
  var jsonSchema = require("./lib/json-schema");
407
407
  var base32 = require("./lib/base32");
408
408
  var uriTemplate = require("./lib/uri-template");
409
+ var jwk = require("./lib/jwk");
409
410
  var standardWebhooks = require("./lib/standard-webhooks");
410
411
  var lro = require("./lib/lro");
411
412
  var jsonApi = require("./lib/jsonapi");
@@ -433,6 +434,7 @@ module.exports = {
433
434
  jsonSchema: jsonSchema,
434
435
  base32: base32,
435
436
  uriTemplate: uriTemplate,
437
+ jwk: jwk,
436
438
  standardWebhooks: standardWebhooks,
437
439
  lro: lro,
438
440
  jsonApi: jsonApi,
package/lib/acme.js CHANGED
@@ -45,6 +45,7 @@ var nodeCrypto = require("node:crypto");
45
45
 
46
46
  var C = require("./constants");
47
47
  var asn1 = require("./asn1-der");
48
+ var jwk = require("./jwk");
48
49
  var safeUrl = require("./safe-url");
49
50
  var safeJson = require("./safe-json");
50
51
  var validateOpts = require("./validate-opts");
@@ -114,8 +115,7 @@ function _publicJwkFromKeyObject(keyObject) {
114
115
 
115
116
  function _jwkThumbprint(publicJwk) {
116
117
  // RFC 7638 §3 — base64url(SHA-256(canonical JSON of required members)).
117
- var canon = JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y });
118
- return _b64u(nodeCrypto.createHash("sha256").update(canon).digest());
118
+ return jwk.thumbprint(publicJwk);
119
119
  }
120
120
 
121
121
  function _signJws(privateKey, protectedHeader, payload) {
package/lib/auth/dpop.js CHANGED
@@ -28,6 +28,7 @@
28
28
 
29
29
  var nodeCrypto = require("node:crypto");
30
30
  var bCrypto = require("../crypto");
31
+ var jwk = require("../jwk");
31
32
  var safeJson = require("../safe-json");
32
33
  var safeUrl = require("../safe-url");
33
34
  var validateOpts = require("../validate-opts");
@@ -84,52 +85,21 @@ function _b64urlDecode(s) {
84
85
  }
85
86
  }
86
87
 
87
- // Canonical JWK per RFC 7638 keys present in lexicographic order,
88
- // only the kty-defined "required" members. Used for thumbprint.
89
- function _canonicalJwk(jwk) {
90
- if (!jwk || typeof jwk !== "object") {
91
- throw new AuthError("auth-dpop/bad-jwk", "jwk must be an object");
92
- }
93
- if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
94
- throw new AuthError("auth-dpop/bad-jwk", "jwk.kty is required");
95
- }
96
- if (jwk.kty === "EC") {
97
- if (typeof jwk.crv !== "string" || typeof jwk.x !== "string" || typeof jwk.y !== "string") {
98
- throw new AuthError("auth-dpop/bad-jwk", "EC jwk requires crv, x, y");
99
- }
100
- return JSON.stringify({ crv: jwk.crv, kty: "EC", x: jwk.x, y: jwk.y });
88
+ // Asymmetric key types DPoP accepts (its proof model relies on a
89
+ // signature, so symmetric "oct" keys are refused). AKP is the IANA key
90
+ // type for ML-DSA / SLH-DSA PQC public keys.
91
+ var DPOP_KTY = { EC: 1, OKP: 1, RSA: 1, AKP: 1 };
92
+
93
+ function thumbprint(key) {
94
+ if (!key || typeof key !== "object" || typeof key.kty !== "string" || key.kty.length === 0) {
95
+ throw new AuthError("auth-dpop/bad-jwk", "jwk must be an object with a kty");
101
96
  }
102
- if (jwk.kty === "OKP") {
103
- if (typeof jwk.crv !== "string" || typeof jwk.x !== "string") {
104
- throw new AuthError("auth-dpop/bad-jwk", "OKP jwk requires crv, x");
105
- }
106
- return JSON.stringify({ crv: jwk.crv, kty: "OKP", x: jwk.x });
97
+ if (!DPOP_KTY[key.kty]) {
98
+ throw new AuthError("auth-dpop/refused-kty", "jwk.kty='" + key.kty + "' is not allowed (DPoP requires asymmetric kty)");
107
99
  }
108
- if (jwk.kty === "RSA") {
109
- if (typeof jwk.e !== "string" || typeof jwk.n !== "string") {
110
- throw new AuthError("auth-dpop/bad-jwk", "RSA jwk requires e, n");
111
- }
112
- return JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n });
113
- }
114
- if (jwk.kty === "AKP") {
115
- // PQC asymmetric key package (draft-ietf-cose-cnsa-pqc / IANA AKP
116
- // registry). Node:crypto exports ML-DSA / SLH-DSA public keys with
117
- // kty=AKP, alg=<algId>, pub=<base64url public bytes>.
118
- if (typeof jwk.alg !== "string" || typeof jwk.pub !== "string") {
119
- throw new AuthError("auth-dpop/bad-jwk", "AKP jwk requires alg, pub");
120
- }
121
- return JSON.stringify({ alg: jwk.alg, kty: "AKP", pub: jwk.pub });
122
- }
123
- // Symmetric keys (oct) and any other kty are refused outright — DPoP's
124
- // proof model requires asymmetric.
125
- throw new AuthError("auth-dpop/refused-kty",
126
- "jwk.kty='" + jwk.kty + "' is not allowed (DPoP requires asymmetric kty)");
127
- }
128
-
129
- function thumbprint(jwk) {
130
- var canonical = _canonicalJwk(jwk);
131
- var hash = nodeCrypto.createHash("sha256").update(canonical, "utf8").digest();
132
- return _b64urlEncode(hash);
100
+ // The RFC 7638 thumbprint itself is computed by b.jwk.
101
+ try { return jwk.thumbprint(key); }
102
+ catch (e) { throw new AuthError("auth-dpop/bad-jwk", (e && e.message) || "invalid jwk"); }
133
103
  }
134
104
 
135
105
  function _sha256B64Url(input) {
package/lib/dbsc.js CHANGED
@@ -36,7 +36,7 @@ var nodeCrypto = require("node:crypto");
36
36
  var validateOpts = require("./validate-opts");
37
37
  var safeJson = require("./safe-json");
38
38
  var bCrypto = require("./crypto");
39
- var canonicalJson = require("./canonical-json");
39
+ var jwk = require("./jwk");
40
40
  var jwtExternal = require("./auth/jwt-external");
41
41
  var C = require("./constants");
42
42
  var { defineClass } = require("./framework-error");
@@ -272,23 +272,10 @@ function _trimLeadingZeros(buf) {
272
272
  return buf.slice(i);
273
273
  }
274
274
 
275
- function _jwkThumbprint(jwk) {
276
- // RFC 7638 canonical thumbprint: alphabetic key-name ordering + no
277
- // whitespace + SHA-256 + base64url. For EC P-256 the required keys
278
- // are { crv, kty, x, y }.
279
- var members;
280
- if (jwk.kty === "EC") {
281
- members = { crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y };
282
- } else if (jwk.kty === "RSA") {
283
- members = { e: jwk.e, kty: jwk.kty, n: jwk.n };
284
- } else {
285
- throw new DbscError("dbsc/bad-jwk-kty",
286
- "jwkThumbprint: unsupported kty " + jwk.kty);
287
- }
288
- var canonical = canonicalJson.stringify(members);
289
- return bCrypto.toBase64Url(
290
- nodeCrypto.createHash("sha256").update(Buffer.from(canonical, "utf8")).digest()
291
- );
275
+ function _jwkThumbprint(key) {
276
+ // RFC 7638 thumbprint (base64url(SHA-256(canonical JWK))) via b.jwk.
277
+ try { return jwk.thumbprint(key); }
278
+ catch (e) { throw new DbscError("dbsc/bad-jwk-kty", "jwkThumbprint: " + ((e && e.message) || "invalid jwk")); }
292
279
  }
293
280
 
294
281
  module.exports = {
package/lib/jwk.js ADDED
@@ -0,0 +1,127 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.jwk
4
+ * @nav Identity
5
+ * @title JWK Thumbprint
6
+ *
7
+ * @intro
8
+ * Compute the <a href="https://www.rfc-editor.org/rfc/rfc7638">RFC 7638</a>
9
+ * thumbprint of a JSON Web Key — the canonical, hash-based identifier used
10
+ * to name a key (DPoP <code>jkt</code> bindings, ACME account-key
11
+ * thumbprints per RFC 8555, DBSC session pins, and <code>kid</code>
12
+ * derivation). The thumbprint is
13
+ * <code>base64url(SHA-256(canonical-JSON))</code>, where the canonical
14
+ * JSON contains only the key-type's required members, with member names
15
+ * in lexicographic order and no whitespace — so the same key always
16
+ * produces the same thumbprint regardless of how its JWK was serialized.
17
+ *
18
+ * <code>thumbprint(jwk)</code> returns the base64url digest;
19
+ * <code>canonicalize(jwk)</code> returns the exact JSON string that is
20
+ * hashed. The standard key types are supported — EC, RSA, oct, and OKP
21
+ * (RFC 8037 Ed25519 / X25519) — plus AKP, the IANA key type Node uses for
22
+ * ML-DSA / SLH-DSA post-quantum public keys. SHA-256 is the default;
23
+ * <code>hash: "sha384" | "sha512"</code> selects a longer digest
24
+ * (RFC 9278 thumbprint-with-hash).
25
+ *
26
+ * @card
27
+ * RFC 7638 JWK thumbprint — the canonical
28
+ * <code>base64url(SHA-256(canonical-JSON))</code> identifier for a JSON
29
+ * Web Key (EC / RSA / oct / OKP / AKP), behind DPoP <code>jkt</code>,
30
+ * ACME account keys, and DBSC session pins.
31
+ */
32
+
33
+ var nodeCrypto = require("node:crypto");
34
+ var canonicalJson = require("./canonical-json");
35
+ var { defineClass } = require("./framework-error");
36
+
37
+ var JwkError = defineClass("JwkError", { alwaysPermanent: true });
38
+
39
+ var HASHES = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
40
+
41
+ // RFC 7638 §3.2 + JWA: the required members per key type, which (and only
42
+ // which) participate in the thumbprint. Listed for documentation; the
43
+ // canonical form is produced with lexicographic ordering regardless.
44
+ var REQUIRED = {
45
+ EC: ["crv", "kty", "x", "y"],
46
+ RSA: ["e", "kty", "n"],
47
+ oct: ["k", "kty"],
48
+ OKP: ["crv", "kty", "x"], // RFC 8037
49
+ AKP: ["alg", "kty", "pub"], // IANA AKP — ML-DSA / SLH-DSA public keys
50
+ };
51
+
52
+ function _requiredMembers(jwk) {
53
+ if (!jwk || typeof jwk !== "object" || Array.isArray(jwk)) {
54
+ throw new JwkError("jwk/bad-jwk", "jwk: must be a JWK object");
55
+ }
56
+ if (typeof jwk.kty !== "string" || jwk.kty.length === 0) {
57
+ throw new JwkError("jwk/bad-jwk", "jwk: 'kty' is required");
58
+ }
59
+ var names = REQUIRED[jwk.kty];
60
+ if (!names) throw new JwkError("jwk/unsupported-kty", "jwk: unsupported kty '" + jwk.kty + "'");
61
+ var out = {};
62
+ for (var i = 0; i < names.length; i++) {
63
+ var n = names[i];
64
+ if (typeof jwk[n] !== "string" || jwk[n].length === 0) {
65
+ throw new JwkError("jwk/bad-jwk", "jwk: " + jwk.kty + " key requires a string '" + n + "' member");
66
+ }
67
+ out[n] = jwk[n];
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * @primitive b.jwk.canonicalize
74
+ * @signature b.jwk.canonicalize(jwk)
75
+ * @since 0.12.68
76
+ * @status stable
77
+ * @related b.jwk.thumbprint
78
+ *
79
+ * Return the RFC 7638 canonical JSON string for a JWK — only the key-type's
80
+ * required members, member names in lexicographic order, no whitespace.
81
+ * This is the exact input that <code>thumbprint</code> hashes. Throws
82
+ * <code>JwkError</code> for a missing <code>kty</code>, an unsupported key
83
+ * type, or a missing required member.
84
+ *
85
+ * @example
86
+ * b.jwk.canonicalize({ kty: "EC", crv: "P-256", x: "...", y: "...", use: "sig" });
87
+ * // → '{"crv":"P-256","kty":"EC","x":"...","y":"..."}' (use omitted)
88
+ */
89
+ function canonicalize(jwk) {
90
+ return canonicalJson.stringify(_requiredMembers(jwk));
91
+ }
92
+
93
+ /**
94
+ * @primitive b.jwk.thumbprint
95
+ * @signature b.jwk.thumbprint(jwk, opts?)
96
+ * @since 0.12.68
97
+ * @status stable
98
+ * @related b.jwk.canonicalize
99
+ *
100
+ * Compute the RFC 7638 thumbprint of a JWK:
101
+ * <code>base64url(hash(canonicalJSON))</code>. Only the key-type's required
102
+ * members feed the hash, so optional fields (<code>kid</code>,
103
+ * <code>use</code>, <code>alg</code>, …) never change the result. SHA-256
104
+ * is the default digest; <code>hash</code> selects a longer one. Throws
105
+ * <code>JwkError</code> on an invalid JWK or unknown hash.
106
+ *
107
+ * @opts
108
+ * hash: "sha256" | "sha384" | "sha512", // default: "sha256"
109
+ *
110
+ * @example
111
+ * b.jwk.thumbprint({ kty: "RSA", e: "AQAB", n: "0vx7ago...DKgw" });
112
+ * // → "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
113
+ */
114
+ function thumbprint(jwk, opts) {
115
+ opts = opts || {};
116
+ var hash = HASHES[opts.hash || "sha256"];
117
+ if (!hash) throw new JwkError("jwk/bad-hash", "jwk.thumbprint: hash must be sha256, sha384, or sha512");
118
+ var canon = canonicalize(jwk);
119
+ return nodeCrypto.createHash(hash).update(canon, "utf8").digest("base64url");
120
+ }
121
+
122
+ module.exports = {
123
+ thumbprint: thumbprint,
124
+ canonicalize: canonicalize,
125
+ REQUIRED: REQUIRED,
126
+ JwkError: JwkError,
127
+ };
@@ -6,8 +6,12 @@
6
6
  *
7
7
  * Heuristics (all combined):
8
8
  * - Missing Accept-Language header (real browsers always send one)
9
- * - Missing Sec-Fetch-Mode header (modern browsers send these on every
10
- * navigation; absence is suspicious for HTML routes but not API)
9
+ * - Missing Sec-Fetch-Mode header ADVISORY ONLY (never blocks). Tagged
10
+ * in mode:"tag" on secure-context HTML GETs where a modern browser
11
+ * would have sent it. It cannot block because the header is absent for
12
+ * entire browser families (Safari < 16.4) and for every plain-HTTP
13
+ * non-localhost origin (Umbrel, LAN / *.local proxies) — a 403 on it
14
+ * alone would refuse real users.
11
15
  * - User-Agent matches known automation libraries (curl, wget, python-
12
16
  * requests, axios, Go-http-client) — operators can add or remove
13
17
  * entries via config
@@ -86,9 +90,13 @@ function _xffIpFor(trustProxy) {
86
90
  * Cheap fingerprint-based detection of obviously-non-browser requests.
87
91
  * Constructed via `b.middleware.botGuard(opts)`; the resulting
88
92
  * middleware has the `(req, res, next)` shape shown above.
89
- * Combines three heuristics: missing `Accept-Language`, missing
90
- * `Sec-Fetch-Mode` (HTML routes), and User-Agent regex match against
91
- * a default list (curl / wget / python-requests / axios / etc.). Not
93
+ * Two blocking heuristics missing `Accept-Language` and a User-Agent
94
+ * regex match against a default list (curl / wget / python-requests /
95
+ * axios / etc.) plus one advisory signal: a missing `Sec-Fetch-Mode`
96
+ * on a secure-context HTML GET sets `req.suspectedBot` in `mode: "tag"`
97
+ * but NEVER blocks (the header is absent for Safari < 16.4 and every
98
+ * plain-HTTP non-localhost origin, so blocking on it refuses real
99
+ * users). Not
92
100
  * a substitute for proper authentication — catches drive-by scrapers
93
101
  * and low-effort bots. In `mode: "block"` (default) the request is
94
102
  * refused; in `mode: "tag"` `req.suspectedBot = true` is set and the
@@ -152,6 +160,28 @@ function create(opts) {
152
160
  return /^\/api\//.test(path);
153
161
  }
154
162
 
163
+ // Browsers only emit Fetch Metadata (Sec-Fetch-*) in a *secure context*
164
+ // (W3C Secure Contexts): an HTTPS origin, or a localhost-family origin
165
+ // even over plain HTTP. On a plain-HTTP non-localhost origin — an Umbrel
166
+ // app, a LAN / *.local reverse-proxy deployment — the browser omits
167
+ // Sec-Fetch-* entirely, so a missing Sec-Fetch-Mode is NORMAL there and
168
+ // must not be read as a bot signal. The effective scheme honours
169
+ // X-Forwarded-Proto only under trustProxy (otherwise it is forgeable).
170
+ function _isSecureContext(req) {
171
+ if (requestHelpers.requestProtocol(req, { trustProxy: trustProxy }) === "https") return true;
172
+ var host = (req.headers && req.headers.host) || "";
173
+ host = String(host).toLowerCase().replace(/:\d+$/, ""); // strip :port
174
+ if (host.charAt(0) === "[") { // [::1] IPv6 literal
175
+ var end = host.indexOf("]");
176
+ host = end === -1 ? host.slice(1) : host.slice(1, end);
177
+ }
178
+ host = host.replace(/\.$/, ""); // strip trailing root-zone dot (RFC 1034 §3.1) so "localhost." matches
179
+ if (host === "localhost" || /\.localhost$/.test(host)) return true;
180
+ if (host === "::1") return true;
181
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; // allow:regex-no-length-cap — bounded dotted-quad loopback
182
+ return false;
183
+ }
184
+
155
185
  function _checkHeuristics(req) {
156
186
  var headers = req.headers || {};
157
187
  var ua = headers["user-agent"] || "";
@@ -167,7 +197,14 @@ function create(opts) {
167
197
  return null;
168
198
  }
169
199
  if (!headers["accept-language"]) return "missing-accept-language";
170
- if (req.method === "GET" && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
200
+ // Missing Sec-Fetch-Mode NEVER blocks: the header is absent for entire
201
+ // browser families (Safari < 16.4 omits Fetch Metadata even over HTTPS)
202
+ // and for every plain-HTTP non-localhost origin (Umbrel, LAN / *.local
203
+ // reverse proxies), so a 403 on it alone refuses real users. It survives
204
+ // only as an advisory TAG in mode:"tag", and even then only in a secure
205
+ // context where a modern browser would have sent it. Drive-by bots are
206
+ // still blocked by missing Accept-Language + the User-Agent deny-list.
207
+ if (mode === "tag" && req.method === "GET" && _isSecureContext(req) && !headers["sec-fetch-mode"]) return "missing-sec-fetch-mode";
171
208
  return null;
172
209
  }
173
210
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.12.66",
3
+ "version": "0.12.69",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:4e9da547-078d-46ac-833b-3eae7e281a06",
5
+ "serialNumber": "urn:uuid:99cf7113-3c76-4163-809f-e0478728ac1c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-26T10:01:10.136Z",
8
+ "timestamp": "2026-05-26T15:45:38.862Z",
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.12.66",
22
+ "bom-ref": "@blamejs/core@0.12.69",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.12.66",
25
+ "version": "0.12.69",
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.12.66",
29
+ "purl": "pkg:npm/%40blamejs/core@0.12.69",
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.12.66",
57
+ "ref": "@blamejs/core@0.12.69",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]