@blamejs/core 0.10.9 → 0.10.12

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.
@@ -196,6 +196,38 @@ function create(opts) {
196
196
  var profile = opts.profile || "strict";
197
197
  var authConfig = opts.auth || null;
198
198
  var mailStore = opts.mailStore;
199
+ // b.agent.tenant adoption (v0.10.12) — cross-tenant authentication
200
+ // is refused at the AUTH-success boundary BEFORE the listener
201
+ // accepts the actor into transaction state. The scope's `.check`
202
+ // method is validated at create() time so a malformed scope object
203
+ // surfaces as a configuration error rather than rejecting every
204
+ // otherwise-valid auth as "cross-tenant".
205
+ var tenantScope = opts.tenantScope || null;
206
+ var agentTenantId = opts.agentTenantId || null;
207
+ if (tenantScope && typeof tenantScope.check !== "function") {
208
+ throw new MailServerPop3Error("mail-server-pop3/bad-tenant-scope",
209
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance " +
210
+ "(missing .check); a malformed scope would refuse every auth as cross-tenant");
211
+ }
212
+ if (tenantScope && !agentTenantId) {
213
+ throw new MailServerPop3Error("mail-server-pop3/no-agent-tenant-id",
214
+ "create: opts.tenantScope requires opts.agentTenantId");
215
+ }
216
+
217
+ function _assertTenantOrRefuse(state, socket, result) {
218
+ if (!tenantScope || !agentTenantId) return true;
219
+ try { tenantScope.check(result.actor, agentTenantId); return true; }
220
+ catch (tenantErr) {
221
+ _emit("mail.server.pop3.cross_tenant_refused",
222
+ { connectionId: state.id,
223
+ actorTenant: (result.actor && result.actor.tenantId) || null,
224
+ agentTenant: agentTenantId,
225
+ code: (tenantErr && tenantErr.code) || null },
226
+ "denied");
227
+ _writeErr(socket, "Authentication rejected (cross-tenant)");
228
+ return false;
229
+ }
230
+ }
199
231
 
200
232
  var rateLimit;
201
233
  if (opts.rateLimit === false) {
@@ -467,6 +499,7 @@ function create(opts) {
467
499
  })
468
500
  .then(function (result) {
469
501
  if (result && result.ok && result.actor) {
502
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
470
503
  state.actor = result.actor;
471
504
  _enterTransaction(state, socket, "PASS");
472
505
  return;
@@ -527,6 +560,7 @@ function create(opts) {
527
560
  })
528
561
  .then(function (result) {
529
562
  if (result && result.ok && result.actor) {
563
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
530
564
  state.actor = result.actor;
531
565
  _enterTransaction(state, socket, "APOP");
532
566
  return;
@@ -595,6 +629,7 @@ function create(opts) {
595
629
  })
596
630
  .then(function (result) {
597
631
  if (result && result.ok && result.actor) {
632
+ if (!_assertTenantOrRefuse(state, socket, result)) return;
598
633
  state.actor = result.actor;
599
634
  _enterTransaction(state, socket, "AUTH/" + mech);
600
635
  return;
@@ -0,0 +1,363 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.mail.serverRegistry
4
+ * @nav Mail
5
+ * @title Mail Server Method Registry
6
+ *
7
+ * @intro
8
+ * Shared per-method dispatch registry for the IMAP / JMAP /
9
+ * ManageSieve listener factories. Replaces the hand-rolled
10
+ * `switch (verb)` dispatchers with a single primitive that:
11
+ *
12
+ * - runs the protocol-specific guard chain BEFORE the handler
13
+ * lookup (so operator-supplied overrides cannot bypass
14
+ * `b.guardImapCommand` / `b.guardJmap` / `b.guardManagesieveCommand`),
15
+ * - enforces per-handler resource budgets (`maxHandlerBytes`,
16
+ * `maxHandlerMs`) — refused at registration time if not supplied,
17
+ * - emits a `mail.serverRegistry.method_dispatch` audit event with
18
+ * handler-source (`builtin` | `operator-override`),
19
+ * - rejects registrations of method names outside the per-protocol
20
+ * IANA / RFC catalogue (unless `allowExperimental: true`, which
21
+ * itself audits).
22
+ *
23
+ * Operators wanting to override IMAP `FETCH`, JMAP `Email/query`,
24
+ * ManageSieve `PUTSCRIPT`, etc. supply
25
+ * `opts.overrides: { "FETCH": { fn, maxHandlerBytes, maxHandlerMs } }`
26
+ * to the listener factory and the registry routes dispatch through
27
+ * the override without touching wire-protocol state, audit
28
+ * lifecycle, or the guard substrate.
29
+ *
30
+ * Per-handler resource budgets are required (no auto-defaults) per
31
+ * the framework's security-defaults-on rule: an operator-supplied
32
+ * FETCH override that omits a budget could regress CVE-2024-34055
33
+ * (Cyrus authenticated OOM via unbounded allocation); the
34
+ * stricter-mode contract forces the operator to acknowledge the
35
+ * resource ceiling explicitly.
36
+ *
37
+ * @card
38
+ * Per-method dispatch registry for the mail-server listeners. Guard-chain preserving, per-handler resource-budget required, audit on every dispatch.
39
+ */
40
+
41
+ var safeAsync = require("./safe-async");
42
+ var validateOpts = require("./validate-opts");
43
+ var audit = require("./audit");
44
+ var { defineClass } = require("./framework-error");
45
+
46
+ var MailServerRegistryError = defineClass("MailServerRegistryError", { alwaysPermanent: true });
47
+
48
+ // Per-protocol RFC catalogue. Names outside these tables refuse at
49
+ // register() unless `allowExperimental: true`.
50
+ var IMAP_VERBS = Object.freeze({
51
+ CAPABILITY: 1, NOOP: 1, LOGOUT: 1, STARTTLS: 1, AUTHENTICATE: 1,
52
+ LOGIN: 1, ID: 1, ENABLE: 1, SELECT: 1, EXAMINE: 1, CREATE: 1,
53
+ DELETE: 1, RENAME: 1, SUBSCRIBE: 1, UNSUBSCRIBE: 1, LIST: 1,
54
+ NAMESPACE: 1, STATUS: 1, APPEND: 1, IDLE: 1, CHECK: 1, CLOSE: 1,
55
+ UNSELECT: 1, EXPUNGE: 1, SEARCH: 1, FETCH: 1, STORE: 1, COPY: 1,
56
+ MOVE: 1, UID: 1, DONE: 1,
57
+ });
58
+
59
+ var MANAGESIEVE_VERBS = Object.freeze({
60
+ AUTHENTICATE: 1, STARTTLS: 1, LOGOUT: 1, CAPABILITY: 1, HAVESPACE: 1,
61
+ PUTSCRIPT: 1, LISTSCRIPTS: 1, SETACTIVE: 1, GETSCRIPT: 1,
62
+ DELETESCRIPT: 1, RENAMESCRIPT: 1, NOOP: 1,
63
+ });
64
+
65
+ // JMAP method names match the `Type/verb` shape. The catalogue table
66
+ // enumerates the RFC 8620 + RFC 8621 set; operator-registered types
67
+ // must opt in via `allowExperimental: true` with audit emission.
68
+ var JMAP_METHODS = Object.freeze({
69
+ "Core/echo": 1,
70
+ "Mailbox/get": 1, "Mailbox/changes": 1, "Mailbox/query": 1,
71
+ "Mailbox/queryChanges": 1, "Mailbox/set": 1,
72
+ "Thread/get": 1, "Thread/changes": 1,
73
+ "Email/get": 1, "Email/changes": 1, "Email/query": 1,
74
+ "Email/queryChanges": 1, "Email/set": 1, "Email/copy": 1,
75
+ "Email/import": 1, "Email/parse": 1,
76
+ "SearchSnippet/get": 1,
77
+ "Identity/get": 1, "Identity/changes": 1, "Identity/set": 1,
78
+ "EmailSubmission/get": 1, "EmailSubmission/changes": 1,
79
+ "EmailSubmission/query": 1, "EmailSubmission/queryChanges": 1,
80
+ "EmailSubmission/set": 1,
81
+ "VacationResponse/get": 1, "VacationResponse/set": 1,
82
+ });
83
+
84
+ var CATALOGUE = Object.freeze({
85
+ imap: IMAP_VERBS,
86
+ jmap: JMAP_METHODS,
87
+ managesieve: MANAGESIEVE_VERBS,
88
+ });
89
+
90
+ // Maximum resource budget caps. Operators cannot raise above these
91
+ // even with explicit values — protects against accidental
92
+ // configuration that lifts the CVE-2024-34055 / CVE-2026-26312 OOM
93
+ // ceiling.
94
+ var MAX_HANDLER_BYTES_CEILING = 256 * 1024 * 1024; // allow:raw-byte-literal — 256 MiB per-handler ceiling
95
+ var MAX_HANDLER_MS_CEILING = 5 * 60 * 1000; // allow:raw-time-literal — 5 minute per-handler ceiling
96
+
97
+ /**
98
+ * @primitive b.mail.serverRegistry.create
99
+ * @signature b.mail.serverRegistry.create(opts)
100
+ * @since 0.10.11
101
+ * @status stable
102
+ * @related b.mail.server.imap.create, b.mail.server.jmap.create, b.mail.server.managesieve.create
103
+ *
104
+ * Build a per-method dispatch registry for one of the mail-server
105
+ * listeners. Returns `{ register, unregister, dispatch, list,
106
+ * has, source, MailServerRegistryError }`.
107
+ *
108
+ * @opts
109
+ * protocol: "imap" | "jmap" | "managesieve",
110
+ * defaults: { [name]: { fn, maxHandlerBytes, maxHandlerMs } },
111
+ * overrides: { [name]: { fn, maxHandlerBytes, maxHandlerMs } },
112
+ * notFoundHandler: function (name, ctx), // optional; returns the protocol's "not configured" reply
113
+ *
114
+ * @example
115
+ * var reg = b.mail.serverRegistry.create({
116
+ * protocol: "imap",
117
+ * defaults: { CAPABILITY: { fn: _capabilityHandler,
118
+ * maxHandlerBytes: 8 * 1024,
119
+ * maxHandlerMs: 5 * 1000 } },
120
+ * overrides: opts.overrides || {},
121
+ * });
122
+ * await reg.dispatch("CAPABILITY", state, socket, parsed);
123
+ */
124
+ function create(opts) {
125
+ validateOpts.requireObject(opts, "b.mail.serverRegistry.create",
126
+ MailServerRegistryError, "mail-server-registry/bad-opts");
127
+ validateOpts.requireNonEmptyString(opts.protocol,
128
+ "b.mail.serverRegistry.create: protocol", MailServerRegistryError,
129
+ "mail-server-registry/bad-protocol");
130
+ if (!CATALOGUE[opts.protocol]) {
131
+ throw new MailServerRegistryError("mail-server-registry/unknown-protocol",
132
+ "create: protocol must be 'imap', 'jmap', or 'managesieve' (got '" + opts.protocol + "')");
133
+ }
134
+ // Tenant scope (v0.10.12 — b.agent.tenant adoption).
135
+ // When `opts.tenantScope` is supplied alongside `opts.agentTenantId`,
136
+ // every dispatch first gates on `tenantScope.check(actor,
137
+ // agentTenantId)`. Actor without matching tenantId surfaces as a
138
+ // typed `agent-tenant/cross-tenant-access-refused` per the v0.9.25
139
+ // contract — the listener's catch path converts that into the
140
+ // protocol's `BAD AUTH` / `NO not authorized` reply.
141
+ //
142
+ // Optional: when omitted, dispatch behaves identically to v0.10.11
143
+ // (no per-tenant gate; operators that don't run multi-tenant don't
144
+ // pay the check cost).
145
+ var tenantScope = opts.tenantScope || null;
146
+ var agentTenantId = opts.agentTenantId || null;
147
+ if (tenantScope && typeof tenantScope.check !== "function") {
148
+ throw new MailServerRegistryError("mail-server-registry/bad-tenant-scope",
149
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance (missing .check)");
150
+ }
151
+ if (tenantScope && !agentTenantId) {
152
+ throw new MailServerRegistryError("mail-server-registry/no-agent-tenant-id",
153
+ "create: opts.tenantScope requires opts.agentTenantId (the tenant this listener serves)");
154
+ }
155
+ var catalogue = CATALOGUE[opts.protocol];
156
+ var entries = Object.create(null);
157
+
158
+ function _validateEntry(name, entry, source) {
159
+ if (!entry || typeof entry !== "object") {
160
+ throw new MailServerRegistryError("mail-server-registry/bad-entry",
161
+ "register: entry for '" + name + "' must be an object");
162
+ }
163
+ if (typeof entry.fn !== "function") {
164
+ throw new MailServerRegistryError("mail-server-registry/bad-handler-fn",
165
+ "register: entry.fn for '" + name + "' must be a function");
166
+ }
167
+ if (typeof entry.maxHandlerBytes !== "number" || !isFinite(entry.maxHandlerBytes) ||
168
+ entry.maxHandlerBytes < 1 || entry.maxHandlerBytes > MAX_HANDLER_BYTES_CEILING ||
169
+ Math.floor(entry.maxHandlerBytes) !== entry.maxHandlerBytes) {
170
+ throw new MailServerRegistryError("mail-server-registry/bad-max-handler-bytes",
171
+ "register: '" + name + "' entry.maxHandlerBytes must be a positive integer ≤ " +
172
+ MAX_HANDLER_BYTES_CEILING + " (got " + entry.maxHandlerBytes + ") — stricter-mode " +
173
+ "registration refuses entries without an explicit budget (defends CVE-2024-34055 / " +
174
+ "CVE-2026-26312 OOM class)");
175
+ }
176
+ if (typeof entry.maxHandlerMs !== "number" || !isFinite(entry.maxHandlerMs) ||
177
+ entry.maxHandlerMs < 1 || entry.maxHandlerMs > MAX_HANDLER_MS_CEILING ||
178
+ Math.floor(entry.maxHandlerMs) !== entry.maxHandlerMs) {
179
+ throw new MailServerRegistryError("mail-server-registry/bad-max-handler-ms",
180
+ "register: '" + name + "' entry.maxHandlerMs must be a positive integer ≤ " +
181
+ MAX_HANDLER_MS_CEILING + " (got " + entry.maxHandlerMs + ")");
182
+ }
183
+ if (!catalogue[name] && entry.allowExperimental !== true) {
184
+ throw new MailServerRegistryError("mail-server-registry/unknown-method",
185
+ "register: '" + name + "' is not in the " + opts.protocol + " catalogue; pass " +
186
+ "allowExperimental: true to opt out of the catalogue gate (audited)");
187
+ }
188
+ if (entry.allowExperimental === true && !catalogue[name]) {
189
+ try {
190
+ audit.safeEmit({
191
+ action: "mail.serverRegistry.experimental_registration",
192
+ outcome: "denied",
193
+ metadata: { protocol: opts.protocol, name: name, source: source,
194
+ severity: "warning" },
195
+ });
196
+ } catch (_e) { /* drop-silent */ }
197
+ }
198
+ }
199
+
200
+ function register(name, entry) {
201
+ if (typeof name !== "string" || name.length === 0) {
202
+ throw new MailServerRegistryError("mail-server-registry/bad-name",
203
+ "register: name must be a non-empty string");
204
+ }
205
+ var source = entry && entry.source === "operator-override"
206
+ ? "operator-override" : "operator-override"; // user-facing register defaults to override
207
+ _validateEntry(name, entry, source);
208
+ entries[name] = {
209
+ fn: entry.fn,
210
+ maxHandlerBytes: entry.maxHandlerBytes,
211
+ maxHandlerMs: entry.maxHandlerMs,
212
+ source: source,
213
+ allowExperimental: entry.allowExperimental === true,
214
+ };
215
+ }
216
+
217
+ function _internalRegister(name, entry, source) {
218
+ _validateEntry(name, entry, source);
219
+ entries[name] = {
220
+ fn: entry.fn,
221
+ maxHandlerBytes: entry.maxHandlerBytes,
222
+ maxHandlerMs: entry.maxHandlerMs,
223
+ source: source,
224
+ allowExperimental: entry.allowExperimental === true,
225
+ };
226
+ }
227
+
228
+ // Seed defaults first, then operator overrides shadow them.
229
+ if (opts.defaults && typeof opts.defaults === "object") {
230
+ var dnames = Object.keys(opts.defaults);
231
+ for (var di = 0; di < dnames.length; di += 1) {
232
+ _internalRegister(dnames[di], opts.defaults[dnames[di]], "builtin");
233
+ }
234
+ }
235
+ if (opts.overrides && typeof opts.overrides === "object") {
236
+ var onames = Object.keys(opts.overrides);
237
+ for (var oi = 0; oi < onames.length; oi += 1) {
238
+ _internalRegister(onames[oi], opts.overrides[onames[oi]], "operator-override");
239
+ }
240
+ }
241
+
242
+ function unregister(name) {
243
+ if (entries[name]) {
244
+ delete entries[name];
245
+ return true;
246
+ }
247
+ return false;
248
+ }
249
+
250
+ function has(name) { return entries[name] !== undefined; }
251
+ function source(name) { return entries[name] ? entries[name].source : null; }
252
+
253
+ function list() {
254
+ var out = [];
255
+ var names = Object.keys(entries).sort(); // allow:bare-canonicalize-walk — deterministic output ordering
256
+ for (var i = 0; i < names.length; i += 1) {
257
+ out.push({
258
+ name: names[i],
259
+ source: entries[names[i]].source,
260
+ hasBudget: true,
261
+ });
262
+ }
263
+ return out;
264
+ }
265
+
266
+ /**
267
+ * Dispatch a registered method. `name` is the per-protocol verb /
268
+ * method-name; `args` is variadic forwarded to the handler. The
269
+ * registry wraps the handler in `safeAsync.withTimeout` so a
270
+ * runaway handler can't pin the connection. On not-found, returns
271
+ * the protocol's `notFoundHandler` result (or throws if none).
272
+ */
273
+ function dispatch(name) {
274
+ var argsArr = Array.prototype.slice.call(arguments, 1);
275
+ // Tenant scope check — pre-dispatch, pre-guard, pre-audit.
276
+ //
277
+ // Two argument shapes occur across the three listeners:
278
+ // - IMAP / ManageSieve dispatch with `(state, socket, parsed)` —
279
+ // state.actor is the actor.
280
+ // - JMAP dispatches with `(actor, resolvedArgs, ctx)` — the
281
+ // first argument IS the actor object directly.
282
+ //
283
+ // Detect both shapes: if argsArr[0].actor exists, use it; else if
284
+ // argsArr[0] itself carries a `tenantId` field, treat it as the
285
+ // actor. The dispatch shapes are documented at the listener
286
+ // factory layer; the registry's job here is uniform enforcement.
287
+ if (tenantScope && argsArr.length > 0 && argsArr[0]) {
288
+ var actor = argsArr[0].actor ||
289
+ (typeof argsArr[0] === "object" && argsArr[0] !== null &&
290
+ Object.prototype.hasOwnProperty.call(argsArr[0], "tenantId")
291
+ ? argsArr[0] : null);
292
+ if (actor) {
293
+ // tenantScope.check throws AgentTenantError on cross-tenant;
294
+ // we let the typed error propagate so the listener's
295
+ // catch-path converts it to the protocol's refusal reply.
296
+ tenantScope.check(actor, agentTenantId);
297
+ }
298
+ }
299
+ var entry = entries[name];
300
+ if (!entry) {
301
+ if (typeof opts.notFoundHandler === "function") {
302
+ return opts.notFoundHandler.apply(null, [name].concat(argsArr));
303
+ }
304
+ throw new MailServerRegistryError("mail-server-registry/not-configured",
305
+ "dispatch: '" + name + "' has no registered handler (" + opts.protocol +
306
+ " protocol; supply via opts.defaults or opts.overrides)");
307
+ }
308
+ var t0 = Date.now();
309
+ try {
310
+ audit.safeEmit({
311
+ action: "mail.serverRegistry.method_dispatch",
312
+ outcome: "success",
313
+ metadata: { protocol: opts.protocol, name: name, source: entry.source },
314
+ });
315
+ } catch (_e) { /* drop-silent */ }
316
+ var result;
317
+ try { result = entry.fn.apply(null, argsArr); }
318
+ catch (err) {
319
+ throw new MailServerRegistryError("mail-server-registry/handler-threw",
320
+ "dispatch: '" + name + "' handler threw (" + ((err && err.message) || String(err)) + ")");
321
+ }
322
+ // Wrap promise-returning handlers in safeAsync.withTimeout so a
323
+ // runaway handler can't pin the connection past maxHandlerMs.
324
+ // safeAsync raises its own `async/timeout` error; map it into a
325
+ // typed MailServerRegistryError so the listener catch path sees a
326
+ // single error class.
327
+ if (result && typeof result.then === "function") {
328
+ var timeoutMs = entry.maxHandlerMs;
329
+ var handlerName = name;
330
+ return safeAsync.withTimeout(result, timeoutMs,
331
+ { name: opts.protocol + "/" + handlerName })
332
+ .catch(function (err) {
333
+ if (err && err.code === "async/timeout") {
334
+ throw new MailServerRegistryError("mail-server-registry/handler-timeout",
335
+ "dispatch: '" + handlerName + "' exceeded maxHandlerMs=" + timeoutMs + " (" +
336
+ (Date.now() - t0) + "ms elapsed)");
337
+ }
338
+ throw err;
339
+ });
340
+ }
341
+ return result;
342
+ }
343
+
344
+ return {
345
+ register: register,
346
+ unregister: unregister,
347
+ dispatch: dispatch,
348
+ list: list,
349
+ has: has,
350
+ source: source,
351
+ protocol: opts.protocol,
352
+ MailServerRegistryError: MailServerRegistryError,
353
+ };
354
+ }
355
+
356
+ module.exports = {
357
+ create: create,
358
+ CATALOGUE: CATALOGUE,
359
+ IMAP_VERBS: Object.keys(IMAP_VERBS),
360
+ JMAP_METHODS: Object.keys(JMAP_METHODS),
361
+ MANAGESIEVE_VERBS: Object.keys(MANAGESIEVE_VERBS),
362
+ MailServerRegistryError: MailServerRegistryError,
363
+ };
@@ -266,6 +266,18 @@ function create(opts) {
266
266
  throw new MailServerSubmissionError("mail-server-submission/no-tls-context",
267
267
  "mail.server.submission.create: tlsContext is required");
268
268
  }
269
+ // b.agent.tenant shape validation at create() time — a malformed
270
+ // scope object would refuse every auth as cross-tenant, masking the
271
+ // configuration error as an auth outage.
272
+ if (opts.tenantScope && typeof opts.tenantScope.check !== "function") {
273
+ throw new MailServerSubmissionError("mail-server-submission/bad-tenant-scope",
274
+ "create: opts.tenantScope must be a b.agent.tenant.create() instance " +
275
+ "(missing .check); a malformed scope would refuse every auth as cross-tenant");
276
+ }
277
+ if (opts.tenantScope && !opts.agentTenantId) {
278
+ throw new MailServerSubmissionError("mail-server-submission/no-agent-tenant-id",
279
+ "create: opts.tenantScope requires opts.agentTenantId");
280
+ }
269
281
  numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
270
282
  ["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
271
283
  "mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
@@ -742,6 +754,27 @@ function create(opts) {
742
754
  // successful verify, not whatever state.authPending happens
743
755
  // to be at the post-null read (which is always null).
744
756
  var successfulMechanism = state.authPending && state.authPending.mechanism;
757
+ // b.agent.tenant gate (v0.10.12). When the listener is
758
+ // wired with `opts.tenantScope` + `opts.agentTenantId`,
759
+ // every authenticated actor must belong to the listener's
760
+ // tenant. Cross-tenant authentication surfaces here as a
761
+ // `535 5.7.0` refusal — the actor never reaches authenticated
762
+ // state, mail submission never begins under the wrong tenant.
763
+ if (opts.tenantScope && opts.agentTenantId) {
764
+ try { opts.tenantScope.check(result.actor, opts.agentTenantId); }
765
+ catch (tenantErr) {
766
+ state.authPending = null;
767
+ _emit("mail.server.submission.cross_tenant_refused",
768
+ { connectionId: state.id,
769
+ actorTenant: (result.actor && result.actor.tenantId) || null,
770
+ agentTenant: opts.agentTenantId,
771
+ code: (tenantErr && tenantErr.code) || null },
772
+ "denied");
773
+ _writeReply(socket, REPLY_535_AUTH_FAILED,
774
+ "5.7.0 Authentication rejected (cross-tenant)");
775
+ return;
776
+ }
777
+ }
745
778
  state.authenticated = true;
746
779
  state.actor = result.actor;
747
780
  state.authPending = null;
package/lib/mcp.js CHANGED
@@ -192,8 +192,18 @@ function _checkRedirectUri(uri, allowlist, errorClass) {
192
192
  "mcp: redirect_uri did not parse");
193
193
  }
194
194
  var isHttps = parsed.protocol === "https:";
195
- var isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" ||
196
- parsed.hostname === "::1";
195
+ // Strip the trailing root-zone dot before the reserved-name compare.
196
+ // RFC 1034 §3.1 `localhost.` is the absolute form of `localhost`
197
+ // and resolves to the same target; without the strip, an attacker
198
+ // who supplies `localhost.` as the redirect_uri host slips past the
199
+ // local-allow path (the URL parser preserves the dot, the equality
200
+ // check fails, the URI gets routed as cleartext non-local — RFC 9700
201
+ // §4.1.1 bypass).
202
+ var rawHost = parsed.hostname || "";
203
+ while (rawHost.length > 0 && rawHost.charAt(rawHost.length - 1) === ".") {
204
+ rawHost = rawHost.slice(0, -1);
205
+ }
206
+ var isLocal = rawHost === "localhost" || rawHost === "127.0.0.1" || rawHost === "::1";
197
207
  if (!isHttps && !isLocal) {
198
208
  throw errorClass.factory("INSECURE_REDIRECT_URI",
199
209
  "mcp: redirect_uri must be HTTPS (or localhost; RFC 9700 sec 4.1.1)");
@@ -54,6 +54,15 @@ var LOCAL_SUFFIXES = [".localhost", ".local", ".test", ".invalid",
54
54
  ".internal", ".intranet", ".lan", ".home", ".corp"];
55
55
  function _isLocalFormHost(host) {
56
56
  if (typeof host !== "string" || host.length === 0) return true;
57
+ // Strip the trailing root-zone dot BEFORE any reserved-name compare.
58
+ // RFC 1034 §3.1 — `foo.` is the absolute form of `foo` (both resolve
59
+ // to the same target). Without the strip, `localhost.` would slip
60
+ // past the reserved-form check and reach a public DoH/DoT provider
61
+ // that maps it to NXDOMAIN, which downstream consumers might then
62
+ // try to resolve via system fallback.
63
+ while (host.length > 0 && host.charAt(host.length - 1) === ".") {
64
+ host = host.slice(0, -1);
65
+ }
57
66
  if (host === "localhost") return true;
58
67
  // IP literal — skip DNS resolution entirely (caller passes through).
59
68
  if (net.isIP(host)) return true;
package/lib/pagination.js CHANGED
@@ -87,7 +87,8 @@ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
87
87
 
88
88
  function _b64urlDecode(s) {
89
89
  if (typeof s !== "string") throw new PaginationError("pagination/bad-cursor", "cursor must be a string");
90
- return bCrypto.fromBase64Url(s);
90
+ try { return bCrypto.fromBase64Url(s); }
91
+ catch (_e) { throw new PaginationError("pagination/bad-cursor", "cursor is not valid base64url"); }
91
92
  }
92
93
 
93
94
  function _tag(secretBuf, stateJson) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.10.9",
3
+ "version": "0.10.12",
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.6",
5
- "serialNumber": "urn:uuid:d3396b9d-5788-4743-8236-73318b661112",
5
+ "serialNumber": "urn:uuid:c0157c5c-5ef3-45a3-aef2-004727f9fd8c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-18T01:44:25.642Z",
8
+ "timestamp": "2026-05-18T06:22:30.244Z",
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.10.9",
22
+ "bom-ref": "@blamejs/core@0.10.12",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.10.9",
25
+ "version": "0.10.12",
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.10.9",
29
+ "purl": "pkg:npm/%40blamejs/core@0.10.12",
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.10.9",
57
+ "ref": "@blamejs/core@0.10.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]