@blamejs/core 0.9.46 → 0.10.1

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 (78) hide show
  1. package/CHANGELOG.md +951 -893
  2. package/index.js +30 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +67 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/compliance.js +176 -8
  25. package/lib/crypto-field.js +114 -14
  26. package/lib/crypto.js +216 -20
  27. package/lib/db.js +1 -0
  28. package/lib/guard-imap-command.js +335 -0
  29. package/lib/guard-jmap.js +321 -0
  30. package/lib/guard-managesieve-command.js +566 -0
  31. package/lib/guard-pop3-command.js +317 -0
  32. package/lib/guard-smtp-command.js +58 -3
  33. package/lib/mail-agent.js +20 -7
  34. package/lib/mail-arc-sign.js +12 -8
  35. package/lib/mail-auth.js +323 -34
  36. package/lib/mail-crypto-pgp.js +934 -0
  37. package/lib/mail-crypto-smime.js +340 -0
  38. package/lib/mail-crypto.js +108 -0
  39. package/lib/mail-dav.js +1224 -0
  40. package/lib/mail-deploy.js +492 -0
  41. package/lib/mail-dkim.js +431 -26
  42. package/lib/mail-journal.js +435 -0
  43. package/lib/mail-scan.js +502 -0
  44. package/lib/mail-server-imap.js +1102 -0
  45. package/lib/mail-server-jmap.js +488 -0
  46. package/lib/mail-server-managesieve.js +853 -0
  47. package/lib/mail-server-mx.js +164 -34
  48. package/lib/mail-server-pop3.js +836 -0
  49. package/lib/mail-server-rate-limit.js +269 -0
  50. package/lib/mail-server-submission.js +1032 -0
  51. package/lib/mail-server-tls.js +445 -0
  52. package/lib/mail-sieve.js +557 -0
  53. package/lib/mail-spam-score.js +284 -0
  54. package/lib/mail.js +99 -0
  55. package/lib/metrics.js +130 -10
  56. package/lib/middleware/dpop.js +58 -3
  57. package/lib/middleware/idempotency-key.js +255 -42
  58. package/lib/middleware/protected-resource-metadata.js +114 -2
  59. package/lib/network-dns-resolver.js +33 -0
  60. package/lib/network-tls.js +46 -0
  61. package/lib/outbox.js +62 -12
  62. package/lib/pqc-agent.js +13 -5
  63. package/lib/retry.js +23 -9
  64. package/lib/router.js +23 -1
  65. package/lib/safe-ical.js +634 -0
  66. package/lib/safe-icap.js +502 -0
  67. package/lib/safe-mime.js +15 -0
  68. package/lib/safe-sieve.js +684 -0
  69. package/lib/safe-smtp.js +57 -0
  70. package/lib/safe-url.js +37 -0
  71. package/lib/safe-vcard.js +473 -0
  72. package/lib/self-update-standalone-verifier.js +32 -3
  73. package/lib/self-update.js +168 -17
  74. package/lib/vendor/MANIFEST.json +161 -156
  75. package/lib/vendor-data.js +127 -9
  76. package/lib/vex.js +324 -59
  77. package/package.json +1 -1
  78. package/sbom.cdx.json +6 -6
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardImapCommand
4
+ * @nav Guards
5
+ * @title Guard IMAP Command
6
+ * @order 451
7
+ *
8
+ * @intro
9
+ * IMAP command-line validator (RFC 9051 IMAP4rev2; obsoletes
10
+ * RFC 3501). Gates every command-line the framework's inbound
11
+ * IMAP listener accepts from peers — `CAPABILITY` / `NOOP` /
12
+ * `LOGOUT` / `STARTTLS` / `AUTHENTICATE` / `LOGIN` / `ENABLE` /
13
+ * `SELECT` / `EXAMINE` / `CREATE` / `DELETE` / `RENAME` /
14
+ * `SUBSCRIBE` / `UNSUBSCRIBE` / `LIST` / `NAMESPACE` / `STATUS` /
15
+ * `APPEND` / `IDLE` / `CHECK` / `CLOSE` / `UNSELECT` / `EXPUNGE` /
16
+ * `SEARCH` / `FETCH` / `STORE` / `COPY` / `MOVE` / `UID` /
17
+ * `GETQUOTA` / `SETQUOTA` / `GETQUOTAROOT` / `ID`.
18
+ *
19
+ * ## Smuggling defense — bare-CR / bare-LF refusal
20
+ *
21
+ * Same wire-protocol smuggling class as SMTP: implementations that
22
+ * accept bare-CR or bare-LF in a command line let a hostile peer
23
+ * inject a second command past a per-line filter. RFC 9051 §2.2.1
24
+ * requires CRLF only; this validator refuses every bare CR / bare
25
+ * LF / NUL / C0 / DEL byte outside of explicit literal blocks
26
+ * (which the wire-protocol reader has already framed before
27
+ * handing the line to this validator).
28
+ *
29
+ * ## Literal-injection defense
30
+ *
31
+ * IMAP carries inline length-prefixed literals: `{n}<CRLF><n bytes>`.
32
+ * Per RFC 9051 §2.2.2 the literal opener `{n}` MUST appear at the
33
+ * end of a command line, with the n bytes following on subsequent
34
+ * line(s). RFC 7888 LITERAL+ relaxes the round-trip but is only
35
+ * honored post-AUTH. The validator detects literal openers as
36
+ * either:
37
+ *
38
+ * - well-formed: `{42}` or `{42+}` at the end of the line
39
+ * - injected: `{42}` mid-line (smuggling shape — refuse)
40
+ *
41
+ * Per-literal byte cap defaults to 64 MiB (operator opts down via
42
+ * `maxLiteralBytes`); the LISTENER then enforces the post-literal
43
+ * read against this cap.
44
+ *
45
+ * ## Mailbox-name traversal
46
+ *
47
+ * Mailbox names per RFC 9051 §5.1 — UTF-8 hierarchy with the
48
+ * server-chosen delimiter (typically `/` or `.`). Refuses path-
49
+ * traversal (`..`), NUL bytes, control chars, leading/trailing
50
+ * slash, overlong UTF-8 sequences, and (under strict) modified-
51
+ * UTF7 (RFC 3501 §5.1.3 legacy encoding — operators with legacy
52
+ * MUAs opt in via `allowLegacyMUtf7`).
53
+ *
54
+ * ## Per-verb shape
55
+ *
56
+ * Each command verb has a fixed argument shape per RFC 9051 §6.
57
+ * `LOGIN user pass` takes exactly two atoms or strings. `SELECT`
58
+ * takes one mailbox name. `FETCH` takes a sequence-set + a parts
59
+ * list. Refusals under strict use `guard-imap-command/bad-shape`.
60
+ *
61
+ * ## Caps
62
+ *
63
+ * - Command line (tag + verb + arguments excluding literal
64
+ * payload) capped at 8 KiB. RFC 9051 does not mandate a line
65
+ * cap but most servers limit at 8 KiB or 16 KiB to bound
66
+ * memory; operators on permissive can extend.
67
+ * - Mailbox name capped at 1 KiB.
68
+ * - Sequence set element count capped at 10,000 per command.
69
+ * - SEARCH expression nesting (AND/OR/NOT) capped at 32 levels.
70
+ * - Per-literal byte cap (64 MiB default).
71
+ *
72
+ * Throws `GuardImapCommandError` on every refusal. Pure-functional —
73
+ * no I/O, no state. The IMAP listener composes one instance per
74
+ * accepted connection.
75
+ *
76
+ * @card
77
+ * IMAP command-line validator (RFC 9051 IMAP4rev2). Refuses bare-CR /
78
+ * bare-LF (smuggling defense), enforces literal-injection refusal
79
+ * (RFC 9051 §2.2.2), caps line / mailbox / sequence-set / SEARCH-
80
+ * nesting bytes, validates per-verb shape (CAPABILITY / AUTHENTICATE
81
+ * / LOGIN / SELECT / FETCH / STORE / APPEND / SEARCH / ...).
82
+ */
83
+
84
+ var { defineClass } = require("./framework-error");
85
+
86
+ var GuardImapCommandError = defineClass("GuardImapCommandError", { alwaysPermanent: true });
87
+
88
+ var DEFAULT_PROFILE = "strict";
89
+
90
+ var PROFILES = Object.freeze({
91
+ strict: {
92
+ maxLineBytes: 8192, // allow:raw-byte-literal — 8 KiB command-line cap
93
+ maxLiteralBytes: 67108864, // allow:raw-byte-literal — 64 MiB per-literal cap
94
+ maxMailboxBytes: 1024, // allow:raw-byte-literal — RFC 9051 §5.1 mailbox cap
95
+ maxSequenceSetItems: 10000, // allow:raw-byte-literal — FETCH/STORE sequence-set element cap
96
+ maxSearchDepth: 32, // allow:raw-byte-literal — SEARCH AND/OR/NOT nesting cap
97
+ allowBareLf: false,
98
+ allowLiteralPlus: false, // LITERAL+ (RFC 7888) only post-AUTH; the listener flips this
99
+ allowLegacyMUtf7: false, // RFC 3501 §5.1.3 modified-UTF7 mailbox names — legacy MUA escape hatch
100
+ },
101
+ balanced: {
102
+ maxLineBytes: 16384, // allow:raw-byte-literal — 16 KiB command-line cap
103
+ maxLiteralBytes: 134217728, // allow:raw-byte-literal — 128 MiB per-literal cap
104
+ maxMailboxBytes: 2048, // allow:raw-byte-literal — balanced mailbox cap
105
+ maxSequenceSetItems: 50000, // allow:raw-byte-literal — balanced sequence-set cap
106
+ maxSearchDepth: 48, // allow:raw-byte-literal — balanced SEARCH-depth cap
107
+ allowBareLf: false,
108
+ allowLiteralPlus: true,
109
+ allowLegacyMUtf7: true,
110
+ },
111
+ permissive: {
112
+ maxLineBytes: 65536, // allow:raw-byte-literal — 64 KiB command-line cap (legacy peers)
113
+ maxLiteralBytes: 268435456, // allow:raw-byte-literal — 256 MiB per-literal cap
114
+ maxMailboxBytes: 4096, // allow:raw-byte-literal — permissive mailbox cap
115
+ maxSequenceSetItems: 100000, // allow:raw-byte-literal — permissive sequence-set cap
116
+ maxSearchDepth: 64, // allow:raw-byte-literal — permissive SEARCH-depth cap
117
+ allowBareLf: true,
118
+ allowLiteralPlus: true,
119
+ allowLegacyMUtf7: true,
120
+ },
121
+ });
122
+
123
+ var COMPLIANCE_POSTURES = Object.freeze({
124
+ hipaa: "strict",
125
+ "pci-dss": "strict",
126
+ gdpr: "strict",
127
+ soc2: "strict",
128
+ });
129
+
130
+ // IMAP4rev2 commands per RFC 9051 §6.
131
+ var KNOWN_VERBS = Object.freeze({
132
+ CAPABILITY: true, NOOP: true, LOGOUT: true,
133
+ STARTTLS: true, AUTHENTICATE: true, LOGIN: true,
134
+ ENABLE: true, SELECT: true, EXAMINE: true,
135
+ CREATE: true, DELETE: true, RENAME: true,
136
+ SUBSCRIBE: true, UNSUBSCRIBE: true, LIST: true,
137
+ NAMESPACE: true, STATUS: true, APPEND: true,
138
+ IDLE: true, DONE: true, CHECK: true,
139
+ CLOSE: true, UNSELECT: true, EXPUNGE: true,
140
+ SEARCH: true, FETCH: true, STORE: true,
141
+ COPY: true, MOVE: true, UID: true,
142
+ GETQUOTA: true, SETQUOTA: true, GETQUOTAROOT: true,
143
+ ID: true,
144
+ });
145
+
146
+ var ZERO_ARG_VERBS = Object.freeze({
147
+ CAPABILITY: true, NOOP: true, LOGOUT: true,
148
+ STARTTLS: true, IDLE: true, DONE: true,
149
+ CHECK: true, CLOSE: true, UNSELECT: true,
150
+ EXPUNGE: true,
151
+ NAMESPACE: true,
152
+ });
153
+
154
+ // IMAP tag per RFC 9051 §9 ABNF: `tag = 1*<any ASTRING-CHAR except "+">`.
155
+ // We narrow further: letters, digits, hyphen, underscore, dot — refuses
156
+ // `+` (continuation request marker; reserved by §9 explicitly) and
157
+ // `*` (server-untagged response marker) which are reserved.
158
+ var TAG_RE = /^[A-Za-z0-9._-]{1,64}$/; // allow:regex-no-length-cap — anchored + bounded repeat
159
+
160
+ // Literal-opener detection — `{n}` or `{n+}` at end of line per
161
+ // RFC 9051 §2.2.2 / RFC 7888 §2. The `+` form is LITERAL+ (non-
162
+ // synchronizing).
163
+ var LITERAL_OPEN_RE = /\{([0-9]+)(\+?)\}$/; // allow:regex-no-length-cap — anchored + bounded numeric run
164
+
165
+ // Detect a literal-opener mid-line (smuggling shape) — same `{n}` /
166
+ // `{n+}` pattern but NOT at end of line. Used by detectLiteralSmuggling.
167
+ var LITERAL_SMUGGLE_RE = /\{[0-9]+\+?\}(?!\s*$)/; // allow:regex-no-length-cap — bounded numeric run + tail anchor
168
+
169
+ /**
170
+ * @primitive b.guardImapCommand.validate
171
+ * @signature b.guardImapCommand.validate(line, opts?)
172
+ * @since 0.9.49
173
+ * @status stable
174
+ * @related b.guardImapCommand.detectLiteralSmuggling, b.guardSmtpCommand.validate
175
+ *
176
+ * Validate a single IMAP command line (without its CRLF terminator —
177
+ * the listener strips that before calling this). Returns
178
+ * `{ tag, verb, args, literalSize, literalNonSync }` on success;
179
+ * throws `GuardImapCommandError` on any refusal. `literalSize` is the
180
+ * pending-literal byte count when the line ends in `{n}`; `null`
181
+ * otherwise. `literalNonSync` is true for RFC 7888 LITERAL+ (`{n+}`).
182
+ *
183
+ * @opts
184
+ * profile: "strict" | "balanced" | "permissive",
185
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
186
+ * authenticated: boolean, // when true, LITERAL+ (RFC 7888) is honored under
187
+ * strict; pre-AUTH literal+ is refused per RFC 7888 §1
188
+ *
189
+ * @example
190
+ * var parsed = b.guardImapCommand.validate("A001 LOGIN alice secret");
191
+ * // → { tag: "A001", verb: "LOGIN", args: ["alice", "secret"], literalSize: null, literalNonSync: false }
192
+ *
193
+ * var pending = b.guardImapCommand.validate("A002 APPEND INBOX {1024}");
194
+ * // → { tag: "A002", verb: "APPEND", args: ["INBOX"], literalSize: 1024, literalNonSync: false }
195
+ */
196
+ function validate(line, opts) {
197
+ opts = opts || {};
198
+ var profileName = typeof opts.profile === "string" ? opts.profile : DEFAULT_PROFILE;
199
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
200
+ profileName = COMPLIANCE_POSTURES[opts.posture];
201
+ }
202
+ var caps = PROFILES[profileName];
203
+ if (!caps) {
204
+ throw new GuardImapCommandError("guard-imap-command/bad-profile",
205
+ "guardImapCommand.validate: unknown profile '" + profileName + "'");
206
+ }
207
+ if (typeof line !== "string") {
208
+ throw new GuardImapCommandError("guard-imap-command/bad-input",
209
+ "guardImapCommand.validate: line must be a string");
210
+ }
211
+ if (line.length === 0) {
212
+ throw new GuardImapCommandError("guard-imap-command/empty-line",
213
+ "guardImapCommand.validate: empty command line");
214
+ }
215
+ if (line.length > caps.maxLineBytes) {
216
+ throw new GuardImapCommandError("guard-imap-command/line-too-long",
217
+ "guardImapCommand.validate: line " + line.length + " bytes exceeds cap " + caps.maxLineBytes);
218
+ }
219
+ // Byte-safety: refuse bare CR / bare LF / NUL / C0 / DEL. The
220
+ // wire-protocol reader has already stripped the terminating CRLF
221
+ // before calling validate(); any remaining CR or LF is a smuggling
222
+ // shape.
223
+ for (var i = 0; i < line.length; i += 1) {
224
+ var c = line.charCodeAt(i);
225
+ if (c === 0x00 || c === 0x7F || (c < 0x20 && c !== 0x09)) { // allow:raw-byte-literal — control-byte refusal
226
+ if (c === 0x0A && caps.allowBareLf) continue;
227
+ throw new GuardImapCommandError("guard-imap-command/bad-byte",
228
+ "guardImapCommand.validate: control byte 0x" + c.toString(16) + " at offset " + i); // allow:raw-byte-literal — hex format literal in error message
229
+ }
230
+ }
231
+
232
+ // RFC 9051 §2.2.1 — `tag SP command [SP args] CRLF`
233
+ var firstSpace = line.indexOf(" ");
234
+ if (firstSpace === -1) {
235
+ throw new GuardImapCommandError("guard-imap-command/missing-verb",
236
+ "guardImapCommand.validate: command line missing verb (no SP after tag)");
237
+ }
238
+ var tag = line.slice(0, firstSpace);
239
+ if (!TAG_RE.test(tag)) { // allow:regex-no-length-cap — TAG_RE anchored + bounded-repeat
240
+ throw new GuardImapCommandError("guard-imap-command/bad-tag",
241
+ "guardImapCommand.validate: bad tag '" + tag + "' (RFC 9051 §9 atom)");
242
+ }
243
+ var rest = line.slice(firstSpace + 1);
244
+ var verbSpace = rest.indexOf(" ");
245
+ var verb = (verbSpace === -1 ? rest : rest.slice(0, verbSpace)).toUpperCase();
246
+ var args = verbSpace === -1 ? "" : rest.slice(verbSpace + 1);
247
+
248
+ if (!KNOWN_VERBS[verb]) {
249
+ throw new GuardImapCommandError("guard-imap-command/unknown-verb",
250
+ "guardImapCommand.validate: unknown verb '" + verb + "'");
251
+ }
252
+ if (ZERO_ARG_VERBS[verb] && args.length > 0) {
253
+ throw new GuardImapCommandError("guard-imap-command/unexpected-args",
254
+ "guardImapCommand.validate: verb '" + verb + "' takes no arguments");
255
+ }
256
+
257
+ // Literal-opener detection — `{n}` at end of line.
258
+ var literalSize = null;
259
+ var literalNonSync = false;
260
+ var litMatch = args.match(LITERAL_OPEN_RE);
261
+ if (litMatch) {
262
+ var sz = parseInt(litMatch[1], 10);
263
+ if (!isFinite(sz) || sz < 0 || sz > caps.maxLiteralBytes) {
264
+ throw new GuardImapCommandError("guard-imap-command/literal-too-large",
265
+ "guardImapCommand.validate: literal size " + sz + " exceeds cap " + caps.maxLiteralBytes);
266
+ }
267
+ literalSize = sz;
268
+ literalNonSync = litMatch[2] === "+";
269
+ if (literalNonSync && !caps.allowLiteralPlus) {
270
+ throw new GuardImapCommandError("guard-imap-command/literal-plus-refused",
271
+ "guardImapCommand.validate: LITERAL+ (RFC 7888) refused under profile '" + profileName + "'");
272
+ }
273
+ if (literalNonSync && opts.authenticated === false) {
274
+ // RFC 7888 §1: LITERAL+ MAY be used by clients but servers MAY
275
+ // refuse it pre-AUTH. We refuse pre-AUTH to bound resource use
276
+ // before authentication.
277
+ throw new GuardImapCommandError("guard-imap-command/literal-plus-pre-auth",
278
+ "guardImapCommand.validate: LITERAL+ refused pre-authentication");
279
+ }
280
+ }
281
+
282
+ // Mid-line literal opener is smuggling-shaped.
283
+ if (detectLiteralSmuggling(line)) {
284
+ throw new GuardImapCommandError("guard-imap-command/literal-smuggling",
285
+ "guardImapCommand.validate: literal opener `{n}` MUST appear at end of line (RFC 9051 §2.2.2)");
286
+ }
287
+
288
+ return { tag: tag, verb: verb, args: args, literalSize: literalSize, literalNonSync: literalNonSync };
289
+ }
290
+
291
+ /**
292
+ * @primitive b.guardImapCommand.detectLiteralSmuggling
293
+ * @signature b.guardImapCommand.detectLiteralSmuggling(line)
294
+ * @since 0.9.49
295
+ * @status stable
296
+ *
297
+ * Return `true` when the input line contains a literal opener
298
+ * `{n}` or `{n+}` that is NOT at the end of the line — the
299
+ * smuggling-shape per RFC 9051 §2.2.2.
300
+ *
301
+ * @example
302
+ * b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10} hostile"); // → true
303
+ * b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10}"); // → false (well-formed)
304
+ */
305
+ function detectLiteralSmuggling(line) {
306
+ if (typeof line !== "string") return false;
307
+ return LITERAL_SMUGGLE_RE.test(line); // allow:regex-no-length-cap — caller's input is already length-capped upstream by the listener's per-line cap
308
+ }
309
+
310
+ /**
311
+ * @primitive b.guardImapCommand.compliancePosture
312
+ * @signature b.guardImapCommand.compliancePosture(posture)
313
+ * @since 0.9.49
314
+ * @status stable
315
+ *
316
+ * Return the effective profile for a compliance posture, or `null`
317
+ * for unknown names.
318
+ *
319
+ * @example
320
+ * b.guardImapCommand.compliancePosture("hipaa"); // → "strict"
321
+ */
322
+ function compliancePosture(posture) {
323
+ return COMPLIANCE_POSTURES[posture] || null;
324
+ }
325
+
326
+ module.exports = {
327
+ validate: validate,
328
+ detectLiteralSmuggling: detectLiteralSmuggling,
329
+ compliancePosture: compliancePosture,
330
+ PROFILES: PROFILES,
331
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
332
+ KNOWN_VERBS: KNOWN_VERBS,
333
+ ZERO_ARG_VERBS: ZERO_ARG_VERBS,
334
+ GuardImapCommandError: GuardImapCommandError,
335
+ };
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.guardJmap
4
+ * @nav Guards
5
+ * @title Guard JMAP Request
6
+ * @order 452
7
+ *
8
+ * @intro
9
+ * JMAP request-envelope validator (RFC 8620 JMAP Core). Validates
10
+ * the shape of an HTTP request body posted to `/jmap/api` and
11
+ * refuses requests that exceed operator caps, omit required
12
+ * capability declarations, or contain malformed back-references.
13
+ *
14
+ * ## Request shape (RFC 8620 §3.3)
15
+ *
16
+ * ```json
17
+ * {
18
+ * "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
19
+ * "methodCalls": [
20
+ * ["Mailbox/get", { "accountId": "A1", "ids": null }, "c0"],
21
+ * ["Email/query", { "filter": { "inMailbox": "#c0/list/0" } }, "c1"]
22
+ * ],
23
+ * "createdIds": null
24
+ * }
25
+ * ```
26
+ *
27
+ * `using` is the set of capability URIs the request invokes; the
28
+ * server's `urn:ietf:params:jmap:core` is implicit. `methodCalls`
29
+ * is an array of 3-tuples `[methodName, args, clientId]` where
30
+ * `clientId` echoes back on the response for client-side
31
+ * correlation.
32
+ *
33
+ * ## Back-reference resolution (RFC 8620 §3.7)
34
+ *
35
+ * Subsequent `methodCalls` reference earlier results via
36
+ * `{ "resultOf": <prior-clientId>, "name": <methodName>, "path": <JSONPath> }`
37
+ * placeholders inside the `args` object. The validator detects
38
+ * back-references and caps the chain depth so a pathological
39
+ * chain doesn't degrade into a O(2^N) blowup.
40
+ *
41
+ * ## Caps
42
+ *
43
+ * - `maxCallsInRequest` — default 32 (RFC 8620 §3.6)
44
+ * - `maxObjectsInGet` — default 500
45
+ * - `maxObjectsInSet` — default 500
46
+ * - `maxSizeRequest` — default 10 MiB
47
+ * - `maxBackRefDepth` — default 8 (we add this; spec doesn't)
48
+ * - `maxUsingCapabilities` — default 32 (refuses oversize `using`)
49
+ *
50
+ * Refusals emit a `urn:ietf:params:jmap:error:*` URI per
51
+ * RFC 8620 §3.6.1.
52
+ *
53
+ * @card
54
+ * JMAP request-envelope validator (RFC 8620 §3.3). Refuses oversize
55
+ * requests, capability-unknown / malformed back-reference / pipeline-
56
+ * bomb shapes per RFC 8620 §3.6.1 error vocabulary.
57
+ */
58
+
59
+ var { defineClass } = require("./framework-error");
60
+ var safeJson = require("./safe-json");
61
+ var validateOpts = require("./validate-opts");
62
+
63
+ var GuardJmapError = defineClass("GuardJmapError", { alwaysPermanent: true });
64
+
65
+ var DEFAULT_PROFILE = "strict";
66
+
67
+ var PROFILES = Object.freeze({
68
+ strict: {
69
+ maxCallsInRequest: 32, // allow:raw-byte-literal — RFC 8620 §3.6 default
70
+ maxObjectsInGet: 500, // allow:raw-byte-literal — RFC 8620 §3.6 default
71
+ maxObjectsInSet: 500, // allow:raw-byte-literal — RFC 8620 §3.6 default
72
+ maxSizeRequest: 10485760, // allow:raw-byte-literal — 10 MiB request body cap
73
+ maxBackRefDepth: 8,
74
+ maxUsingCapabilities: 32, // allow:raw-byte-literal — `using` array length cap
75
+ },
76
+ balanced: {
77
+ maxCallsInRequest: 128, // allow:raw-byte-literal — balanced call cap
78
+ maxObjectsInGet: 1000, // allow:raw-byte-literal — balanced object cap
79
+ maxObjectsInSet: 1000, // allow:raw-byte-literal — balanced object cap
80
+ maxSizeRequest: 52428800, // allow:raw-byte-literal — 50 MiB balanced
81
+ maxBackRefDepth: 16, // allow:raw-byte-literal — balanced depth
82
+ maxUsingCapabilities: 64, // allow:raw-byte-literal — balanced using cap
83
+ },
84
+ permissive: {
85
+ maxCallsInRequest: 512, // allow:raw-byte-literal — permissive call cap
86
+ maxObjectsInGet: 5000, // allow:raw-byte-literal — permissive object cap
87
+ maxObjectsInSet: 5000, // allow:raw-byte-literal — permissive object cap
88
+ maxSizeRequest: 104857600, // allow:raw-byte-literal — 100 MiB permissive
89
+ maxBackRefDepth: 32, // allow:raw-byte-literal — permissive depth
90
+ maxUsingCapabilities: 128, // allow:raw-byte-literal — permissive using cap
91
+ },
92
+ });
93
+
94
+ var COMPLIANCE_POSTURES = Object.freeze({
95
+ hipaa: "strict",
96
+ "pci-dss": "strict",
97
+ gdpr: "strict",
98
+ soc2: "strict",
99
+ });
100
+
101
+ // Capability URIs the server's core JMAP implementation always supports.
102
+ // Additional capabilities (mail / contacts / calendars / submission)
103
+ // the operator opts into via opts.serverCapabilities.
104
+ var CORE_CAPABILITIES = Object.freeze({
105
+ "urn:ietf:params:jmap:core": true,
106
+ });
107
+
108
+ /**
109
+ * @primitive b.guardJmap.validate
110
+ * @signature b.guardJmap.validate(rawBody, opts?)
111
+ * @since 0.9.50
112
+ * @status stable
113
+ * @related b.guardImapCommand.validate, b.safeJson.parse
114
+ *
115
+ * Validate a JMAP request envelope. Accepts either a raw JSON string
116
+ * (bytes) or a pre-parsed object. Returns
117
+ * `{ using, methodCalls, createdIds }` on success; throws
118
+ * `GuardJmapError` with the matching `urn:ietf:params:jmap:error:*`
119
+ * URI on refusal.
120
+ *
121
+ * @opts
122
+ * profile: "strict" | "balanced" | "permissive",
123
+ * posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
124
+ * serverCapabilities: { "urn:ietf:params:jmap:mail": true, ... },
125
+ * // capability URIs the server has wired; `using`
126
+ * // entries not in this set are refused with
127
+ * // urn:ietf:params:jmap:error:unknownCapability
128
+ *
129
+ * @example
130
+ * var parsed = b.guardJmap.validate(rawBody, {
131
+ * serverCapabilities: { "urn:ietf:params:jmap:mail": true },
132
+ * });
133
+ * // → { using: [...], methodCalls: [[methodName, args, clientId], ...] }
134
+ */
135
+ function validate(rawBody, opts) {
136
+ opts = opts || {};
137
+ var profileName = typeof opts.profile === "string" ? opts.profile : DEFAULT_PROFILE;
138
+ if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
139
+ profileName = COMPLIANCE_POSTURES[opts.posture];
140
+ }
141
+ var caps = PROFILES[profileName];
142
+ if (!caps) {
143
+ throw new GuardJmapError("guard-jmap/bad-profile",
144
+ "guardJmap.validate: unknown profile '" + profileName + "'");
145
+ }
146
+ // Clone serverCapabilities before injecting the core capability so we
147
+ // never mutate the operator-supplied object. mail.server.jmap.create
148
+ // passes its shared `serverCapabilities` into every validate() call;
149
+ // pre-fix this rewrote the operator's `urn:ietf:params:jmap:core`
150
+ // entry to `true` after the first request, breaking the Session
151
+ // resource's RFC 8620 §2 capability-object shape.
152
+ var serverCaps = Object.assign({}, opts.serverCapabilities || {});
153
+ // Always allow the core capability — the server provides it inherently.
154
+ serverCaps["urn:ietf:params:jmap:core"] = true;
155
+
156
+ // Parse if rawBody is a string. Cap the byte size before parsing.
157
+ var body;
158
+ if (typeof rawBody === "string" || Buffer.isBuffer(rawBody)) {
159
+ var s = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
160
+ // Wire-protocol size cap MUST be measured in UTF-8 bytes, not
161
+ // JavaScript UTF-16 code units. A 1 MiB cap interpreted as code
162
+ // units lets non-ASCII payloads (emoji, CJK) past the gate at
163
+ // 2-4× the actual byte budget — directly weakens the DoS cap.
164
+ var byteLen = typeof rawBody === "string" ? Buffer.byteLength(s, "utf8") : rawBody.length;
165
+ if (byteLen > caps.maxSizeRequest) {
166
+ throw new GuardJmapError("urn:ietf:params:jmap:error:requestTooLarge",
167
+ "guardJmap.validate: request body " + byteLen +
168
+ " bytes exceeds cap " + caps.maxSizeRequest);
169
+ }
170
+ try {
171
+ body = safeJson.parse(s);
172
+ } catch (e) {
173
+ throw new GuardJmapError("guard-jmap/bad-json",
174
+ "guardJmap.validate: body is not valid JSON: " + (e && e.message ? e.message : String(e)));
175
+ }
176
+ } else if (rawBody && typeof rawBody === "object") {
177
+ body = rawBody;
178
+ } else {
179
+ throw new GuardJmapError("guard-jmap/bad-input",
180
+ "guardJmap.validate: rawBody must be a JSON string, Buffer, or pre-parsed object");
181
+ }
182
+
183
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
184
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
185
+ "guardJmap.validate: request body must be a JSON object");
186
+ }
187
+
188
+ if (!Array.isArray(body.using)) {
189
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
190
+ "guardJmap.validate: `using` must be an array of capability URIs");
191
+ }
192
+ if (body.using.length > caps.maxUsingCapabilities) {
193
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
194
+ "guardJmap.validate: `using` length " + body.using.length +
195
+ " exceeds cap " + caps.maxUsingCapabilities);
196
+ }
197
+ for (var ui = 0; ui < body.using.length; ui += 1) {
198
+ var cap = body.using[ui];
199
+ if (typeof cap !== "string") {
200
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
201
+ "guardJmap.validate: `using[" + ui + "]` must be a string capability URI");
202
+ }
203
+ if (!CORE_CAPABILITIES[cap] && !serverCaps[cap]) {
204
+ throw new GuardJmapError("urn:ietf:params:jmap:error:unknownCapability",
205
+ "guardJmap.validate: capability '" + cap + "' not advertised by this server");
206
+ }
207
+ }
208
+
209
+ if (!Array.isArray(body.methodCalls)) {
210
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
211
+ "guardJmap.validate: `methodCalls` must be an array");
212
+ }
213
+ if (body.methodCalls.length === 0) {
214
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
215
+ "guardJmap.validate: `methodCalls` must contain at least one call");
216
+ }
217
+ if (body.methodCalls.length > caps.maxCallsInRequest) {
218
+ throw new GuardJmapError("urn:ietf:params:jmap:error:limit/maxCallsInRequest",
219
+ "guardJmap.validate: " + body.methodCalls.length +
220
+ " methodCalls exceeds cap " + caps.maxCallsInRequest);
221
+ }
222
+
223
+ var seenClientIds = Object.create(null);
224
+ for (var ci = 0; ci < body.methodCalls.length; ci += 1) {
225
+ var call = body.methodCalls[ci];
226
+ if (!Array.isArray(call) || call.length !== 3) {
227
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
228
+ "guardJmap.validate: methodCalls[" + ci + "] must be a 3-tuple [name, args, clientId]");
229
+ }
230
+ if (typeof call[0] !== "string") {
231
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
232
+ "guardJmap.validate: methodCalls[" + ci + "][0] (method name) must be a string");
233
+ }
234
+ if (typeof call[1] !== "object" || call[1] === null || Array.isArray(call[1])) {
235
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
236
+ "guardJmap.validate: methodCalls[" + ci + "][1] (args) must be an object");
237
+ }
238
+ if (typeof call[2] !== "string") {
239
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
240
+ "guardJmap.validate: methodCalls[" + ci + "][2] (clientId) must be a string");
241
+ }
242
+ if (call[2].length === 0 || call[2].length > 256) { // allow:raw-byte-literal — clientId length cap
243
+ throw new GuardJmapError("urn:ietf:params:jmap:error:invalidArguments",
244
+ "guardJmap.validate: methodCalls[" + ci + "][2] (clientId) length must be 1..256");
245
+ }
246
+ // Back-reference depth cap: count `resultOf` occurrences in the
247
+ // args subtree. Pathological depth would let a client chain
248
+ // hundreds of resolutions per call.
249
+ var refCount = _countBackRefs(call[1], 0, caps.maxBackRefDepth);
250
+ if (refCount === -1) {
251
+ throw new GuardJmapError("urn:ietf:params:jmap:error:limit/maxBackRefDepth",
252
+ "guardJmap.validate: methodCalls[" + ci + "] back-reference depth exceeds cap " +
253
+ caps.maxBackRefDepth);
254
+ }
255
+ seenClientIds[call[2]] = true;
256
+ }
257
+
258
+ validateOpts.optionalPlainObject(body.createdIds,
259
+ "guardJmap.validate: `createdIds`",
260
+ GuardJmapError, "urn:ietf:params:jmap:error:invalidArguments",
261
+ "null or an object");
262
+
263
+ return {
264
+ using: body.using,
265
+ methodCalls: body.methodCalls,
266
+ createdIds: body.createdIds || null,
267
+ };
268
+ }
269
+
270
+ // Walk args looking for back-reference markers
271
+ // (`#name`-prefixed keys per RFC 8620 §3.7 or a `resultOf` shape).
272
+ // Returns the maximum depth seen, or -1 if it exceeds maxDepth.
273
+ function _countBackRefs(node, depth, maxDepth) {
274
+ if (depth > maxDepth) return -1;
275
+ if (node === null || typeof node !== "object") return depth;
276
+ if (Array.isArray(node)) {
277
+ var maxA = depth;
278
+ for (var i = 0; i < node.length; i += 1) {
279
+ var d = _countBackRefs(node[i], depth + 1, maxDepth);
280
+ if (d === -1) return -1;
281
+ if (d > maxA) maxA = d;
282
+ }
283
+ return maxA;
284
+ }
285
+ var keys = Object.keys(node);
286
+ if (keys.length > 1000) return -1; // allow:raw-byte-literal — per-object key cap
287
+ var maxO = depth;
288
+ for (var k = 0; k < keys.length; k += 1) {
289
+ var key = keys[k];
290
+ var inc = (key === "resultOf" || key.charCodeAt(0) === 0x23) ? 1 : 0; // allow:raw-byte-literal — `#` (0x23) is the JMAP back-ref prefix
291
+ var d2 = _countBackRefs(node[key], depth + inc, maxDepth);
292
+ if (d2 === -1) return -1;
293
+ if (d2 > maxO) maxO = d2;
294
+ }
295
+ return maxO;
296
+ }
297
+
298
+ /**
299
+ * @primitive b.guardJmap.compliancePosture
300
+ * @signature b.guardJmap.compliancePosture(posture)
301
+ * @since 0.9.50
302
+ * @status stable
303
+ *
304
+ * Return the effective profile for a compliance posture, or `null`
305
+ * for unknown names.
306
+ *
307
+ * @example
308
+ * b.guardJmap.compliancePosture("hipaa"); // → "strict"
309
+ */
310
+ function compliancePosture(posture) {
311
+ return COMPLIANCE_POSTURES[posture] || null;
312
+ }
313
+
314
+ module.exports = {
315
+ validate: validate,
316
+ compliancePosture: compliancePosture,
317
+ PROFILES: PROFILES,
318
+ COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
319
+ CORE_CAPABILITIES: CORE_CAPABILITIES,
320
+ GuardJmapError: GuardJmapError,
321
+ };