@blamejs/core 0.7.107 → 0.8.4

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.
Files changed (100) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/NOTICE +17 -1
  3. package/README.md +4 -3
  4. package/index.js +15 -0
  5. package/lib/asyncapi-bindings.js +160 -0
  6. package/lib/asyncapi-traits.js +143 -0
  7. package/lib/asyncapi.js +531 -0
  8. package/lib/audit-sign.js +1 -1
  9. package/lib/audit.js +68 -2
  10. package/lib/auth/acr-vocabulary.js +265 -0
  11. package/lib/auth/auth-time-tracker.js +111 -0
  12. package/lib/auth/elevation-grant.js +306 -0
  13. package/lib/auth/jwt.js +13 -0
  14. package/lib/auth/lockout.js +16 -3
  15. package/lib/auth/oauth.js +15 -1
  16. package/lib/auth/password.js +22 -2
  17. package/lib/auth/sd-jwt-vc-issuer.js +2 -2
  18. package/lib/auth/sd-jwt-vc.js +7 -2
  19. package/lib/auth/step-up-policy.js +335 -0
  20. package/lib/auth/step-up.js +445 -0
  21. package/lib/break-glass.js +53 -14
  22. package/lib/cache-redis.js +1 -1
  23. package/lib/cache.js +6 -1
  24. package/lib/cli.js +3 -3
  25. package/lib/cluster.js +24 -1
  26. package/lib/compliance-ai-act-logging.js +190 -0
  27. package/lib/compliance-ai-act-prohibited.js +205 -0
  28. package/lib/compliance-ai-act-risk.js +189 -0
  29. package/lib/compliance-ai-act-transparency.js +200 -0
  30. package/lib/compliance-ai-act.js +558 -0
  31. package/lib/compliance.js +12 -2
  32. package/lib/config-drift.js +2 -2
  33. package/lib/crypto-field.js +21 -1
  34. package/lib/crypto.js +114 -1
  35. package/lib/db.js +35 -4
  36. package/lib/dev.js +30 -3
  37. package/lib/dual-control.js +19 -1
  38. package/lib/external-db.js +10 -0
  39. package/lib/file-upload.js +30 -3
  40. package/lib/flag-cache.js +136 -0
  41. package/lib/flag-evaluation-context.js +135 -0
  42. package/lib/flag-providers.js +279 -0
  43. package/lib/flag-targeting.js +210 -0
  44. package/lib/flag.js +284 -0
  45. package/lib/guard-all.js +33 -16
  46. package/lib/guard-csv.js +16 -2
  47. package/lib/guard-html.js +35 -0
  48. package/lib/guard-svg.js +20 -0
  49. package/lib/http-client.js +57 -11
  50. package/lib/inbox.js +391 -0
  51. package/lib/log-stream-syslog.js +8 -0
  52. package/lib/log-stream.js +1 -1
  53. package/lib/mail-arc-sign.js +372 -0
  54. package/lib/mail-auth.js +2 -0
  55. package/lib/mail.js +40 -0
  56. package/lib/middleware/ai-act-disclosure.js +166 -0
  57. package/lib/middleware/asyncapi-serve.js +136 -0
  58. package/lib/middleware/attach-user.js +25 -2
  59. package/lib/middleware/bearer-auth.js +71 -6
  60. package/lib/middleware/body-parser.js +13 -0
  61. package/lib/middleware/cors.js +10 -0
  62. package/lib/middleware/csrf-protect.js +34 -3
  63. package/lib/middleware/dpop.js +3 -3
  64. package/lib/middleware/flag-context.js +76 -0
  65. package/lib/middleware/host-allowlist.js +1 -1
  66. package/lib/middleware/index.js +15 -0
  67. package/lib/middleware/openapi-serve.js +143 -0
  68. package/lib/middleware/require-aal.js +2 -2
  69. package/lib/middleware/require-step-up.js +186 -0
  70. package/lib/middleware/trace-propagate.js +1 -1
  71. package/lib/mtls-ca.js +23 -29
  72. package/lib/mtls-engine-default.js +21 -1
  73. package/lib/network-tls.js +21 -6
  74. package/lib/object-store/sigv4-bucket-ops.js +41 -0
  75. package/lib/observability-otlp-exporter.js +35 -2
  76. package/lib/openapi-paths-builder.js +248 -0
  77. package/lib/openapi-schema-walk.js +192 -0
  78. package/lib/openapi-security.js +169 -0
  79. package/lib/openapi-yaml.js +154 -0
  80. package/lib/openapi.js +443 -0
  81. package/lib/outbox.js +3 -3
  82. package/lib/permissions.js +10 -1
  83. package/lib/pqc-agent.js +22 -1
  84. package/lib/pqc-software.js +195 -0
  85. package/lib/pubsub.js +8 -4
  86. package/lib/redact.js +26 -1
  87. package/lib/retention.js +26 -0
  88. package/lib/router.js +1 -0
  89. package/lib/scheduler.js +57 -1
  90. package/lib/session.js +3 -3
  91. package/lib/ssrf-guard.js +19 -4
  92. package/lib/static.js +12 -0
  93. package/lib/totp.js +16 -0
  94. package/lib/vault/index.js +3 -0
  95. package/lib/vault-aad.js +259 -0
  96. package/lib/vendor/MANIFEST.json +29 -0
  97. package/lib/vendor/noble-post-quantum.cjs +18 -0
  98. package/lib/ws-client.js +978 -0
  99. package/package.json +1 -1
  100. package/sbom.cyclonedx.json +6 -6
