@blamejs/core 0.9.18 → 0.9.20
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 +2 -0
- package/index.js +16 -0
- package/lib/guard-mail-compose.js +282 -0
- package/lib/guard-mail-move.js +202 -0
- package/lib/guard-mail-query.js +296 -0
- package/lib/guard-mail-reply.js +172 -0
- package/lib/guard-mail-sieve.js +207 -0
- package/lib/guard-message-id.js +241 -0
- package/lib/mail-agent.js +638 -0
- package/lib/mail-store.js +652 -0
- package/lib/mail.js +6 -0
- package/lib/safe-mime.js +714 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMailQuery
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Mail Query
|
|
6
|
+
* @order 430
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Search / fetch / changes filter validator for `b.mail.agent`. Refuses
|
|
10
|
+
* filter shapes that can't safely cross the worker-thread or queue
|
|
11
|
+
* boundary (functions, regex objects with state, Date objects with
|
|
12
|
+
* non-finite values, cycles), enforces a projection-column allowlist
|
|
13
|
+
* against the mail-store schema, and pins the per-actor fields that
|
|
14
|
+
* the active compliance posture requires (HIPAA → `purposeOfUse`;
|
|
15
|
+
* PCI-DSS → `pciScope`; GDPR → `lawfulBasis`).
|
|
16
|
+
*
|
|
17
|
+
* Composes `b.guardJson` (via `validate(JSON.stringify(filter))`-shape
|
|
18
|
+
* guard) at the structural level and adds mail-specific rules: the
|
|
19
|
+
* filter is recursively walked once with a depth cap, no operator
|
|
20
|
+
* key is allowed outside the documented set, and any field-name
|
|
21
|
+
* used as a comparator key must be in `FILTERABLE_COLUMNS`.
|
|
22
|
+
*
|
|
23
|
+
* @card
|
|
24
|
+
* Validates `b.mail.agent.search` / `Email/query` filter specs.
|
|
25
|
+
* Pure-data only (no functions / regex), bounded depth, projection
|
|
26
|
+
* allowlist, posture-required actor fields.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
var { defineClass } = require("./framework-error");
|
|
30
|
+
|
|
31
|
+
var GuardMailQueryError = defineClass("GuardMailQueryError", { alwaysPermanent: true });
|
|
32
|
+
|
|
33
|
+
var DEFAULT_PROFILE = "strict";
|
|
34
|
+
|
|
35
|
+
var PROFILES = Object.freeze({
|
|
36
|
+
strict: { maxDepth: 8, maxKeys: 64, maxStringBytes: 8192, maxArrayLen: 256 }, // allow:raw-byte-literal — caps for filter spec
|
|
37
|
+
balanced: { maxDepth: 16, maxKeys: 128, maxStringBytes: 16384, maxArrayLen: 1024 }, // allow:raw-byte-literal
|
|
38
|
+
permissive: { maxDepth: 24, maxKeys: 512, maxStringBytes: 65536, maxArrayLen: 4096 }, // allow:raw-byte-literal
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
42
|
+
hipaa: "strict",
|
|
43
|
+
"pci-dss": "strict",
|
|
44
|
+
gdpr: "strict",
|
|
45
|
+
soc2: "strict",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Columns the filter may reference and the projection may request.
|
|
49
|
+
// Sealed columns can be `=` / `IN` matched (the mail-store walks
|
|
50
|
+
// derivedHashes for the equality form); range / LIKE matches are only
|
|
51
|
+
// allowed against plaintext columns.
|
|
52
|
+
var FILTERABLE_COLUMNS = Object.freeze({
|
|
53
|
+
objectid: { kind: "plaintext", ops: ["eq", "in"] },
|
|
54
|
+
modseq: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le", "in"] },
|
|
55
|
+
internal_date: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
|
|
56
|
+
received_at: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
|
|
57
|
+
size_bytes: { kind: "plaintext", ops: ["eq", "gt", "lt", "ge", "le"] },
|
|
58
|
+
thread_root_id: { kind: "plaintext", ops: ["eq", "in"] },
|
|
59
|
+
legal_hold: { kind: "plaintext", ops: ["eq"] },
|
|
60
|
+
message_id: { kind: "sealed", ops: ["eq", "in"] },
|
|
61
|
+
from_addr: { kind: "sealed", ops: ["eq", "in"] },
|
|
62
|
+
subject: { kind: "sealed", ops: ["eq"] },
|
|
63
|
+
flag: { kind: "join", ops: ["eq", "in"] },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
var ALLOWED_OPS = Object.freeze({
|
|
67
|
+
eq: true, in: true, gt: true, lt: true, ge: true, le: true,
|
|
68
|
+
and: true, or: true, not: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Per-posture actor required fields. Operator must supply these on the
|
|
72
|
+
// agent actor object for any read/write op under the matching posture
|
|
73
|
+
// — refused otherwise.
|
|
74
|
+
var POSTURE_ACTOR_FIELDS = Object.freeze({
|
|
75
|
+
hipaa: ["purposeOfUse"],
|
|
76
|
+
"pci-dss": ["pciScope"],
|
|
77
|
+
gdpr: ["lawfulBasis"],
|
|
78
|
+
soc2: [],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @primitive b.guardMailQuery.validate
|
|
83
|
+
* @signature b.guardMailQuery.validate(filter, opts?)
|
|
84
|
+
* @since 0.9.20
|
|
85
|
+
* @status stable
|
|
86
|
+
* @related b.guardMailQuery.validateActor, b.mail.agent.create
|
|
87
|
+
*
|
|
88
|
+
* Validate a filter spec. Returns the input on success; throws
|
|
89
|
+
* `GuardMailQueryError` on refusal.
|
|
90
|
+
*
|
|
91
|
+
* @opts
|
|
92
|
+
* profile: "strict" | "balanced" | "permissive", // default "strict"
|
|
93
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2", // pins strict
|
|
94
|
+
* project: Array<string>, // projection columns
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* b.guardMailQuery.validate({ and: [{ modseq: { gt: 0 } }, { flag: { eq: "\\Seen" } }] });
|
|
98
|
+
*/
|
|
99
|
+
function validate(filter, opts) {
|
|
100
|
+
opts = opts || {};
|
|
101
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
102
|
+
if (filter === undefined || filter === null) {
|
|
103
|
+
throw new GuardMailQueryError("mail-query/empty",
|
|
104
|
+
"guardMailQuery.validate: filter required");
|
|
105
|
+
}
|
|
106
|
+
if (typeof filter !== "object" || Array.isArray(filter)) {
|
|
107
|
+
throw new GuardMailQueryError("mail-query/bad-input",
|
|
108
|
+
"guardMailQuery.validate: filter must be a plain object");
|
|
109
|
+
}
|
|
110
|
+
_walk(filter, 0, profile, new Set());
|
|
111
|
+
|
|
112
|
+
if (opts.project) {
|
|
113
|
+
if (!Array.isArray(opts.project)) {
|
|
114
|
+
throw new GuardMailQueryError("mail-query/bad-project",
|
|
115
|
+
"guardMailQuery.validate: opts.project must be an array");
|
|
116
|
+
}
|
|
117
|
+
for (var i = 0; i < opts.project.length; i += 1) {
|
|
118
|
+
var col = opts.project[i];
|
|
119
|
+
if (typeof col !== "string" || !Object.prototype.hasOwnProperty.call(FILTERABLE_COLUMNS, col)) {
|
|
120
|
+
throw new GuardMailQueryError("mail-query/bad-projection-column",
|
|
121
|
+
"guardMailQuery.validate: column '" + col + "' not in projection allowlist");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return filter;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @primitive b.guardMailQuery.validateActor
|
|
130
|
+
* @signature b.guardMailQuery.validateActor(actor, posture?)
|
|
131
|
+
* @since 0.9.20
|
|
132
|
+
* @status stable
|
|
133
|
+
* @related b.guardMailQuery.validate
|
|
134
|
+
*
|
|
135
|
+
* Validate that `actor` carries the per-posture required fields.
|
|
136
|
+
* Returns `actor` on success; throws on missing field.
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* b.guardMailQuery.validateActor({ id: "u1", roles: ["clinician"], purposeOfUse: "TREATMENT" }, "hipaa");
|
|
140
|
+
*/
|
|
141
|
+
function validateActor(actor, posture) {
|
|
142
|
+
if (!actor || typeof actor !== "object") {
|
|
143
|
+
throw new GuardMailQueryError("mail-query/no-actor",
|
|
144
|
+
"guardMailQuery.validateActor: actor required");
|
|
145
|
+
}
|
|
146
|
+
if (typeof actor.id !== "string" || actor.id.length === 0) {
|
|
147
|
+
throw new GuardMailQueryError("mail-query/bad-actor",
|
|
148
|
+
"guardMailQuery.validateActor: actor.id must be a non-empty string");
|
|
149
|
+
}
|
|
150
|
+
if (posture && POSTURE_ACTOR_FIELDS[posture]) {
|
|
151
|
+
var required = POSTURE_ACTOR_FIELDS[posture];
|
|
152
|
+
for (var i = 0; i < required.length; i += 1) {
|
|
153
|
+
var f = required[i];
|
|
154
|
+
if (typeof actor[f] !== "string" || actor[f].length === 0) {
|
|
155
|
+
throw new GuardMailQueryError("mail-query/missing-posture-field",
|
|
156
|
+
"guardMailQuery.validateActor: posture '" + posture + "' requires actor." + f);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return actor;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @primitive b.guardMailQuery.compliancePosture
|
|
165
|
+
* @signature b.guardMailQuery.compliancePosture(posture)
|
|
166
|
+
* @since 0.9.20
|
|
167
|
+
* @status stable
|
|
168
|
+
*
|
|
169
|
+
* Return the effective profile for a given compliance posture name.
|
|
170
|
+
* Returns `null` when the posture is unknown (operator-supplied typos
|
|
171
|
+
* surface here instead of silently falling back to the default).
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* b.guardMailQuery.compliancePosture("hipaa"); // → "strict"
|
|
175
|
+
*/
|
|
176
|
+
function compliancePosture(posture) {
|
|
177
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _walk(node, depth, profile, visited) {
|
|
181
|
+
if (depth > profile.maxDepth) {
|
|
182
|
+
throw new GuardMailQueryError("mail-query/depth",
|
|
183
|
+
"guardMailQuery.validate: filter depth exceeds maxDepth=" + profile.maxDepth);
|
|
184
|
+
}
|
|
185
|
+
if (node === null || typeof node !== "object") {
|
|
186
|
+
_checkScalar(node, profile);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (typeof node === "function") {
|
|
190
|
+
throw new GuardMailQueryError("mail-query/function-not-allowed",
|
|
191
|
+
"guardMailQuery.validate: functions refused (filter must be pure data)");
|
|
192
|
+
}
|
|
193
|
+
if (node instanceof RegExp) {
|
|
194
|
+
throw new GuardMailQueryError("mail-query/regex-not-allowed",
|
|
195
|
+
"guardMailQuery.validate: RegExp refused (use { like: ... } string predicate)");
|
|
196
|
+
}
|
|
197
|
+
if (node instanceof Date) {
|
|
198
|
+
if (!isFinite(node.getTime())) {
|
|
199
|
+
throw new GuardMailQueryError("mail-query/bad-date",
|
|
200
|
+
"guardMailQuery.validate: invalid Date");
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (Buffer.isBuffer(node)) {
|
|
205
|
+
throw new GuardMailQueryError("mail-query/buffer-not-allowed",
|
|
206
|
+
"guardMailQuery.validate: Buffer refused inside filter");
|
|
207
|
+
}
|
|
208
|
+
if (visited.has(node)) {
|
|
209
|
+
throw new GuardMailQueryError("mail-query/cycle",
|
|
210
|
+
"guardMailQuery.validate: cyclic filter refused");
|
|
211
|
+
}
|
|
212
|
+
visited.add(node);
|
|
213
|
+
|
|
214
|
+
if (Array.isArray(node)) {
|
|
215
|
+
if (node.length > profile.maxArrayLen) {
|
|
216
|
+
throw new GuardMailQueryError("mail-query/array-too-long",
|
|
217
|
+
"guardMailQuery.validate: array length " + node.length + " exceeds " + profile.maxArrayLen);
|
|
218
|
+
}
|
|
219
|
+
for (var i = 0; i < node.length; i += 1) {
|
|
220
|
+
_walk(node[i], depth + 1, profile, visited);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
var keys = Object.keys(node);
|
|
226
|
+
if (keys.length > profile.maxKeys) {
|
|
227
|
+
throw new GuardMailQueryError("mail-query/too-many-keys",
|
|
228
|
+
"guardMailQuery.validate: " + keys.length + " keys exceeds maxKeys=" + profile.maxKeys);
|
|
229
|
+
}
|
|
230
|
+
for (var ki = 0; ki < keys.length; ki += 1) {
|
|
231
|
+
var k = keys[ki];
|
|
232
|
+
if (k === "__proto__" || k === "constructor" || k === "prototype") {
|
|
233
|
+
throw new GuardMailQueryError("mail-query/proto-key",
|
|
234
|
+
"guardMailQuery.validate: forbidden key '" + k + "'");
|
|
235
|
+
}
|
|
236
|
+
var isOp = Object.prototype.hasOwnProperty.call(ALLOWED_OPS, k);
|
|
237
|
+
var isCol = Object.prototype.hasOwnProperty.call(FILTERABLE_COLUMNS, k);
|
|
238
|
+
if (!isOp && !isCol) {
|
|
239
|
+
throw new GuardMailQueryError("mail-query/unknown-key",
|
|
240
|
+
"guardMailQuery.validate: key '" + k + "' not an allowed operator or column");
|
|
241
|
+
}
|
|
242
|
+
_walk(node[k], depth + 1, profile, visited);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function _checkScalar(v, profile) {
|
|
247
|
+
if (typeof v === "string") {
|
|
248
|
+
if (Buffer.byteLength(v, "utf8") > profile.maxStringBytes) {
|
|
249
|
+
throw new GuardMailQueryError("mail-query/string-too-long",
|
|
250
|
+
"guardMailQuery.validate: string exceeds maxStringBytes=" + profile.maxStringBytes);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (typeof v === "number") {
|
|
255
|
+
if (!isFinite(v)) {
|
|
256
|
+
throw new GuardMailQueryError("mail-query/bad-number",
|
|
257
|
+
"guardMailQuery.validate: non-finite number refused");
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (typeof v === "boolean") return;
|
|
262
|
+
if (v === null) return;
|
|
263
|
+
if (typeof v === "undefined") {
|
|
264
|
+
throw new GuardMailQueryError("mail-query/undefined-not-allowed",
|
|
265
|
+
"guardMailQuery.validate: undefined refused");
|
|
266
|
+
}
|
|
267
|
+
if (typeof v === "symbol" || typeof v === "bigint" || typeof v === "function") {
|
|
268
|
+
throw new GuardMailQueryError("mail-query/bad-scalar",
|
|
269
|
+
"guardMailQuery.validate: scalar type " + typeof v + " refused");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _resolveProfile(opts) {
|
|
274
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
275
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
276
|
+
}
|
|
277
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
278
|
+
if (!PROFILES[p]) {
|
|
279
|
+
throw new GuardMailQueryError("mail-query/bad-profile",
|
|
280
|
+
"guardMailQuery: unknown profile '" + p + "'");
|
|
281
|
+
}
|
|
282
|
+
return p;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = {
|
|
286
|
+
validate: validate,
|
|
287
|
+
validateActor: validateActor,
|
|
288
|
+
compliancePosture: compliancePosture,
|
|
289
|
+
PROFILES: PROFILES,
|
|
290
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
291
|
+
FILTERABLE_COLUMNS: FILTERABLE_COLUMNS,
|
|
292
|
+
POSTURE_ACTOR_FIELDS: POSTURE_ACTOR_FIELDS,
|
|
293
|
+
GuardMailQueryError: GuardMailQueryError,
|
|
294
|
+
NAME: "mailQuery",
|
|
295
|
+
KIND: "mail-query",
|
|
296
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMailReply
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Mail Reply
|
|
6
|
+
* @order 432
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Reply-thread shape validator for `b.mail.agent.reply` /
|
|
10
|
+
* `b.mail.agent.forward`. Composes `b.guardMessageId` (v0.9.19) for
|
|
11
|
+
* each Message-Id in the chain and adds reply-specific rules:
|
|
12
|
+
*
|
|
13
|
+
* - References-chain cap — `maxChainLength` (default 100) defends
|
|
14
|
+
* infinite-loop forwards and References-bomb DoS
|
|
15
|
+
* - In-Reply-To continuity — when both `inReplyTo` and `references`
|
|
16
|
+
* are supplied, the last element of References must match
|
|
17
|
+
* In-Reply-To (RFC 5322 §3.6.4)
|
|
18
|
+
* - Quoted-original byte cap — when `quotedOriginal` is set, the
|
|
19
|
+
* byte cap defends pathological reply-of-reply chains that grow
|
|
20
|
+
* linearly with each hop
|
|
21
|
+
* - Forwarded-attachment cardinality — forwards may include the
|
|
22
|
+
* original's attachments by reference; cap at `maxForwardedAttachments`
|
|
23
|
+
* (default 32) to prevent attachment-bomb forwards
|
|
24
|
+
*
|
|
25
|
+
* @card
|
|
26
|
+
* Validates reply / forward shape. References-chain cap (defends
|
|
27
|
+
* infinite-loop forwards), In-Reply-To continuity (RFC 5322 §3.6.4),
|
|
28
|
+
* quoted-original byte cap.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
var { defineClass } = require("./framework-error");
|
|
32
|
+
var guardMessageId = require("./guard-message-id");
|
|
33
|
+
|
|
34
|
+
var GuardMailReplyError = defineClass("GuardMailReplyError", { alwaysPermanent: true });
|
|
35
|
+
|
|
36
|
+
var DEFAULT_PROFILE = "strict";
|
|
37
|
+
|
|
38
|
+
var PROFILES = Object.freeze({
|
|
39
|
+
strict: { maxChainLength: 100, maxQuotedBytes: 524288, maxForwardedAttachments: 32 }, // allow:raw-byte-literal — chain count + 512 KiB
|
|
40
|
+
balanced: { maxChainLength: 500, maxQuotedBytes: 2097152, maxForwardedAttachments: 128 }, // allow:raw-byte-literal — chain count + 2 MiB
|
|
41
|
+
permissive: { maxChainLength: 2000, maxQuotedBytes: 10485760, maxForwardedAttachments: 512 }, // allow:raw-byte-literal — chain count + 10 MiB
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
45
|
+
hipaa: "strict",
|
|
46
|
+
"pci-dss": "strict",
|
|
47
|
+
gdpr: "strict",
|
|
48
|
+
soc2: "strict",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @primitive b.guardMailReply.validate
|
|
53
|
+
* @signature b.guardMailReply.validate(reply, opts?)
|
|
54
|
+
* @since 0.9.20
|
|
55
|
+
* @status stable
|
|
56
|
+
* @related b.guardMessageId, b.guardMailCompose
|
|
57
|
+
*
|
|
58
|
+
* Validate a reply / forward envelope. `reply.inReplyTo` is the
|
|
59
|
+
* Message-Id of the parent; `reply.references` is the chain (oldest
|
|
60
|
+
* first); `reply.quotedOriginal` is the optional included original
|
|
61
|
+
* body (already redacted by the caller — guard validates byte cap
|
|
62
|
+
* only, not content).
|
|
63
|
+
*
|
|
64
|
+
* @opts
|
|
65
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
66
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* b.guardMailReply.validate({
|
|
70
|
+
* inReplyTo: "<a@x>",
|
|
71
|
+
* references: ["<root@x>", "<a@x>"],
|
|
72
|
+
* });
|
|
73
|
+
*/
|
|
74
|
+
function validate(reply, opts) {
|
|
75
|
+
opts = opts || {};
|
|
76
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
77
|
+
if (!reply || typeof reply !== "object") {
|
|
78
|
+
throw new GuardMailReplyError("mail-reply/bad-input",
|
|
79
|
+
"guardMailReply.validate: reply required");
|
|
80
|
+
}
|
|
81
|
+
if (typeof reply.inReplyTo !== "string" || reply.inReplyTo.length === 0) {
|
|
82
|
+
throw new GuardMailReplyError("mail-reply/no-in-reply-to",
|
|
83
|
+
"guardMailReply.validate: inReplyTo required");
|
|
84
|
+
}
|
|
85
|
+
guardMessageId.validate(reply.inReplyTo, { profile: "strict" });
|
|
86
|
+
|
|
87
|
+
if (typeof reply.references !== "undefined") {
|
|
88
|
+
if (!Array.isArray(reply.references)) {
|
|
89
|
+
throw new GuardMailReplyError("mail-reply/bad-references",
|
|
90
|
+
"guardMailReply.validate: references must be an array");
|
|
91
|
+
}
|
|
92
|
+
if (reply.references.length > profile.maxChainLength) {
|
|
93
|
+
throw new GuardMailReplyError("mail-reply/chain-too-long",
|
|
94
|
+
"guardMailReply.validate: chain length " + reply.references.length +
|
|
95
|
+
" exceeds maxChainLength=" + profile.maxChainLength);
|
|
96
|
+
}
|
|
97
|
+
for (var i = 0; i < reply.references.length; i += 1) {
|
|
98
|
+
guardMessageId.validate(reply.references[i], { profile: "strict" });
|
|
99
|
+
}
|
|
100
|
+
if (reply.references.length > 0) {
|
|
101
|
+
var last = reply.references[reply.references.length - 1];
|
|
102
|
+
if (last !== reply.inReplyTo) {
|
|
103
|
+
throw new GuardMailReplyError("mail-reply/discontinuity",
|
|
104
|
+
"guardMailReply.validate: last References '" + last +
|
|
105
|
+
"' does not match inReplyTo '" + reply.inReplyTo + "' (RFC 5322 §3.6.4)");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof reply.quotedOriginal !== "undefined") {
|
|
111
|
+
if (typeof reply.quotedOriginal !== "string") {
|
|
112
|
+
throw new GuardMailReplyError("mail-reply/bad-quoted",
|
|
113
|
+
"guardMailReply.validate: quotedOriginal must be a string");
|
|
114
|
+
}
|
|
115
|
+
if (Buffer.byteLength(reply.quotedOriginal, "utf8") > profile.maxQuotedBytes) {
|
|
116
|
+
throw new GuardMailReplyError("mail-reply/quoted-too-big",
|
|
117
|
+
"guardMailReply.validate: quotedOriginal exceeds maxQuotedBytes=" + profile.maxQuotedBytes);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (typeof reply.forwardedAttachments !== "undefined") {
|
|
122
|
+
if (!Array.isArray(reply.forwardedAttachments)) {
|
|
123
|
+
throw new GuardMailReplyError("mail-reply/bad-fwd-attach",
|
|
124
|
+
"guardMailReply.validate: forwardedAttachments must be an array");
|
|
125
|
+
}
|
|
126
|
+
if (reply.forwardedAttachments.length > profile.maxForwardedAttachments) {
|
|
127
|
+
throw new GuardMailReplyError("mail-reply/too-many-fwd-attach",
|
|
128
|
+
"guardMailReply.validate: forwarded attachment count " +
|
|
129
|
+
reply.forwardedAttachments.length + " exceeds " + profile.maxForwardedAttachments);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return reply;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @primitive b.guardMailReply.compliancePosture
|
|
137
|
+
* @signature b.guardMailReply.compliancePosture(posture)
|
|
138
|
+
* @since 0.9.20
|
|
139
|
+
* @status stable
|
|
140
|
+
*
|
|
141
|
+
* Return the effective profile for a given compliance posture name.
|
|
142
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
143
|
+
* here instead of silently falling through to the default profile.
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* b.guardMailReply.compliancePosture("hipaa"); // → "strict"
|
|
147
|
+
*/
|
|
148
|
+
function compliancePosture(posture) {
|
|
149
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _resolveProfile(opts) {
|
|
153
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
154
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
155
|
+
}
|
|
156
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
157
|
+
if (!PROFILES[p]) {
|
|
158
|
+
throw new GuardMailReplyError("mail-reply/bad-profile",
|
|
159
|
+
"guardMailReply: unknown profile '" + p + "'");
|
|
160
|
+
}
|
|
161
|
+
return p;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
module.exports = {
|
|
165
|
+
validate: validate,
|
|
166
|
+
compliancePosture: compliancePosture,
|
|
167
|
+
PROFILES: PROFILES,
|
|
168
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
169
|
+
GuardMailReplyError: GuardMailReplyError,
|
|
170
|
+
NAME: "mailReply",
|
|
171
|
+
KIND: "mail-reply",
|
|
172
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.guardMailSieve
|
|
4
|
+
* @nav Guards
|
|
5
|
+
* @title Guard Mail Sieve
|
|
6
|
+
* @order 434
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Validator for `b.mail.agent.sieve.put` / `.activate`. The full
|
|
10
|
+
* Sieve parser + bytecode + runtime lands at v0.9.26 as
|
|
11
|
+
* `b.safeSieve`; this guard handles the agent-side actor + script-
|
|
12
|
+
* envelope checks that apply BEFORE parsing:
|
|
13
|
+
*
|
|
14
|
+
* - actor-scope check — only the owner of a script (or
|
|
15
|
+
* `mailScope: "admin"`) may edit it
|
|
16
|
+
* - script-byte cap — refuses scripts larger than `maxScriptBytes`
|
|
17
|
+
* (default 65536 — same cap v0.9.26's `b.safeSieve` will use)
|
|
18
|
+
* - script-name shape — RFC 5804 §2.3 script names are bounded
|
|
19
|
+
* UTF-8; the guard enforces the byte cap (default 256) and
|
|
20
|
+
* refuses NUL / control / slash / path-traversal shapes
|
|
21
|
+
* - line-count cap — defends scripts that are technically under
|
|
22
|
+
* the byte cap but pathological (one-character lines)
|
|
23
|
+
*
|
|
24
|
+
* When v0.9.26 ships, `b.safeSieve.validate(script)` will be
|
|
25
|
+
* invoked AFTER this guard — operators who want to bytecode-
|
|
26
|
+
* validate at agent.sieve.put time pass `requireParse: true` and
|
|
27
|
+
* the agent calls into v0.9.26's parser.
|
|
28
|
+
*
|
|
29
|
+
* @card
|
|
30
|
+
* Validates `b.mail.agent.sieve` operations. Actor-scope check,
|
|
31
|
+
* script-byte cap, name shape, line-count cap. Pre-parser checks
|
|
32
|
+
* only — full Sieve parse lands at v0.9.26 (`b.safeSieve`).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
var { defineClass } = require("./framework-error");
|
|
36
|
+
|
|
37
|
+
var GuardMailSieveError = defineClass("GuardMailSieveError", { alwaysPermanent: true });
|
|
38
|
+
|
|
39
|
+
var DEFAULT_PROFILE = "strict";
|
|
40
|
+
|
|
41
|
+
var PROFILES = Object.freeze({
|
|
42
|
+
strict: { maxScriptBytes: 65536, maxNameBytes: 256, maxLines: 2000 }, // allow:raw-byte-literal
|
|
43
|
+
balanced: { maxScriptBytes: 262144, maxNameBytes: 256, maxLines: 10000 }, // allow:raw-byte-literal
|
|
44
|
+
permissive: { maxScriptBytes: 1048576, maxNameBytes: 1024, maxLines: 50000 }, // allow:raw-byte-literal
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
48
|
+
hipaa: "strict",
|
|
49
|
+
"pci-dss": "strict",
|
|
50
|
+
gdpr: "strict",
|
|
51
|
+
soc2: "strict",
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @primitive b.guardMailSieve.validate
|
|
56
|
+
* @signature b.guardMailSieve.validate(op, opts?)
|
|
57
|
+
* @since 0.9.20
|
|
58
|
+
* @status stable
|
|
59
|
+
* @related b.mail.agent.create
|
|
60
|
+
*
|
|
61
|
+
* Validate a sieve-management op shape. `op.kind` is one of `"put"` /
|
|
62
|
+
* `"activate"` / `"delete"`; `op.actor` carries the actor; for `"put"`
|
|
63
|
+
* the `op.name` and `op.script` are validated; for `"activate"` /
|
|
64
|
+
* `"delete"` only `op.name` is required.
|
|
65
|
+
*
|
|
66
|
+
* @opts
|
|
67
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
68
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
69
|
+
* ownedNames: Array<string>, // names the actor owns (operator-supplied)
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* b.guardMailSieve.validate({
|
|
73
|
+
* kind: "put",
|
|
74
|
+
* actor: { id: "u1", mailScope: "user" },
|
|
75
|
+
* name: "my-filter",
|
|
76
|
+
* script: "require [\"fileinto\"];\nif address :is \"From\" \"x@x\" { fileinto \"Junk\"; }",
|
|
77
|
+
* }, { ownedNames: ["my-filter"] });
|
|
78
|
+
*/
|
|
79
|
+
function validate(op, opts) {
|
|
80
|
+
opts = opts || {};
|
|
81
|
+
var profile = PROFILES[_resolveProfile(opts)];
|
|
82
|
+
if (!op || typeof op !== "object") {
|
|
83
|
+
throw new GuardMailSieveError("mail-sieve/bad-input",
|
|
84
|
+
"guardMailSieve.validate: op required");
|
|
85
|
+
}
|
|
86
|
+
if (op.kind !== "put" && op.kind !== "activate" && op.kind !== "delete") {
|
|
87
|
+
throw new GuardMailSieveError("mail-sieve/bad-kind",
|
|
88
|
+
"guardMailSieve.validate: op.kind must be 'put' | 'activate' | 'delete'");
|
|
89
|
+
}
|
|
90
|
+
if (!op.actor || typeof op.actor !== "object" || typeof op.actor.id !== "string") {
|
|
91
|
+
throw new GuardMailSieveError("mail-sieve/no-actor",
|
|
92
|
+
"guardMailSieve.validate: op.actor with .id required");
|
|
93
|
+
}
|
|
94
|
+
_checkName(op.name, profile);
|
|
95
|
+
|
|
96
|
+
if (op.kind === "put") {
|
|
97
|
+
if (typeof op.script !== "string") {
|
|
98
|
+
throw new GuardMailSieveError("mail-sieve/bad-script",
|
|
99
|
+
"guardMailSieve.validate: op.script must be a string");
|
|
100
|
+
}
|
|
101
|
+
var bytes = Buffer.byteLength(op.script, "utf8");
|
|
102
|
+
if (bytes === 0) {
|
|
103
|
+
throw new GuardMailSieveError("mail-sieve/empty-script",
|
|
104
|
+
"guardMailSieve.validate: script must be non-empty");
|
|
105
|
+
}
|
|
106
|
+
if (bytes > profile.maxScriptBytes) {
|
|
107
|
+
throw new GuardMailSieveError("mail-sieve/script-too-big",
|
|
108
|
+
"guardMailSieve.validate: script " + bytes + " bytes exceeds maxScriptBytes=" +
|
|
109
|
+
profile.maxScriptBytes);
|
|
110
|
+
}
|
|
111
|
+
// Line-count cap (a one-byte-line bomb stays under maxScriptBytes
|
|
112
|
+
// but blows up later parser stages; refuse here).
|
|
113
|
+
var lineCount = 1;
|
|
114
|
+
for (var i = 0; i < op.script.length; i += 1) {
|
|
115
|
+
if (op.script.charCodeAt(i) === 0x0A) lineCount += 1; // allow:raw-byte-literal — LF
|
|
116
|
+
}
|
|
117
|
+
if (lineCount > profile.maxLines) {
|
|
118
|
+
throw new GuardMailSieveError("mail-sieve/too-many-lines",
|
|
119
|
+
"guardMailSieve.validate: " + lineCount + " lines exceeds maxLines=" + profile.maxLines);
|
|
120
|
+
}
|
|
121
|
+
// Control-char refusal in script (NUL is always refused; other
|
|
122
|
+
// C0 except CR/LF/TAB are refused too — Sieve scripts are
|
|
123
|
+
// text-only per RFC 5228 §1.4).
|
|
124
|
+
for (var j = 0; j < op.script.length; j += 1) {
|
|
125
|
+
var c = op.script.charCodeAt(j);
|
|
126
|
+
if (c === 0x00 || (c < 0x20 && c !== 0x09 && c !== 0x0A && c !== 0x0D) || c === 0x7F) { // allow:raw-byte-literal — NUL / C0 except TAB/LF/CR / DEL refusal
|
|
127
|
+
throw new GuardMailSieveError("mail-sieve/control-char-in-script",
|
|
128
|
+
"guardMailSieve.validate: control char 0x" + c.toString(16) + " at offset " + j);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Actor-scope check — non-admin actors may only edit / activate /
|
|
134
|
+
// delete scripts whose name is in their owned-names list. Operator
|
|
135
|
+
// supplies the list via opts.ownedNames (looked up from RBAC table).
|
|
136
|
+
var isAdmin = op.actor.mailScope === "admin";
|
|
137
|
+
if (!isAdmin) {
|
|
138
|
+
var owned = Array.isArray(opts.ownedNames) ? opts.ownedNames : [];
|
|
139
|
+
if (owned.indexOf(op.name) < 0) {
|
|
140
|
+
throw new GuardMailSieveError("mail-sieve/not-owner",
|
|
141
|
+
"guardMailSieve.validate: actor does not own script '" + op.name +
|
|
142
|
+
"' (use mailScope:'admin' or supply opts.ownedNames)");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return op;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @primitive b.guardMailSieve.compliancePosture
|
|
150
|
+
* @signature b.guardMailSieve.compliancePosture(posture)
|
|
151
|
+
* @since 0.9.20
|
|
152
|
+
* @status stable
|
|
153
|
+
*
|
|
154
|
+
* Return the effective profile for a given compliance posture name.
|
|
155
|
+
* Returns `null` for unknown posture names so operator typos surface
|
|
156
|
+
* here instead of silently falling through to the default profile.
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* b.guardMailSieve.compliancePosture("hipaa"); // → "strict"
|
|
160
|
+
*/
|
|
161
|
+
function compliancePosture(posture) {
|
|
162
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _checkName(name, profile) {
|
|
166
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
167
|
+
throw new GuardMailSieveError("mail-sieve/bad-name",
|
|
168
|
+
"guardMailSieve.validate: op.name must be a non-empty string");
|
|
169
|
+
}
|
|
170
|
+
if (Buffer.byteLength(name, "utf8") > profile.maxNameBytes) {
|
|
171
|
+
throw new GuardMailSieveError("mail-sieve/name-too-long",
|
|
172
|
+
"guardMailSieve.validate: op.name exceeds maxNameBytes=" + profile.maxNameBytes);
|
|
173
|
+
}
|
|
174
|
+
if (name.indexOf("..") >= 0) {
|
|
175
|
+
throw new GuardMailSieveError("mail-sieve/path-traversal",
|
|
176
|
+
"guardMailSieve.validate: op.name contains '..'");
|
|
177
|
+
}
|
|
178
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
179
|
+
var c = name.charCodeAt(i);
|
|
180
|
+
if (c < 0x20 || c === 0x7F || c === 0x2F || c === 0x5C) { // allow:raw-byte-literal — C0 / DEL / slash / backslash refusal
|
|
181
|
+
throw new GuardMailSieveError("mail-sieve/bad-name-char",
|
|
182
|
+
"guardMailSieve.validate: op.name contains forbidden char 0x" + c.toString(16));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _resolveProfile(opts) {
|
|
188
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
189
|
+
return COMPLIANCE_POSTURES[opts.posture];
|
|
190
|
+
}
|
|
191
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
192
|
+
if (!PROFILES[p]) {
|
|
193
|
+
throw new GuardMailSieveError("mail-sieve/bad-profile",
|
|
194
|
+
"guardMailSieve: unknown profile '" + p + "'");
|
|
195
|
+
}
|
|
196
|
+
return p;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
validate: validate,
|
|
201
|
+
compliancePosture: compliancePosture,
|
|
202
|
+
PROFILES: PROFILES,
|
|
203
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
204
|
+
GuardMailSieveError: GuardMailSieveError,
|
|
205
|
+
NAME: "mailSieve",
|
|
206
|
+
KIND: "mail-sieve",
|
|
207
|
+
};
|