@blamejs/core 0.4.25 → 0.4.27

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - **0.4.26** (2026-04-30) — primitive-drift sweep: middleware audit context + safeUrl
12
+ - **0.4.25** (2026-04-30) — b.objectStore.bucketOps: bucket-level operations (SigV4)
11
13
  - **0.4.24** (2026-04-30) — b.objectStore: multipart upload + server-side encryption
12
14
  - **0.4.23** (2026-04-30) — b.mail.dkim signing + calendar invites
13
15
  - **0.4.22** (2026-04-30) — b.mail attachments + inline images + plain/HTML alternatives
package/lib/auth/oauth.js CHANGED
@@ -216,15 +216,24 @@ function _validateUrl(url, allowHttp, label) {
216
216
  if (typeof url !== "string" || url.length === 0) {
217
217
  throw new OAuthError("auth-oauth/bad-url", label + ": URL is required");
218
218
  }
219
- var parsed;
220
- try { parsed = new URL(url); }
221
- catch (_e) {
222
- throw new OAuthError("auth-oauth/bad-url", label + ": invalid URL '" + url + "'");
219
+ // Operator-supplied OAuth issuer / endpoint URL — route through
220
+ // safeUrl so the scheme allowlist is consistent with the rest of the
221
+ // framework's outbound gates. Map safe-url's error codes to the
222
+ // domain-specific oauth codes operators already key alerts on.
223
+ try {
224
+ safeUrl.parse(url, {
225
+ allowedProtocols: allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
226
+ });
227
+ } catch (e) {
228
+ if (e && e.code === "safe-url/protocol-disallowed") {
229
+ throw new OAuthError("auth-oauth/insecure-url",
230
+ label + ": must be https" + (allowHttp ? " or http" : "") +
231
+ " (got '" + url + "')");
232
+ }
233
+ throw new OAuthError("auth-oauth/bad-url",
234
+ label + ": invalid URL '" + url + "'");
223
235
  }
224
- if (parsed.protocol === "https:") return url;
225
- if (parsed.protocol === "http:" && allowHttp) return url;
226
- throw new OAuthError("auth-oauth/insecure-url",
227
- label + ": must be https (got '" + parsed.protocol + "//" + parsed.host + "')");
236
+ return url;
228
237
  }
229
238
 
230
239
  // ---- JOSE alg → node:crypto verify parameters ----
package/lib/error-page.js CHANGED
@@ -39,8 +39,11 @@
39
39
  */
40
40
 
41
41
  var lazyRequire = require("./lazy-require");
42
+ var template = require("./template");
42
43
  var audit = lazyRequire(function () { return require("./audit"); });
43
44
 
45
+ var _esc = template.escapeHtml;
46
+
44
47
  // Status code → default short reason. Used when the error doesn't carry
45
48
  // its own message (e.g. a generic Error thrown from a route).
46
49
  var STATUS_REASONS = {
@@ -114,16 +117,6 @@ function _wantsJson(req, defaultFormat) {
114
117
  return req && req.method && req.method.toUpperCase() !== "GET";
115
118
  }
116
119
 
117
- function _esc(s) {
118
- if (s === undefined || s === null) return "";
119
- return String(s)
120
- .replace(/&/g, "&")
121
- .replace(/</g, "&lt;")
122
- .replace(/>/g, "&gt;")
123
- .replace(/"/g, "&quot;")
124
- .replace(/'/g, "&#39;");
125
- }
126
-
127
120
  function _redactHeaders(headers) {
128
121
  if (!headers || typeof headers !== "object") return {};
129
122
  var out = {};
package/lib/forms.js CHANGED
@@ -36,6 +36,8 @@
36
36
  * forms.escapeHtml = template.escapeHtml (re-export for convenience)
37
37
  */
38
38
  var nodeCrypto = require("crypto");
39
+ var safeSchema = require("./safe-schema");
40
+ var safeUrl = require("./safe-url");
39
41
  var template = require("./template");
40
42
 
41
43
  // ============================================================
@@ -318,17 +320,26 @@ function validate(spec, body) {
318
320
  }
319
321
  }
320
322
  if (f.type === "email" && typeof coerced === "string") {
321
- // Pragmatic email check RFC 5322 is impractical to regex
322
- // correctly; this catches obvious nonsense ("foo", "foo@",
323
- // "@bar") without over-engineering.
324
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(coerced)) {
323
+ // Same pragmatic check the rest of the framework uses
324
+ // (safeSchema.EMAIL_RE shared so we don't carry parallel
325
+ // copies of the same /^[^\s@]+@[^\s@]+\.[^\s@]+$/ regex).
326
+ if (!safeSchema.EMAIL_RE.test(coerced)) {
325
327
  errors[f.name] = (f.label || f.name) + " must be a valid email address";
326
328
  continue;
327
329
  }
328
330
  }
329
331
  if (f.type === "url" && typeof coerced === "string") {
330
- try { new URL(coerced); }
331
- catch (_e) { errors[f.name] = (f.label || f.name) + " must be a valid URL"; continue; }
332
+ // Form `url` fields come from the request body — operator/external
333
+ // input. Route through safeUrl so the scheme allowlist is honored
334
+ // (https-only by default; operator opts in to http via field meta).
335
+ try {
336
+ safeUrl.parse(coerced, {
337
+ allowedProtocols: f.allowHttp ? safeUrl.ALLOW_HTTP_ALL : safeUrl.ALLOW_HTTP_TLS,
338
+ });
339
+ } catch (_e) {
340
+ errors[f.name] = (f.label || f.name) + " must be a valid URL";
341
+ continue;
342
+ }
332
343
  }
333
344
  if (typeof coerced === "string") {
334
345
  if (f.minlength !== undefined && coerced.length < Number(f.minlength)) {
package/lib/mail.js CHANGED
@@ -67,6 +67,7 @@ var audit = lazyRequire(function () { return require("./audit"); });
67
67
  var httpClient = lazyRequire(function () { return require("./http-client"); });
68
68
  var net = lazyRequire(function () { return require("net"); });
69
69
  var tls = lazyRequire(function () { return require("tls"); });
70
+ var safeSchema = require("./safe-schema");
70
71
  var validateOpts = require("./validate-opts");
71
72
  var { FrameworkError } = require("./framework-error");
72
73
 
@@ -80,10 +81,10 @@ class MailError extends FrameworkError {
80
81
  }
81
82
  }
82
83
 
83
- // Pragmatic email checksame shape as forms.validate. RFC 5322 in a
84
- // regex is a fool's errand; this catches obvious nonsense and lets
85
- // real-world addresses through.
86
- var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
84
+ // Pragmatic email regexshared with forms.validate / safe-schema.
85
+ // RFC 5322 in a regex is a fool's errand; this catches obvious nonsense
86
+ // and lets real-world addresses through.
87
+ var EMAIL_RE = safeSchema.EMAIL_RE;
87
88
 
88
89
  function _normalizeRecipientList(value, label) {
89
90
  if (value === undefined || value === null) return [];
@@ -45,9 +45,19 @@ var DEFAULT_BLOCKED_AGENTS = [
45
45
  ];
46
46
 
47
47
  var lazyRequire = require("../lazy-require");
48
+ var requestHelpers = require("../request-helpers");
48
49
  var validateOpts = require("../validate-opts");
49
50
  var audit = lazyRequire(function () { return require("../audit"); });
50
51
 
52
+ // Bot-guard's "trust the proxy header" semantics for actor.ip — the
53
+ // audit event records the apparent source even when behind a CDN.
54
+ // extractActorContext defaults to socket.remoteAddress; we override.
55
+ function _xffIp(req) {
56
+ var xff = req.headers && req.headers["x-forwarded-for"];
57
+ if (xff) return String(xff).split(",")[0].trim();
58
+ return (req.socket && req.socket.remoteAddress) || null;
59
+ }
60
+
51
61
  function create(opts) {
52
62
  opts = opts || {};
53
63
  validateOpts(opts, [
@@ -105,10 +115,7 @@ function create(opts) {
105
115
  req.suspectedBot = hit;
106
116
  try {
107
117
  audit().emit({
108
- actor: {
109
- ip: (req.headers && req.headers["x-forwarded-for"]) || (req.socket && req.socket.remoteAddress),
110
- userAgent: req.headers && req.headers["user-agent"],
111
- },
118
+ actor: requestHelpers.extractActorContext(req, { ip: _xffIp(req) }),
112
119
  action: "system.botguard.tag",
113
120
  outcome: "denied",
114
121
  reason: hit,
@@ -121,10 +128,7 @@ function create(opts) {
121
128
 
122
129
  try {
123
130
  audit().emit({
124
- actor: {
125
- ip: (req.headers && req.headers["x-forwarded-for"]) || (req.socket && req.socket.remoteAddress),
126
- userAgent: req.headers && req.headers["user-agent"],
127
- },
131
+ actor: requestHelpers.extractActorContext(req, { ip: _xffIp(req) }),
128
132
  action: "system.botguard.block",
129
133
  outcome: "denied",
130
134
  reason: hit,
@@ -66,6 +66,7 @@
66
66
  */
67
67
  var lazyRequire = require("../lazy-require");
68
68
  var forms = require("../forms");
69
+ var requestHelpers = require("../request-helpers");
69
70
  var validateOpts = require("../validate-opts");
70
71
  var audit = lazyRequire(function () { return require("../audit"); });
71
72
 
@@ -203,7 +204,7 @@ function create(opts) {
203
204
  audit().safeEmit({
204
205
  action: "auth.csrf.denied",
205
206
  outcome: "denied",
206
- actor: { ip: req.socket && req.socket.remoteAddress, userAgent: req.headers && req.headers["user-agent"] },
207
+ actor: requestHelpers.extractActorContext(req),
207
208
  reason: reason,
208
209
  metadata: { method: req.method, path: (req.url || "").split("?")[0] },
209
210
  });
@@ -45,6 +45,7 @@
45
45
  */
46
46
  var C = require("../constants");
47
47
  var lazyRequire = require("../lazy-require");
48
+ var requestHelpers = require("../request-helpers");
48
49
  var validateOpts = require("../validate-opts");
49
50
  var clusterStorage = require("../cluster-storage");
50
51
 
@@ -252,8 +253,11 @@ function create(opts) {
252
253
  if (verdict.retryAfter > 0) res.setHeader("Retry-After", String(verdict.retryAfter));
253
254
  }
254
255
  try {
256
+ // Override `ip` with the x-forwarded-for-aware client IP so the
257
+ // audit event carries the proxied origin even when extractActorContext
258
+ // would have read the socket address.
255
259
  audit().emit({
256
- actor: { ip: _clientIp(req), userAgent: req.headers && req.headers["user-agent"] },
260
+ actor: requestHelpers.extractActorContext(req, { ip: _clientIp(req) }),
257
261
  action: "system.ratelimit.block",
258
262
  outcome: "denied",
259
263
  reason: "rate limit exceeded",
@@ -29,6 +29,7 @@
29
29
  * }
30
30
  */
31
31
  var lazyRequire = require("../lazy-require");
32
+ var requestHelpers = require("../request-helpers");
32
33
  var validateOpts = require("../validate-opts");
33
34
  var audit = lazyRequire(function () { return require("../audit"); });
34
35
 
@@ -61,7 +62,7 @@ function create(opts) {
61
62
  audit().emit({
62
63
  action: "auth.required.denied",
63
64
  outcome: "denied",
64
- actor: { ip: req.socket && req.socket.remoteAddress, userAgent: req.headers && req.headers["user-agent"] },
65
+ actor: requestHelpers.extractActorContext(req),
65
66
  reason: "no authenticated user on request",
66
67
  metadata: { method: req.method, path: req.pathname || (req.url || "").split("?")[0] },
67
68
  });
@@ -54,6 +54,7 @@ var nodeCrypto = require("crypto");
54
54
  var sigv4 = require("./sigv4");
55
55
  var safeXml = require("../parsers/safe-xml");
56
56
  var safeUrl = require("../safe-url");
57
+ var template = require("../template");
57
58
  var httpClient = require("../http-client");
58
59
  var { ObjectStoreError } = require("../framework-error");
59
60
 
@@ -83,15 +84,18 @@ function _validateBucketName(name) {
83
84
  }
84
85
  }
85
86
 
86
- function _xmlEscape(s) {
87
- return String(s)
88
- .replace(/&/g, "&amp;")
89
- .replace(/</g, "&lt;")
90
- .replace(/>/g, "&gt;")
91
- .replace(/"/g, "&quot;")
92
- .replace(/'/g, "&apos;");
93
- }
87
+ // XML body strings flow through template.escapeHtml — `&#x27;` (which
88
+ // it emits for the apostrophe) is a numeric character reference and
89
+ // is valid in both XML and HTML, where `&apos;` is XML-only. AWS S3
90
+ // accepts both; using the shared HTML escape keeps the framework
91
+ // down to one canonical escape primitive.
92
+ var _xmlEscape = template.escapeHtml;
94
93
 
94
+ // AWS PutBucketLifecycle / PutBucketCors require a Content-MD5 header
95
+ // for body integrity (legacy AWS API requirement; SigV4 already covers
96
+ // integrity via x-amz-content-sha256 but the API still validates this).
97
+ // MD5 here is NOT a credential or security primitive — it's an AWS API
98
+ // shape. b.credentialHash is the wrong tool.
95
99
  function _md5Base64(buf) {
96
100
  return nodeCrypto.createHash("md5").update(buf).digest("base64");
97
101
  }
@@ -109,6 +109,7 @@
109
109
  * so the two stay distinct rather than one wrapping the other.
110
110
  */
111
111
 
112
+ var safeJson = require("./safe-json");
112
113
  var { defineClass } = require("./framework-error");
113
114
 
114
115
  var SafeSchemaError = defineClass("SafeSchemaError", { alwaysPermanent: true });
@@ -143,10 +144,17 @@ var ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
143
144
  // base64 (standard alphabet, with optional padding). Base64url variants
144
145
  // rejected — operators chain .regex(...) for that.
145
146
  var BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
146
- // IPv6 — covers full, compressed (::), and IPv4-mapped forms. Not
147
- // exhaustive on every legal corner, but rejects the common malformed
148
- // inputs operators actually see at HTTP boundaries.
149
- var IPV6_RE = /^(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|::(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{0,4}|(?:[0-9a-fA-F]{1,4}:){1,6}(?::[0-9a-fA-F]{1,4}){1,1}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5})$/;
147
+ // IPv6 structural pattern full 8-hextet, every `::`-compressed shape,
148
+ // `::` and `::1` literals, IPv4-mapped (`::ffff:1.2.3.4`), and 6-prefix
149
+ // + IPv4 tail. Adapted from validator.js (Apache-2.0); zone IDs
150
+ // (`fe80::1%eth0`) are deliberately omitted — the framework rejects
151
+ // them as non-portable, matching `safe-json.formats.ipv6`. Bounded
152
+ // quantifiers, no nested-quantifier alternation, ReDoS-safe.
153
+ //
154
+ // `.ipv6()` schema method delegates to `safeJson.formats.ipv6` for
155
+ // stricter algorithmic validation; this regex is exported as a
156
+ // structural pattern for operators who want it directly.
157
+ var IPV6_RE = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
150
158
 
151
159
  // ---- helpers ----
152
160
 
@@ -424,22 +432,33 @@ function _stringMethods(schema, spec) {
424
432
  _fail(p, "string/datetime", "must be an ISO-8601 datetime with timezone");
425
433
  });
426
434
  };
435
+ // IP-address validators delegate to safe-json's algorithmic format
436
+ // checks rather than re-running regex matches. The algorithmic path
437
+ // handles edge cases that pure regex misses (compressed `::` shapes,
438
+ // IPv4-mapped `::ffff:1.2.3.4`, multi-`::` rejection, group-count
439
+ // bounds) and keeps the framework's IP-validation behavior in one
440
+ // tested place. IPV4_RE / IPV6_RE remain exported for operators who
441
+ // want the structural pattern, but `.ipv4()` / `.ipv6()` / `.ip()`
442
+ // are the canonical validation surface.
427
443
  schema.ipv4 = function () {
428
444
  return chain(function (v, p) {
429
- return IPV4_RE.test(v) ? { ok: true } :
430
- _fail(p, "string/ipv4", "must be a valid IPv4 address");
445
+ return (typeof v === "string" && safeJson.formats.ipv4(v))
446
+ ? { ok: true }
447
+ : _fail(p, "string/ipv4", "must be a valid IPv4 address");
431
448
  });
432
449
  };
433
450
  schema.ipv6 = function () {
434
451
  return chain(function (v, p) {
435
- return IPV6_RE.test(v) ? { ok: true } :
436
- _fail(p, "string/ipv6", "must be a valid IPv6 address");
452
+ return (typeof v === "string" && safeJson.formats.ipv6(v))
453
+ ? { ok: true }
454
+ : _fail(p, "string/ipv6", "must be a valid IPv6 address");
437
455
  });
438
456
  };
439
457
  schema.ip = function () {
440
458
  return chain(function (v, p) {
441
- return (IPV4_RE.test(v) || IPV6_RE.test(v)) ? { ok: true } :
442
- _fail(p, "string/ip", "must be a valid IP address (v4 or v6)");
459
+ return (typeof v === "string" && safeJson.formats.ip(v))
460
+ ? { ok: true }
461
+ : _fail(p, "string/ip", "must be a valid IP address (v4 or v6)");
443
462
  });
444
463
  };
445
464
  schema.cuid = function () {
@@ -1173,4 +1192,17 @@ module.exports = {
1173
1192
 
1174
1193
  // Errors
1175
1194
  SafeSchemaError: SafeSchemaError,
1195
+
1196
+ // Validation regexes — exported so other modules don't re-declare
1197
+ // their own copies. Pragmatic patterns; operators wanting RFC-strict
1198
+ // behavior chain `.refine()` on top of the schema instead.
1199
+ EMAIL_RE: EMAIL_RE,
1200
+ URL_RE: URL_RE,
1201
+ UUID_RE: UUID_RE,
1202
+ DATE_RE: DATE_RE,
1203
+ DATETIME_RE: DATETIME_RE,
1204
+ IPV4_RE: IPV4_RE,
1205
+ IPV6_RE: IPV6_RE,
1206
+ CUID_RE: CUID_RE,
1207
+ ULID_RE: ULID_RE,
1176
1208
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",