package/lib/flag.js ADDED
@@ -0,0 +1,284 @@
1
+ "use strict";
2
+ /**
3
+ * b.flag — feature-flag client per the OpenFeature specification
4
+ * (https://openfeature.dev/specification/).
5
+ *
6
+ * var flag = b.flag.create({
7
+ * provider: b.flag.providers.localFile({ path: "./flags.json" }),
8
+ * defaultEvaluationContext: { environment: "production" },
9
+ * });
10
+ *
11
+ * var enabled = flag.getBoolean("new-checkout-flow", { targetingKey: req.user.id });
12
+ * var sample = flag.getString ("greeting-banner", { targetingKey: req.user.id }, "default-text");
13
+ * var rate = flag.getNumber ("upsell-rate", { targetingKey: req.user.id }, 0);
14
+ * var cfg = flag.getObject ("checkout-config", { targetingKey: req.user.id }, {});
15
+ *
16
+ * var details = flag.getDetails("new-checkout-flow", ctx);
17
+ * // → { value, variant, reason, metadata }
18
+ *
19
+ * flag.middleware() → request-time middleware that attaches a
20
+ * per-request `flag` accessor onto req.
21
+ *
22
+ * Per the validation-tier policy: create() throws on bad opts; the
23
+ * hot-path getValue / getBoolean / etc. NEVER throw — they return the
24
+ * operator-supplied default + emit `flag.evaluation.error` on the
25
+ * audit chain so the operator sees the problem without taking down
26
+ * the request.
27
+ */
28
+
29
+ var validateOpts = require("./validate-opts");
30
+ var lazyRequire = require("./lazy-require");
31
+ var providersMod = require("./flag-providers");
32
+ var contextMod = require("./flag-evaluation-context");
33
+ var targeting = require("./flag-targeting");
34
+ var cacheMod = require("./flag-cache");
35
+ var { defineClass } = require("./framework-error");
36
+ var FlagError = defineClass("FlagError", { alwaysPermanent: true });
37
+
38
+ var audit = lazyRequire(function () { return require("./audit"); });
39
+
40
+ function _validateHooks(rawHooks) {
41
+ var out = { before: [], after: [], error: [], finally: [] };
42
+ if (rawHooks == null) return out;
43
+ if (typeof rawHooks !== "object") {
44
+ throw new FlagError("flag/bad-hooks",
45
+ "create: hooks must be an object { before, after, error, finally }");
46
+ }
47
+ var stages = ["before", "after", "error", "finally"];
48
+ for (var i = 0; i < stages.length; i += 1) {
49
+ var stage = stages[i];
50
+ if (rawHooks[stage] == null) continue;
51
+ var arr = Array.isArray(rawHooks[stage]) ? rawHooks[stage] : [rawHooks[stage]];
52
+ for (var j = 0; j < arr.length; j += 1) {
53
+ if (typeof arr[j] !== "function") {
54
+ throw new FlagError("flag/bad-hooks",
55
+ "create: hooks." + stage + "[" + j + "] must be a function");
56
+ }
57
+ }
58
+ out[stage] = arr.slice();
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function create(opts) {
64
+ opts = opts || {};
65
+ validateOpts(opts, [
66
+ "provider", "providers", "defaultEvaluationContext",
67
+ "audit", "errorHandler", "hooks",
68
+ ], "flag.create");
69
+ var providers = [];
70
+ if (opts.provider) {
71
+ if (typeof opts.provider.evaluate !== "function") {
72
+ throw new FlagError("flag/bad-provider",
73
+ "create: provider must implement .evaluate(flagKey, ctx)");
74
+ }
75
+ providers.push(opts.provider);
76
+ }
77
+ if (Array.isArray(opts.providers)) {
78
+ for (var i = 0; i < opts.providers.length; i += 1) {
79
+ if (typeof opts.providers[i].evaluate !== "function") {
80
+ throw new FlagError("flag/bad-provider",
81
+ "create: providers[" + i + "] must implement .evaluate()");
82
+ }
83
+ providers.push(opts.providers[i]);
84
+ }
85
+ }
86
+ if (providers.length === 0) {
87
+ throw new FlagError("flag/no-provider",
88
+ "create: at least one provider is required - pass `provider` or `providers`");
89
+ }
90
+ var defaultCtx = contextMod.create(opts.defaultEvaluationContext || {});
91
+ var auditOn = opts.audit !== false;
92
+ var errorHandler = (typeof opts.errorHandler === "function")
93
+ ? opts.errorHandler : null;
94
+ var hooks = _validateHooks(opts.hooks);
95
+
96
+ function _emitErrorAudit(flagKey, err, ctx) {
97
+ if (!auditOn) return;
98
+ try {
99
+ audit().safeEmit({
100
+ action: "flag.evaluation.error",
101
+ outcome: "failure",
102
+ actor: { targetingKey: ctx && ctx.targetingKey || null },
103
+ metadata: {
104
+ flagKey: flagKey,
105
+ message: err && err.message || String(err),
106
+ code: err && err.code || "flag/unknown",
107
+ },
108
+ });
109
+ } catch (_e) { /* drop-silent */ }
110
+ }
111
+
112
+ function _runHook(stage, info) {
113
+ var arr = hooks[stage];
114
+ if (!arr || arr.length === 0) return;
115
+ for (var i = 0; i < arr.length; i += 1) {
116
+ try { arr[i](info); } catch (_e) { /* drop-silent — hooks are observability, not blocking */ }
117
+ }
118
+ }
119
+
120
+ function _evaluate(flagKey, ctx) {
121
+ var mergedCtx = contextMod.merge(defaultCtx, ctx || {});
122
+ var startMs = Date.now();
123
+ _runHook("before", { flagKey: flagKey, ctx: mergedCtx });
124
+ for (var i = 0; i < providers.length; i += 1) {
125
+ try {
126
+ var result = providers[i].evaluate(flagKey, mergedCtx);
127
+ if (result && result.reason !== "flag_not_found") {
128
+ if (auditOn) {
129
+ try {
130
+ audit().safeEmit({
131
+ action: "flag.evaluated",
132
+ outcome: "success",
133
+ actor: { targetingKey: mergedCtx.targetingKey || null },
134
+ metadata: {
135
+ flagKey: flagKey,
136
+ variant: result.variant,
137
+ reason: result.reason,
138
+ provider: providers[i].kind || null,
139
+ },
140
+ });
141
+ } catch (_e) { /* drop-silent */ }
142
+ }
143
+ _runHook("after", { flagKey: flagKey, ctx: mergedCtx, result: result, elapsedMs: Date.now() - startMs });
144
+ _runHook("finally", { flagKey: flagKey, ctx: mergedCtx, result: result });
145
+ return result;
146
+ }
147
+ } catch (err) {
148
+ _emitErrorAudit(flagKey, err, mergedCtx);
149
+ _runHook("error", { flagKey: flagKey, ctx: mergedCtx, err: err });
150
+ if (errorHandler) {
151
+ try { errorHandler({ flagKey: flagKey, err: err, ctx: mergedCtx }); }
152
+ catch (_e2) { /* drop-silent */ }
153
+ }
154
+ }
155
+ }
156
+ var notFound = {
157
+ value: undefined,
158
+ variant: null,
159
+ reason: "flag_not_found",
160
+ metadata: { flagKey: flagKey, providers: providers.map(function (p) { return p.kind; }) },
161
+ };
162
+ _runHook("after", { flagKey: flagKey, ctx: mergedCtx, result: notFound, elapsedMs: Date.now() - startMs });
163
+ _runHook("finally", { flagKey: flagKey, ctx: mergedCtx, result: notFound });
164
+ return notFound;
165
+ }
166
+
167
+ function _coerceBoolean(v) {
168
+ if (v === true || v === false) return v;
169
+ if (v === "true") return true;
170
+ if (v === "false") return false;
171
+ if (v === 1) return true;
172
+ if (v === 0) return false;
173
+ return null;
174
+ }
175
+
176
+ return {
177
+ getValue: function (flagKey, ctx, defaultValue) {
178
+ var r = _evaluate(flagKey, ctx);
179
+ if (r.value === undefined) return defaultValue;
180
+ return r.value;
181
+ },
182
+ getDetails: function (flagKey, ctx) {
183
+ return _evaluate(flagKey, ctx);
184
+ },
185
+ getBoolean: function (flagKey, ctx, defaultValue) {
186
+ var r = _evaluate(flagKey, ctx);
187
+ if (r.value === undefined) return defaultValue === true;
188
+ var coerced = _coerceBoolean(r.value);
189
+ return coerced != null ? coerced : (defaultValue === true);
190
+ },
191
+ getString: function (flagKey, ctx, defaultValue) {
192
+ var r = _evaluate(flagKey, ctx);
193
+ if (typeof r.value === "string") return r.value;
194
+ return (typeof defaultValue === "string") ? defaultValue : "";
195
+ },
196
+ getNumber: function (flagKey, ctx, defaultValue) {
197
+ var r = _evaluate(flagKey, ctx);
198
+ if (typeof r.value === "number" && isFinite(r.value)) return r.value;
199
+ return (typeof defaultValue === "number") ? defaultValue : 0;
200
+ },
201
+ getObject: function (flagKey, ctx, defaultValue) {
202
+ var r = _evaluate(flagKey, ctx);
203
+ if (r.value && typeof r.value === "object") return r.value;
204
+ return defaultValue == null ? {} : defaultValue;
205
+ },
206
+ list: function () {
207
+ var keys = Object.create(null);
208
+ for (var i = 0; i < providers.length; i += 1) {
209
+ if (typeof providers[i].list === "function") {
210
+ var arr = providers[i].list();
211
+ for (var j = 0; j < arr.length; j += 1) keys[arr[j]] = true;
212
+ }
213
+ }
214
+ return Object.keys(keys);
215
+ },
216
+ providers: providers.slice(),
217
+ defaultEvaluationContext: defaultCtx,
218
+ getValues: function (flagKeys, ctx) {
219
+ var out = {};
220
+ if (!Array.isArray(flagKeys)) return out;
221
+ for (var i = 0; i < flagKeys.length; i += 1) {
222
+ var k = flagKeys[i];
223
+ if (typeof k !== "string") continue;
224
+ var r = _evaluate(k, ctx);
225
+ out[k] = r.value;
226
+ }
227
+ return out;
228
+ },
229
+ getDetailsAll: function (flagKeys, ctx) {
230
+ var out = {};
231
+ if (!Array.isArray(flagKeys)) return out;
232
+ for (var i = 0; i < flagKeys.length; i += 1) {
233
+ var k = flagKeys[i];
234
+ if (typeof k !== "string") continue;
235
+ out[k] = _evaluate(k, ctx);
236
+ }
237
+ return out;
238
+ },
239
+ addProvider: function (next) {
240
+ if (!next || typeof next.evaluate !== "function") {
241
+ throw new FlagError("flag/bad-provider",
242
+ "addProvider: provider must implement .evaluate()");
243
+ }
244
+ providers.push(next);
245
+ return providers.length;
246
+ },
247
+ removeProvider: function (target) {
248
+ var before = providers.length;
249
+ for (var i = providers.length - 1; i >= 0; i -= 1) {
250
+ if (providers[i] === target) providers.splice(i, 1);
251
+ }
252
+ return before - providers.length;
253
+ },
254
+ middleware: function (mwOpts) {
255
+ mwOpts = mwOpts || {};
256
+ validateOpts(mwOpts, ["userKey"], "flag.middleware");
257
+ var self = this;
258
+ return function flagMiddleware(req, res, next) {
259
+ var reqCtx = contextMod.fromRequest(req, {
260
+ userKey: mwOpts.userKey,
261
+ });
262
+ req.flag = {
263
+ getBoolean: function (k, def) { return self.getBoolean(k, reqCtx, def); },
264
+ getString: function (k, def) { return self.getString (k, reqCtx, def); },
265
+ getNumber: function (k, def) { return self.getNumber (k, reqCtx, def); },
266
+ getObject: function (k, def) { return self.getObject (k, reqCtx, def); },
267
+ getValue: function (k, def) { return self.getValue (k, reqCtx, def); },
268
+ getDetails: function (k) { return self.getDetails(k, reqCtx); },
269
+ ctx: reqCtx,
270
+ };
271
+ return next();
272
+ };
273
+ },
274
+ };
275
+ }
276
+
277
+ module.exports = {
278
+ create: create,
279
+ providers: providersMod,
280
+ context: contextMod,
281
+ targeting: targeting,
282
+ cache: cacheMod.cache,
283
+ FlagError: FlagError,
284
+ };
package/lib/guard-all.js CHANGED
@@ -116,8 +116,16 @@ var SHARED_POSTURES = Object.freeze(["hipaa", "pci-dss", "gdpr", "soc2"]);
116
116
 
117
117
  function _verifyParity() {
118
118
  var failures = [];
119
- for (var i = 0; i < GUARDS.length; i += 1) {
120
- var g = GUARDS[i];
119
+ // Walk both registries content guards (with MIME_TYPES + EXTENSIONS)
120
+ // and standalone guards (filename / domain / uuid / cidr / time /
121
+ // mime / jwt / oauth / graphql / shell / regex / jsonpath / template /
122
+ // image / pdf / auth). Standalone guards skip the MIME/EXTENSION
123
+ // checks but every guard MUST declare the shared profile + posture
124
+ // vocabulary so b.guardAll.allGuards() returns a uniform surface.
125
+ var allGuards = GUARDS.concat(STANDALONE_GUARDS);
126
+ for (var i = 0; i < allGuards.length; i += 1) {
127
+ var g = allGuards[i];
128
+ var isContent = i < GUARDS.length;
121
129
  if (!g || typeof g !== "object") {
122
130
  failures.push("guard at index " + i + " is not an exported module object");
123
131
  continue;
@@ -126,11 +134,13 @@ function _verifyParity() {
126
134
  failures.push("guard at index " + i + ": missing NAME export");
127
135
  continue;
128
136
  }
129
- if (!Array.isArray(g.MIME_TYPES) || g.MIME_TYPES.length === 0) {
130
- failures.push(g.NAME + ": missing or empty MIME_TYPES export");
131
- }
132
- if (!Array.isArray(g.EXTENSIONS) || g.EXTENSIONS.length === 0) {
133
- failures.push(g.NAME + ": missing or empty EXTENSIONS export");
137
+ if (isContent) {
138
+ if (!Array.isArray(g.MIME_TYPES) || g.MIME_TYPES.length === 0) {
139
+ failures.push(g.NAME + ": missing or empty MIME_TYPES export");
140
+ }
141
+ if (!Array.isArray(g.EXTENSIONS) || g.EXTENSIONS.length === 0) {
142
+ failures.push(g.NAME + ": missing or empty EXTENSIONS export");
143
+ }
134
144
  }
135
145
  if (typeof g.gate !== "function") {
136
146
  failures.push(g.NAME + ": missing gate(opts) function");
@@ -146,27 +156,34 @@ function _verifyParity() {
146
156
  }
147
157
  });
148
158
  }
149
- // Detect duplicate NAMEs / MIME_TYPES / EXTENSIONS would cause silent
150
- // override in the aggregated gate map; surface at boot instead.
159
+ // Detect duplicate NAMEs across the full registry (both content +
160
+ // standalone) so a future guard with a NAME collision surfaces at
161
+ // boot instead of silently overriding _byName lookups. MIME / EXT
162
+ // collision detection stays scoped to content guards (standalone
163
+ // guards have no MIME/EXTENSIONS).
151
164
  var nameSeen = Object.create(null);
152
165
  var mimeSeen = Object.create(null);
153
166
  var extSeen = Object.create(null);
154
- for (var j = 0; j < GUARDS.length; j += 1) {
155
- var gg = GUARDS[j];
167
+ for (var j = 0; j < allGuards.length; j += 1) {
168
+ var gg = allGuards[j];
156
169
  if (gg && gg.NAME) {
157
- if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME));
170
+ if (nameSeen[gg.NAME]) failures.push("duplicate NAME " + JSON.stringify(gg.NAME) +
171
+ " across the full guard registry");
158
172
  nameSeen[gg.NAME] = true;
159
173
  }
160
- if (gg && Array.isArray(gg.MIME_TYPES)) {
161
- gg.MIME_TYPES.forEach(function (m) {
174
+ }
175
+ for (var jc = 0; jc < GUARDS.length; jc += 1) {
176
+ var ggc = GUARDS[jc];
177
+ if (ggc && Array.isArray(ggc.MIME_TYPES)) {
178
+ ggc.MIME_TYPES.forEach(function (m) {
162
179
  var k = String(m).toLowerCase();
163
180
  if (mimeSeen[k]) failures.push("duplicate MIME_TYPE " + JSON.stringify(k) +
164
181
  " across multiple guards");
165
182
  mimeSeen[k] = true;
166
183
  });
167
184
  }
168
- if (gg && Array.isArray(gg.EXTENSIONS)) {
169
- gg.EXTENSIONS.forEach(function (e) {
185
+ if (ggc && Array.isArray(ggc.EXTENSIONS)) {
186
+ ggc.EXTENSIONS.forEach(function (e) {
170
187
  var k = String(e).toLowerCase();
171
188
  if (extSeen[k]) failures.push("duplicate EXTENSION " + JSON.stringify(k) +
172
189
  " across multiple guards");
package/lib/guard-csv.js CHANGED
@@ -360,7 +360,20 @@ function _detectIssues(text, opts) {
360
360
  }
361
361
 
362
362
  if (opts.formulaInjectionPolicy !== "audit-only" && opts.formulaInjectionPolicy !== "allow") {
363
- var formulaMatch = _firstMatch(text, FORMULA_SCAN_RE);
363
+ // Strip ZWSP / RTLO / LRM / RLM / BOM at cell-start before the
364
+ // formula scan. Without this, a cell beginning with U+200B (zero-
365
+ // width space), U+202E (RTLO), U+200E/F (LTR/RTL marks), or U+FEFF
366
+ // (BOM) followed by `=` slips past the start-anchor check (the `^`
367
+ // sits before the codepoint, not after) and the formula reaches
368
+ // the spreadsheet evaluator. Browsers + Excel + Sheets all strip
369
+ // these silently — operator users see "=SUM(...)" rendered, the
370
+ // file shipped a hidden bidi prefix that bypassed the scanner.
371
+ // U+200B-200F (ZWSP / ZWNJ / ZWJ / LRM / RLM) +
372
+ // U+202A-202E (LRE / RLE / PDF / LRO / RLO) +
373
+ // U+2066-2069 (LRI / RLI / FSI / PDI) +
374
+ // U+FEFF (BOM) // allow:dynamic-regex — explicit codepoints, no operator input
375
+ var stripped = text.replace(new RegExp("^[\\u200B-\\u200F\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]+"), "");
376
+ var formulaMatch = _firstMatch(stripped, FORMULA_SCAN_RE);
364
377
  if (formulaMatch) {
365
378
  issues.push({
366
379
  kind: "formula-prefix-cell", severity: "critical",
@@ -368,7 +381,8 @@ function _detectIssues(text, opts) {
368
381
  location: formulaMatch.index,
369
382
  snippet: "cell beginning with formula trigger " +
370
383
  JSON.stringify(formulaMatch.char.slice(-1)) +
371
- " at byte " + formulaMatch.index,
384
+ " at byte " + formulaMatch.index +
385
+ (stripped.length !== text.length ? " (after stripping leading bidi/zero-width prefix)" : ""),
372
386
  });
373
387
  }
374
388
  }
package/lib/guard-html.js CHANGED
@@ -402,6 +402,30 @@ function escapeAttr(value) {
402
402
  .replace(/=/g, "&#61;");
403
403
  }
404
404
 
405
+ // HTML5 named entities that decode to ASCII codepoints — focused on
406
+ // the entries browsers honor inside URL contexts (whitespace, control
407
+ // chars, scheme-significant punctuation). The full WHATWG named-
408
+ // character-reference table is ~2,231 entries; this is the
409
+ // security-load-bearing subset documented in scheme-bypass writeups
410
+ // (CVE-2026-30838 class). High-codepoint named entities (e.g. mathematical
411
+ // symbols) don't affect URL scheme parsing, so they're omitted.
412
+ var NAMED_ENTITY_ASCII = {
413
+ // Whitespace + control chars browsers strip inside URL schemes
414
+ Tab: "\t", NewLine: "\n",
415
+ // Scheme-significant punctuation
416
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
417
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
418
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
419
+ // Quotes / brackets
420
+ quot: "\"", apos: "'", lt: "<", gt: ">",
421
+ // Misc ASCII
422
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
423
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
424
+ // Whitespace markers (codepoints in the ASCII / Latin-1 range that
425
+ // browsers treat as URL-strippable)
426
+ nbsp: " ",
427
+ };
428
+
405
429
  // _normalizeUrl — peel off entity-encoded leading whitespace and
406
430
  // HTML/URL-encoded scheme prefix tricks, then return the lowercased
407
431
  // scheme. Returns "" if no scheme.
@@ -415,6 +439,17 @@ function _extractScheme(rawUrl) {
415
439
  s = s.replace(/&#(\d+);/g, function (_m, d) {
416
440
  return String.fromCharCode(parseInt(d, 10));
417
441
  });
442
+ // Decode HTML5 named entities that browsers honor inside URL
443
+ // contexts. Without this, payloads like `java&Tab;script:alert(1)`
444
+ // bypass the scheme allowlist (the literal `&Tab;` between `java`
445
+ // and `script:` doesn't match any denied scheme; the browser then
446
+ // decodes the entity, strips the tab, and executes javascript:).
447
+ s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
448
+ if (Object.prototype.hasOwnProperty.call(NAMED_ENTITY_ASCII, name)) {
449
+ return NAMED_ENTITY_ASCII[name];
450
+ }
451
+ return m;
452
+ });
418
453
  // Strip embedded whitespace + control chars + zero-widths the
419
454
  // URL parser would tolerate.
420
455
  s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
package/lib/guard-svg.js CHANGED
@@ -352,6 +352,20 @@ function _resolveOpts(opts) {
352
352
  });
353
353
  }
354
354
 
355
+ // HTML5 named-entity ASCII subset — same shape as guard-html.
356
+ // Browsers honor these inside URL contexts; without decoding them,
357
+ // `java&Tab;script:` and friends bypass the scheme allowlist.
358
+ var SVG_NAMED_ENTITY_ASCII = {
359
+ Tab: "\t", NewLine: "\n",
360
+ colon: ":", semi: ";", period: ".", sol: "/", bsol: "\\",
361
+ num: "#", excl: "!", quest: "?", lpar: "(", rpar: ")",
362
+ lsqb: "[", rsqb: "]", lcub: "{", rcub: "}",
363
+ quot: "\"", apos: "'", lt: "<", gt: ">",
364
+ amp: "&", commat: "@", dollar: "$", percnt: "%",
365
+ ast: "*", plus: "+", lowbar: "_", hyphen: "-",
366
+ nbsp: " ",
367
+ };
368
+
355
369
  function _extractScheme(rawUrl) {
356
370
  var s = String(rawUrl || "").trim();
357
371
  s = s.replace(/&#x([0-9a-f]+);/gi, function (_m, h) {
@@ -360,6 +374,12 @@ function _extractScheme(rawUrl) {
360
374
  s = s.replace(/&#(\d+);/g, function (_m, d) {
361
375
  return String.fromCharCode(parseInt(d, 10));
362
376
  });
377
+ s = s.replace(/&([A-Za-z][A-Za-z0-9]+);/g, function (m, name) {
378
+ if (Object.prototype.hasOwnProperty.call(SVG_NAMED_ENTITY_ASCII, name)) {
379
+ return SVG_NAMED_ENTITY_ASCII[name];
380
+ }
381
+ return m;
382
+ });
363
383
  s = s.replace(C0_CTRL_RE_G, "").replace(ZW_RE_G, "");
364
384
  var m = s.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
365
385
  return m ? m[1].toLowerCase() : "";
@@ -16,7 +16,7 @@
16
16
  * body, // Buffer | string | Readable | undefined
17
17
  * timeoutMs, // wall-clock cap (caller-chosen, no default)
18
18
  * idleTimeoutMs, // zero-progress idle cap (default 30s)
19
- * responseMode, // "buffer" (default) | "stream"
19
+ * responseMode, // "buffer" (default) | "stream" | "always-resolve"
20
20
  * maxResponseBytes, // for buffer mode (default 16 MiB control,
21
21
  * // 1 GiB GET — operators with > 1 GiB
22
22
  * // stored objects must use stream mode)
@@ -618,6 +618,11 @@ function _requestWithRedirects(opts, hopsLeft) {
618
618
  var u0 = safeUrl.parse(opts.url, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
619
619
  originalOrigin = u0.protocol + "//" + u0.host;
620
620
  } catch (_e) { /* request() will reject on next hop's parse */ }
621
+ // onRedirect: function ({ from, to, hop, headersStripped, statusCode }) — called
622
+ // BEFORE each follow. Operator can mutate the next-hop URL or abort
623
+ // the redirect by throwing. Async hooks are awaited.
624
+ var onRedirect = typeof opts.onRedirect === "function" ? opts.onRedirect : null;
625
+ var hopCount = 0;
621
626
 
622
627
  var current = Object.assign({}, opts, { _resolveOnRedirect: true });
623
628
  function _follow() {
@@ -628,6 +633,7 @@ function _requestWithRedirects(opts, hopsLeft) {
628
633
  var loc = res.headers && (res.headers.location || res.headers.Location);
629
634
  if (!loc) return { finalOpts: current, res: res }; // 3xx with no Location — operator handles
630
635
  hopsLeft -= 1;
636
+ hopCount += 1;
631
637
 
632
638
  // Resolve relative Location against the just-fetched URL (the URL
633
639
  // of the request that produced the redirect, which may itself be a
@@ -651,8 +657,10 @@ function _requestWithRedirects(opts, hopsLeft) {
651
657
  var nu = safeUrl.parse(nextUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_ALL });
652
658
  nextOrigin = nu.protocol + "//" + nu.host;
653
659
  } catch (_e) { /* request() will reject when it tries to parse */ }
660
+ var headersStripped = false;
654
661
  if (originalOrigin && nextOrigin && nextOrigin !== originalOrigin) {
655
662
  nextHeaders = _stripCrossOriginAuth(nextHeaders);
663
+ headersStripped = true;
656
664
  }
657
665
 
658
666
  // 303 → always GET; body dropped. 301/302 → historical clients
@@ -667,14 +675,42 @@ function _requestWithRedirects(opts, hopsLeft) {
667
675
  nextBody = undefined;
668
676
  }
669
677
 
670
- current = Object.assign({}, current, {
671
- url: nextUrl,
672
- method: nextMethod,
673
- body: nextBody,
674
- headers: nextHeaders,
675
- _resolveOnRedirect: true,
676
- });
677
- return _follow();
678
+ function _continueFollow() {
679
+ current = Object.assign({}, current, {
680
+ url: nextUrl,
681
+ method: nextMethod,
682
+ body: nextBody,
683
+ headers: nextHeaders,
684
+ _resolveOnRedirect: true,
685
+ });
686
+ return _follow();
687
+ }
688
+
689
+ // Caller-supplied redirect hook fires here. The hook can throw
690
+ // (sync) or reject (async) to abort the follow with a custom
691
+ // error; otherwise we proceed to the next hop. We pre-bind the
692
+ // values the hook gets and pass them in a frozen object so a
693
+ // caller can't mutate the in-flight pipeline by side-effect.
694
+ if (onRedirect) {
695
+ var hookEvent = Object.freeze({
696
+ from: current.url,
697
+ to: nextUrl,
698
+ hop: hopCount,
699
+ statusCode: res.statusCode,
700
+ headersStripped: headersStripped,
701
+ method: nextMethod,
702
+ });
703
+ try {
704
+ var hookResult = onRedirect(hookEvent);
705
+ if (hookResult && typeof hookResult.then === "function") {
706
+ return hookResult.then(function () { return _continueFollow(); });
707
+ }
708
+ } catch (e) {
709
+ return Promise.reject(_makeError(opts.errorClass, "REDIRECT_ABORTED",
710
+ "onRedirect hook refused redirect: " + ((e && e.message) || String(e)), true));
711
+ }
712
+ }
713
+ return _continueFollow();
678
714
  });
679
715
  }
680
716
  void originalUrl;
@@ -885,7 +921,7 @@ function _requestH1(transport, u, opts) {
885
921
  }
886
922
 
887
923
  if (responseMode === "stream") {
888
- if (res.statusCode >= 400) {
924
+ if (res.statusCode >= 400 && responseMode !== "always-resolve") {
889
925
  res.resume();
890
926
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
891
927
  "HTTP " + res.statusCode + " " + (res.statusMessage || ""),
@@ -936,6 +972,14 @@ function _requestH1(transport, u, opts) {
936
972
  // request() never sets _resolveOnRedirect — operator code that
937
973
  // didn't ask for redirect-following keeps seeing 3xx as errors.
938
974
  _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
975
+ } else if (responseMode === "always-resolve") {
976
+ // Operator opted in to "give me the full response object
977
+ // regardless of status." Caller branches on statusCode in
978
+ // their own code path — useful for proxies / forwarders /
979
+ // health-checkers / probe libraries that want to surface
980
+ // the upstream response structurally instead of via an
981
+ // error message string.
982
+ _resolve({ statusCode: res.statusCode, headers: res.headers, body: buf });
939
983
  } else {
940
984
  var msg = "HTTP " + res.statusCode + ": " + buf.toString("utf8").slice(0, 500);
941
985
  _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,
@@ -1079,7 +1123,7 @@ function _requestH2(transport, u, opts) {
1079
1123
  }
1080
1124
 
1081
1125
  if (responseMode === "stream") {
1082
- if (statusCode >= 400) {
1126
+ if (statusCode >= 400 && responseMode !== "always-resolve") {
1083
1127
  stream.resume();
1084
1128
  return _reject(_makeError(opts.errorClass, "HTTP_ERROR",
1085
1129
  "HTTP " + statusCode, _isPermanentStatus(statusCode), statusCode));
@@ -1110,6 +1154,8 @@ function _requestH2(transport, u, opts) {
1110
1154
  });
1111
1155
  if (statusCode >= 200 && statusCode < 300) {
1112
1156
  _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1157
+ } else if (responseMode === "always-resolve") {
1158
+ _resolve({ statusCode: statusCode, headers: responseHeaders, body: buf });
1113
1159
  } else {
1114
1160
  var msg = "HTTP " + statusCode + ": " + buf.toString("utf8").slice(0, 500);
1115
1161
  _reject(_makeError(opts.errorClass, "HTTP_ERROR", msg,