@blamejs/core 0.9.49 → 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.
- package/CHANGELOG.md +951 -908
- package/index.js +25 -0
- package/lib/_test/crypto-fixtures.js +67 -0
- package/lib/agent-event-bus.js +52 -6
- package/lib/agent-idempotency.js +169 -16
- package/lib/agent-orchestrator.js +263 -9
- package/lib/agent-posture-chain.js +163 -5
- package/lib/agent-saga.js +146 -16
- package/lib/agent-snapshot.js +349 -19
- package/lib/agent-stream.js +34 -2
- package/lib/agent-tenant.js +179 -23
- package/lib/agent-trace.js +84 -21
- package/lib/auth/aal.js +8 -1
- package/lib/auth/ciba.js +6 -1
- package/lib/auth/dpop.js +7 -2
- package/lib/auth/fal.js +17 -8
- package/lib/auth/jwt-external.js +128 -4
- package/lib/auth/oauth.js +232 -10
- package/lib/auth/oid4vci.js +67 -7
- package/lib/auth/openid-federation.js +71 -25
- package/lib/auth/passkey.js +140 -6
- package/lib/auth/sd-jwt-vc.js +67 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/compliance.js +176 -8
- package/lib/crypto-field.js +114 -14
- package/lib/crypto.js +216 -20
- package/lib/db.js +1 -0
- package/lib/guard-jmap.js +321 -0
- package/lib/guard-managesieve-command.js +566 -0
- package/lib/guard-pop3-command.js +317 -0
- package/lib/guard-smtp-command.js +58 -3
- package/lib/mail-agent.js +20 -7
- package/lib/mail-arc-sign.js +12 -8
- package/lib/mail-auth.js +323 -34
- package/lib/mail-crypto-pgp.js +934 -0
- package/lib/mail-crypto-smime.js +340 -0
- package/lib/mail-crypto.js +108 -0
- package/lib/mail-dav.js +1224 -0
- package/lib/mail-deploy.js +492 -0
- package/lib/mail-dkim.js +431 -26
- package/lib/mail-journal.js +435 -0
- package/lib/mail-scan.js +502 -0
- package/lib/mail-server-imap.js +64 -26
- package/lib/mail-server-jmap.js +488 -0
- package/lib/mail-server-managesieve.js +853 -0
- package/lib/mail-server-mx.js +40 -30
- package/lib/mail-server-pop3.js +836 -0
- package/lib/mail-server-rate-limit.js +13 -0
- package/lib/mail-server-submission.js +70 -24
- package/lib/mail-server-tls.js +445 -0
- package/lib/mail-sieve.js +557 -0
- package/lib/mail-spam-score.js +284 -0
- package/lib/mail.js +99 -0
- package/lib/metrics.js +80 -3
- package/lib/middleware/dpop.js +58 -3
- package/lib/middleware/idempotency-key.js +255 -42
- package/lib/middleware/protected-resource-metadata.js +114 -2
- package/lib/network-dns-resolver.js +33 -0
- package/lib/network-tls.js +46 -0
- package/lib/outbox.js +62 -12
- package/lib/pqc-agent.js +13 -5
- package/lib/retry.js +23 -9
- package/lib/router.js +23 -1
- package/lib/safe-ical.js +634 -0
- package/lib/safe-icap.js +502 -0
- package/lib/safe-mime.js +15 -0
- package/lib/safe-sieve.js +684 -0
- package/lib/safe-smtp.js +57 -0
- package/lib/safe-url.js +37 -0
- package/lib/safe-vcard.js +473 -0
- package/lib/self-update-standalone-verifier.js +32 -3
- package/lib/self-update.js +153 -33
- package/lib/vendor/MANIFEST.json +161 -156
- package/lib/vendor-data.js +127 -9
- package/lib/vex.js +324 -59
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-scan.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mail.scan
|
|
4
|
+
* @nav Mail
|
|
5
|
+
* @title Mail Scan
|
|
6
|
+
* @order 555
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* Anti-virus / content-scan facade for inbound + outbound mail.
|
|
10
|
+
* Operators wire `b.mail.scan.create({...})` once at boot, then call
|
|
11
|
+
* `.scan(messageBytes, opts)` from the MX listener (v0.9.45),
|
|
12
|
+
* submission listener (v0.9.47), or any custom pipeline that needs a
|
|
13
|
+
* verdict before delivering / forwarding a message.
|
|
14
|
+
*
|
|
15
|
+
* ## Backends
|
|
16
|
+
*
|
|
17
|
+
* Two transport shapes are supported out of the box:
|
|
18
|
+
*
|
|
19
|
+
* - **ICAP** (`protocol: "icap"` — default) — RFC 3507 Internet
|
|
20
|
+
* Content Adaptation Protocol. The framework speaks to a c-icap
|
|
21
|
+
* (or commercial) daemon over TCP, sends a REQMOD or RESPMOD
|
|
22
|
+
* request with the message body encapsulated, and parses the
|
|
23
|
+
* response through `b.safeIcap.parse`. The de-facto standard for
|
|
24
|
+
* Sophos / Symantec / Trend Micro / McAfee ICAP integrations.
|
|
25
|
+
*
|
|
26
|
+
* - **ClamAV INSTREAM** (`protocol: "clamav-instream"`) — the
|
|
27
|
+
* native ClamAV daemon protocol (no ICAP layer). Operator points
|
|
28
|
+
* at a clamd instance; the framework sends `zINSTREAM\0`, framed
|
|
29
|
+
* 4-byte-length-prefix chunks, then a zero-length terminator, and
|
|
30
|
+
* parses the line-shaped `<id>: stream: OK` / `<id>: stream:
|
|
31
|
+
* <virus> FOUND` response.
|
|
32
|
+
* See https://docs.clamav.net/manual/Usage/Configuration.html#instream
|
|
33
|
+
*
|
|
34
|
+
* ## Composition
|
|
35
|
+
*
|
|
36
|
+
* - **`b.safeIcap`** owns the ICAP wire-protocol bounded parser
|
|
37
|
+
* (CRLF discipline, status-allowlist, body cap). Every ICAP byte
|
|
38
|
+
* routes through it before any field is trusted.
|
|
39
|
+
* - **`b.guardArchive`** is composed when `opts.archiveEntries` is
|
|
40
|
+
* supplied — the scanner refuses an archive with hostile entry
|
|
41
|
+
* metadata BEFORE shipping bytes to the AV daemon, so a zip-bomb
|
|
42
|
+
* can't reach the scanner's parser. Recursion-depth cap is the
|
|
43
|
+
* guard's profile-default.
|
|
44
|
+
* - **`b.audit`** receives every request / verdict / error /
|
|
45
|
+
* timeout via `audit.safeEmit` (the audit failure is drop-silent
|
|
46
|
+
* per the hot-path rule).
|
|
47
|
+
*
|
|
48
|
+
* ## Threat model
|
|
49
|
+
*
|
|
50
|
+
* - **ICAP-response-injection (raw bytes → header injection)**:
|
|
51
|
+
* defended by `b.safeIcap` — bare-CR / bare-LF / NUL refused;
|
|
52
|
+
* status-code allowlist; bounded header / body / count caps.
|
|
53
|
+
* - **Parser-bomb on Encapsulated res-body** (hostile daemon ships
|
|
54
|
+
* arbitrary body length): defended by profile-tunable
|
|
55
|
+
* `maxBodyBytes` cap on the safeIcap parse path.
|
|
56
|
+
* - **DoS via slow daemon**: per-request wall-clock timeout (default
|
|
57
|
+
* 30s strict / 60s balanced / 120s permissive). After the timeout
|
|
58
|
+
* the scan resolves with `{ verdict: "error" }` and the listener
|
|
59
|
+
* fails the message-handling step (operator's choice: tempfail /
|
|
60
|
+
* reject / accept-with-tag).
|
|
61
|
+
* - **Archive-bomb / zip-slip pre-AV**: defended by optional
|
|
62
|
+
* `b.guardArchive.validateEntries` composition when the operator
|
|
63
|
+
* enumerates entries before the AV scan.
|
|
64
|
+
*
|
|
65
|
+
* ## Why not "vendor an AV signature engine"?
|
|
66
|
+
*
|
|
67
|
+
* AV signature databases are operator state, not framework state.
|
|
68
|
+
* ClamAV's signature set changes hourly; commercial scanners refresh
|
|
69
|
+
* their state through their own update channel. The framework's job
|
|
70
|
+
* is the wire-protocol parser + the operator-facing facade — the AV
|
|
71
|
+
* intelligence belongs to whatever daemon the operator deploys.
|
|
72
|
+
*
|
|
73
|
+
* @card
|
|
74
|
+
* ICAP (RFC 3507) + ClamAV-INSTREAM AV-scan facade. Composes
|
|
75
|
+
* b.safeIcap for wire-bytes hardening, b.guardArchive for pre-scan
|
|
76
|
+
* archive-metadata refusal, b.audit for verdict emission. Two
|
|
77
|
+
* built-in backends; operator points at their existing daemon.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var net = require("node:net");
|
|
81
|
+
var safeBuffer = require("./safe-buffer");
|
|
82
|
+
var C = require("./constants");
|
|
83
|
+
var { defineClass } = require("./framework-error");
|
|
84
|
+
var lazyRequire = require("./lazy-require");
|
|
85
|
+
var validateOpts = require("./validate-opts");
|
|
86
|
+
var numericBounds = require("./numeric-bounds");
|
|
87
|
+
var safeIcap = require("./safe-icap");
|
|
88
|
+
|
|
89
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
90
|
+
var guardArchive = lazyRequire(function () { return require("./guard-archive"); });
|
|
91
|
+
|
|
92
|
+
var MailScanError = defineClass("MailScanError", { alwaysPermanent: true });
|
|
93
|
+
|
|
94
|
+
var DEFAULT_PROFILE = "strict";
|
|
95
|
+
var DEFAULT_PROTOCOL = "icap";
|
|
96
|
+
var DEFAULT_ICAP_SERVICE = "srv_clamav";
|
|
97
|
+
|
|
98
|
+
// allow:raw-byte-literal — ClamAV INSTREAM 4-byte length prefix.
|
|
99
|
+
var CLAMAV_LENGTH_PREFIX_BYTES = 4;
|
|
100
|
+
|
|
101
|
+
// allow:raw-byte-literal — ClamAV INSTREAM chunk size for streaming.
|
|
102
|
+
var CLAMAV_CHUNK_BYTES = 65536;
|
|
103
|
+
|
|
104
|
+
var PROFILES = Object.freeze({
|
|
105
|
+
strict: { timeoutMs: C.TIME.seconds(30), maxMessageBytes: C.BYTES.mib(25), maxResponseBytes: C.BYTES.mib(50) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
106
|
+
balanced: { timeoutMs: C.TIME.seconds(60), maxMessageBytes: C.BYTES.mib(50), maxResponseBytes: C.BYTES.mib(100) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
107
|
+
permissive: { timeoutMs: C.TIME.seconds(120), maxMessageBytes: C.BYTES.mib(150), maxResponseBytes: C.BYTES.mib(300) }, // allow:raw-byte-literal — operator-facing default mailbox cap
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
111
|
+
hipaa: "strict",
|
|
112
|
+
"pci-dss": "strict",
|
|
113
|
+
gdpr: "strict",
|
|
114
|
+
soc2: "strict",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
var ALLOWED_PROTOCOLS = Object.freeze({
|
|
118
|
+
"icap": true,
|
|
119
|
+
"clamav-instream": true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @primitive b.mail.scan.create
|
|
124
|
+
* @signature b.mail.scan.create(opts)
|
|
125
|
+
* @since 0.9.81
|
|
126
|
+
* @status stable
|
|
127
|
+
* @related b.safeIcap.parse, b.guardArchive.validateEntries
|
|
128
|
+
*
|
|
129
|
+
* Build a mail-scan handle. Returns `{ scan(messageBytes, opts),
|
|
130
|
+
* profile, protocol, MailScanError }` where `.scan` resolves to
|
|
131
|
+
* `{ verdict, icapResponse?, threats?, durationMs }`:
|
|
132
|
+
*
|
|
133
|
+
* - `verdict`: `"clean"` | `"infected"` | `"error"`.
|
|
134
|
+
* - `icapResponse`: the structured `b.safeIcap.parse` result on
|
|
135
|
+
* ICAP backend (omitted on clamav-instream).
|
|
136
|
+
* - `threats`: Array<string> of threat names when infected.
|
|
137
|
+
* - `durationMs`: round-trip ms (audit / metrics).
|
|
138
|
+
*
|
|
139
|
+
* @opts
|
|
140
|
+
* host: string — required. ICAP / clamd hostname or IP.
|
|
141
|
+
* port: number — required. ICAP port (default 1344) /
|
|
142
|
+
* clamd port (default 3310).
|
|
143
|
+
* service: string — ICAP service name (default "srv_clamav").
|
|
144
|
+
* protocol: "icap" | "clamav-instream" — default "icap".
|
|
145
|
+
* timeoutMs: number — per-request wall clock; default per profile.
|
|
146
|
+
* profile: "strict" | "balanced" | "permissive".
|
|
147
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2".
|
|
148
|
+
* audit: b.audit instance (drop-silent on failure).
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* var scanner = b.mail.scan.create({
|
|
152
|
+
* host: "av.internal",
|
|
153
|
+
* port: 1344,
|
|
154
|
+
* service: "srv_clamav",
|
|
155
|
+
* });
|
|
156
|
+
* var verdict = await scanner.scan(rawMessage);
|
|
157
|
+
* if (verdict.verdict === "infected") refuseMessage(verdict.threats);
|
|
158
|
+
*/
|
|
159
|
+
function create(opts) {
|
|
160
|
+
opts = validateOpts.requireObject(opts || {}, "mail.scan.create", MailScanError, "mail-scan/bad-opts");
|
|
161
|
+
validateOpts(opts, [
|
|
162
|
+
"host", "port", "service", "protocol",
|
|
163
|
+
"timeoutMs", "profile", "posture", "audit",
|
|
164
|
+
], "mail.scan.create");
|
|
165
|
+
|
|
166
|
+
validateOpts.requireNonEmptyString(opts.host, "mail.scan.create.host",
|
|
167
|
+
MailScanError, "mail-scan/bad-host");
|
|
168
|
+
if (!numericBounds.isPositiveFiniteInt(opts.port) || opts.port > 65535) { // allow:raw-byte-literal — TCP port-number range cap
|
|
169
|
+
throw new MailScanError("mail-scan/bad-port",
|
|
170
|
+
"mail.scan.create.port must be a positive integer in [1,65535]; got " +
|
|
171
|
+
numericBounds.shape(opts.port));
|
|
172
|
+
}
|
|
173
|
+
var protocol = opts.protocol || DEFAULT_PROTOCOL;
|
|
174
|
+
if (!ALLOWED_PROTOCOLS[protocol]) {
|
|
175
|
+
throw new MailScanError("mail-scan/bad-protocol",
|
|
176
|
+
"mail.scan.create.protocol must be 'icap' or 'clamav-instream'; got '" + protocol + "'");
|
|
177
|
+
}
|
|
178
|
+
var service = opts.service || DEFAULT_ICAP_SERVICE;
|
|
179
|
+
if (protocol === "icap") {
|
|
180
|
+
validateOpts.requireNonEmptyString(service, "mail.scan.create.service",
|
|
181
|
+
MailScanError, "mail-scan/bad-service");
|
|
182
|
+
}
|
|
183
|
+
var profile = opts.profile || (opts.posture && COMPLIANCE_POSTURES[opts.posture]) || DEFAULT_PROFILE;
|
|
184
|
+
if (!PROFILES[profile]) {
|
|
185
|
+
throw new MailScanError("mail-scan/bad-profile",
|
|
186
|
+
"mail.scan.create.profile: unknown '" + profile + "' (valid: strict / balanced / permissive)");
|
|
187
|
+
}
|
|
188
|
+
var caps = PROFILES[profile];
|
|
189
|
+
numericBounds.requirePositiveFiniteIntIfPresent(opts.timeoutMs,
|
|
190
|
+
"mail.scan.create.timeoutMs", MailScanError, "mail-scan/bad-timeout");
|
|
191
|
+
var timeoutMs = opts.timeoutMs || caps.timeoutMs;
|
|
192
|
+
var auditImpl = opts.audit || audit();
|
|
193
|
+
|
|
194
|
+
function scan(messageBytes, scanOpts) {
|
|
195
|
+
scanOpts = scanOpts || {};
|
|
196
|
+
if (!Buffer.isBuffer(messageBytes)) {
|
|
197
|
+
throw new MailScanError("mail-scan/bad-input",
|
|
198
|
+
"mail.scan.scan: messageBytes must be a Buffer; got " + (typeof messageBytes));
|
|
199
|
+
}
|
|
200
|
+
if (messageBytes.length === 0) {
|
|
201
|
+
throw new MailScanError("mail-scan/bad-input",
|
|
202
|
+
"mail.scan.scan: messageBytes must be non-empty");
|
|
203
|
+
}
|
|
204
|
+
if (messageBytes.length > caps.maxMessageBytes) {
|
|
205
|
+
throw new MailScanError("mail-scan/oversize-message",
|
|
206
|
+
"mail.scan.scan: messageBytes=" + messageBytes.length + " exceeds maxMessageBytes=" +
|
|
207
|
+
caps.maxMessageBytes);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Optional archive-entries gate — compose b.guardArchive when the
|
|
211
|
+
// operator enumerates entries before scanning. Hostile entry metadata
|
|
212
|
+
// (zip-slip / hardlink-escape / decompression-bomb-ratio) refuses
|
|
213
|
+
// before the AV daemon ever sees the bytes.
|
|
214
|
+
if (Array.isArray(scanOpts.archiveEntries)) {
|
|
215
|
+
var gv = guardArchive().validateEntries(scanOpts.archiveEntries, { profile: profile });
|
|
216
|
+
if (gv && gv.issues && gv.issues.length > 0) {
|
|
217
|
+
var infectedThreats = gv.issues.map(function (i) { return "archive:" + (i.code || "unknown"); });
|
|
218
|
+
_emitAudit(auditImpl, "mail.scan.infected", "success", {
|
|
219
|
+
reason: "archive-pre-scan", threats: infectedThreats,
|
|
220
|
+
});
|
|
221
|
+
return Promise.resolve({
|
|
222
|
+
verdict: "infected",
|
|
223
|
+
threats: infectedThreats,
|
|
224
|
+
durationMs: 0,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
_emitAudit(auditImpl, "mail.scan.request", "success", {
|
|
230
|
+
protocol: protocol, host: opts.host, port: opts.port, bytes: messageBytes.length,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
var t0 = Date.now();
|
|
234
|
+
if (protocol === "icap") {
|
|
235
|
+
return _scanIcap(messageBytes, scanOpts).then(function (rv) {
|
|
236
|
+
rv.durationMs = Date.now() - t0;
|
|
237
|
+
_emitScanResult(auditImpl, rv);
|
|
238
|
+
return rv;
|
|
239
|
+
}, function (e) {
|
|
240
|
+
var ms = Date.now() - t0;
|
|
241
|
+
return _failTo(auditImpl, e, ms);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return _scanClamavInstream(messageBytes).then(function (rv) {
|
|
245
|
+
rv.durationMs = Date.now() - t0;
|
|
246
|
+
_emitScanResult(auditImpl, rv);
|
|
247
|
+
return rv;
|
|
248
|
+
}, function (e) {
|
|
249
|
+
var ms = Date.now() - t0;
|
|
250
|
+
return _failTo(auditImpl, e, ms);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _scanIcap(messageBytes, scanOpts) {
|
|
255
|
+
return new Promise(function (resolve, reject) {
|
|
256
|
+
var sock = (scanOpts._socket && typeof scanOpts._socket.write === "function")
|
|
257
|
+
? scanOpts._socket
|
|
258
|
+
: net.createConnection({ host: opts.host, port: opts.port });
|
|
259
|
+
|
|
260
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
261
|
+
maxBytes: caps.maxResponseBytes,
|
|
262
|
+
errorClass: MailScanError,
|
|
263
|
+
sizeCode: "mail-scan/icap-response-too-large",
|
|
264
|
+
sizeMessage: "mail.scan.scan: ICAP response exceeded maxResponseBytes",
|
|
265
|
+
});
|
|
266
|
+
var done = false;
|
|
267
|
+
var to = setTimeout(function () {
|
|
268
|
+
if (done) return;
|
|
269
|
+
done = true;
|
|
270
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
271
|
+
reject(new MailScanError("mail-scan/timeout",
|
|
272
|
+
"mail.scan.scan: ICAP timeout after " + timeoutMs + "ms"));
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
|
|
275
|
+
sock.on("error", function (e) {
|
|
276
|
+
if (done) return;
|
|
277
|
+
done = true;
|
|
278
|
+
clearTimeout(to);
|
|
279
|
+
reject(new MailScanError("mail-scan/transport",
|
|
280
|
+
"mail.scan.scan: ICAP socket error: " + (e && e.message || e)));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
sock.on("data", function (chunk) {
|
|
284
|
+
try { collector.push(chunk); }
|
|
285
|
+
catch (e) {
|
|
286
|
+
if (done) return;
|
|
287
|
+
done = true;
|
|
288
|
+
clearTimeout(to);
|
|
289
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
290
|
+
reject(e);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
sock.on("end", function () {
|
|
295
|
+
if (done) return;
|
|
296
|
+
done = true;
|
|
297
|
+
clearTimeout(to);
|
|
298
|
+
try {
|
|
299
|
+
var raw = collector.result();
|
|
300
|
+
var parsed = safeIcap.parse(raw, { profile: profile });
|
|
301
|
+
var threats = [];
|
|
302
|
+
if (parsed.threatName) threats.push(parsed.threatName);
|
|
303
|
+
resolve({
|
|
304
|
+
verdict: parsed.threatFound ? "infected" : (parsed.statusCode === 200 || parsed.statusCode === 204 ? "clean" : "error"),
|
|
305
|
+
icapResponse: parsed,
|
|
306
|
+
threats: threats,
|
|
307
|
+
});
|
|
308
|
+
} catch (e) {
|
|
309
|
+
reject(e);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// RFC 3507 §4.3.2 — RESPMOD request: ICAP-Version, Host header,
|
|
314
|
+
// Encapsulated header pointing at res-hdr=0, res-body=N. We send
|
|
315
|
+
// a minimal HTTP-response wrapper around the raw mail bytes; the
|
|
316
|
+
// ICAP daemon scans the body region.
|
|
317
|
+
var httpHdr = "HTTP/1.1 200 OK\r\nContent-Type: message/rfc822\r\nContent-Length: " +
|
|
318
|
+
messageBytes.length + "\r\n\r\n";
|
|
319
|
+
var resBodyOffset = Buffer.byteLength(httpHdr, "ascii");
|
|
320
|
+
var icapHdr =
|
|
321
|
+
"RESPMOD icap://" + opts.host + ":" + opts.port + "/" + service + " ICAP/1.0\r\n" +
|
|
322
|
+
"Host: " + opts.host + "\r\n" +
|
|
323
|
+
"Allow: 204\r\n" +
|
|
324
|
+
"Encapsulated: res-hdr=0, res-body=" + resBodyOffset + "\r\n" +
|
|
325
|
+
"\r\n";
|
|
326
|
+
try {
|
|
327
|
+
sock.write(icapHdr);
|
|
328
|
+
sock.write(httpHdr);
|
|
329
|
+
// RFC 3507 §4.4.3 — body is chunked-transfer; we write a single
|
|
330
|
+
// chunk + terminator for simplicity. The wire format is the same
|
|
331
|
+
// as HTTP/1.1 chunked: `<hex-length>\r\n<bytes>\r\n0\r\n\r\n`.
|
|
332
|
+
var lenHex = messageBytes.length.toString(16); // allow:raw-byte-literal — hex radix
|
|
333
|
+
sock.write(lenHex + "\r\n");
|
|
334
|
+
sock.write(messageBytes);
|
|
335
|
+
sock.write("\r\n0\r\n\r\n");
|
|
336
|
+
if (typeof sock.end === "function") sock.end();
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (done) return;
|
|
339
|
+
done = true;
|
|
340
|
+
clearTimeout(to);
|
|
341
|
+
reject(new MailScanError("mail-scan/transport",
|
|
342
|
+
"mail.scan.scan: ICAP write error: " + (e && e.message || e)));
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function _scanClamavInstream(messageBytes) {
|
|
348
|
+
return new Promise(function (resolve, reject) {
|
|
349
|
+
var sock = net.createConnection({ host: opts.host, port: opts.port });
|
|
350
|
+
var collector = safeBuffer.boundedChunkCollector({
|
|
351
|
+
maxBytes: caps.maxResponseBytes,
|
|
352
|
+
errorClass: MailScanError,
|
|
353
|
+
sizeCode: "mail-scan/clamav-response-too-large",
|
|
354
|
+
sizeMessage: "mail.scan.scan: clamav reply exceeded maxResponseBytes",
|
|
355
|
+
});
|
|
356
|
+
var done = false;
|
|
357
|
+
var to = setTimeout(function () {
|
|
358
|
+
if (done) return;
|
|
359
|
+
done = true;
|
|
360
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
361
|
+
reject(new MailScanError("mail-scan/timeout",
|
|
362
|
+
"mail.scan.scan: clamav-instream timeout after " + timeoutMs + "ms"));
|
|
363
|
+
}, timeoutMs);
|
|
364
|
+
|
|
365
|
+
sock.on("error", function (e) {
|
|
366
|
+
if (done) return;
|
|
367
|
+
done = true;
|
|
368
|
+
clearTimeout(to);
|
|
369
|
+
reject(new MailScanError("mail-scan/transport",
|
|
370
|
+
"mail.scan.scan: clamav socket error: " + (e && e.message || e)));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
sock.on("data", function (chunk) {
|
|
374
|
+
try { collector.push(chunk); }
|
|
375
|
+
catch (e) {
|
|
376
|
+
if (done) return;
|
|
377
|
+
done = true;
|
|
378
|
+
clearTimeout(to);
|
|
379
|
+
try { sock.destroy(); } catch (_e) { /* drop */ }
|
|
380
|
+
reject(e);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
sock.on("end", function () {
|
|
385
|
+
if (done) return;
|
|
386
|
+
done = true;
|
|
387
|
+
clearTimeout(to);
|
|
388
|
+
var reply = collector.result().toString("utf8").replace(/[\r\n\0]+$/g, ""); // allow:regex-no-length-cap — trailing-trim anchored
|
|
389
|
+
// ClamAV INSTREAM reply: "<id>: <verdict>" where verdict is
|
|
390
|
+
// "stream: OK", "stream: <Sig.Name> FOUND", or "INSTREAM size
|
|
391
|
+
// limit exceeded. ERROR".
|
|
392
|
+
if (/stream:\s+OK\b/.test(reply)) { // allow:regex-no-length-cap — anchored to fixed token
|
|
393
|
+
resolve({ verdict: "clean", threats: [] });
|
|
394
|
+
} else {
|
|
395
|
+
var m = reply.match(/stream:\s+(.+?)\s+FOUND\b/); // allow:regex-no-length-cap — anchored to fixed FOUND token
|
|
396
|
+
if (m) {
|
|
397
|
+
resolve({ verdict: "infected", threats: [m[1]] });
|
|
398
|
+
} else if (/ERROR/i.test(reply)) { // allow:regex-no-length-cap — anchored to fixed ERROR token
|
|
399
|
+
resolve({ verdict: "error", threats: [] });
|
|
400
|
+
} else {
|
|
401
|
+
// Unrecognized reply shape — treat as error so the caller
|
|
402
|
+
// gets a definite "do not deliver" signal instead of a
|
|
403
|
+
// silent clean verdict.
|
|
404
|
+
resolve({ verdict: "error", threats: [] });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
try {
|
|
410
|
+
sock.write("zINSTREAM\0");
|
|
411
|
+
var off = 0;
|
|
412
|
+
while (off < messageBytes.length) {
|
|
413
|
+
var endOff = Math.min(off + CLAMAV_CHUNK_BYTES, messageBytes.length);
|
|
414
|
+
var lenBuf = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
|
|
415
|
+
lenBuf.writeUInt32BE(endOff - off, 0);
|
|
416
|
+
sock.write(lenBuf);
|
|
417
|
+
sock.write(messageBytes.slice(off, endOff));
|
|
418
|
+
off = endOff;
|
|
419
|
+
}
|
|
420
|
+
var term = Buffer.alloc(CLAMAV_LENGTH_PREFIX_BYTES);
|
|
421
|
+
term.writeUInt32BE(0, 0);
|
|
422
|
+
sock.write(term);
|
|
423
|
+
if (typeof sock.end === "function") sock.end();
|
|
424
|
+
} catch (e) {
|
|
425
|
+
if (done) return;
|
|
426
|
+
done = true;
|
|
427
|
+
clearTimeout(to);
|
|
428
|
+
reject(new MailScanError("mail-scan/transport",
|
|
429
|
+
"mail.scan.scan: clamav write error: " + (e && e.message || e)));
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
scan: scan,
|
|
436
|
+
profile: profile,
|
|
437
|
+
protocol: protocol,
|
|
438
|
+
host: opts.host,
|
|
439
|
+
port: opts.port,
|
|
440
|
+
service: service,
|
|
441
|
+
timeoutMs: timeoutMs,
|
|
442
|
+
MailScanError: MailScanError,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* @primitive b.mail.scan.compliancePosture
|
|
448
|
+
* @signature b.mail.scan.compliancePosture(posture)
|
|
449
|
+
* @since 0.9.81
|
|
450
|
+
* @status stable
|
|
451
|
+
*
|
|
452
|
+
* Return the effective profile name for a compliance posture, or
|
|
453
|
+
* `null` for unknown posture names.
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* b.mail.scan.compliancePosture("hipaa"); // → "strict"
|
|
457
|
+
*/
|
|
458
|
+
function compliancePosture(posture) {
|
|
459
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function _emitScanResult(auditImpl, rv) {
|
|
463
|
+
if (rv.verdict === "clean") {
|
|
464
|
+
_emitAudit(auditImpl, "mail.scan.clean", "success", { durationMs: rv.durationMs });
|
|
465
|
+
} else if (rv.verdict === "infected") {
|
|
466
|
+
_emitAudit(auditImpl, "mail.scan.infected", "success", {
|
|
467
|
+
durationMs: rv.durationMs, threats: rv.threats,
|
|
468
|
+
});
|
|
469
|
+
} else {
|
|
470
|
+
_emitAudit(auditImpl, "mail.scan.error", "failure", { durationMs: rv.durationMs });
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function _failTo(auditImpl, e, ms) {
|
|
475
|
+
if (e && e.code === "mail-scan/timeout") {
|
|
476
|
+
_emitAudit(auditImpl, "mail.scan.timeout", "failure", { durationMs: ms });
|
|
477
|
+
} else {
|
|
478
|
+
_emitAudit(auditImpl, "mail.scan.error", "failure", {
|
|
479
|
+
durationMs: ms, message: (e && e.message) || String(e),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
return { verdict: "error", threats: [], durationMs: ms,
|
|
483
|
+
errorCode: (e && e.code) || "mail-scan/unknown",
|
|
484
|
+
errorMessage: (e && e.message) || String(e) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function _emitAudit(auditImpl, action, outcome, metadata) {
|
|
488
|
+
try {
|
|
489
|
+
if (auditImpl && typeof auditImpl.safeEmit === "function") {
|
|
490
|
+
auditImpl.safeEmit({ action: action, outcome: outcome, metadata: metadata });
|
|
491
|
+
}
|
|
492
|
+
} catch (_e) { /* drop-silent — audit failures don't break scan path */ }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
module.exports = {
|
|
496
|
+
create: create,
|
|
497
|
+
compliancePosture: compliancePosture,
|
|
498
|
+
PROFILES: PROFILES,
|
|
499
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
500
|
+
ALLOWED_PROTOCOLS: ALLOWED_PROTOCOLS,
|
|
501
|
+
MailScanError: MailScanError,
|
|
502
|
+
};
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -117,7 +117,6 @@
|
|
|
117
117
|
*/
|
|
118
118
|
|
|
119
119
|
var net = require("node:net");
|
|
120
|
-
var nodeTls = require("node:tls");
|
|
121
120
|
var lazyRequire = require("./lazy-require");
|
|
122
121
|
var C = require("./constants");
|
|
123
122
|
var bCrypto = require("./crypto");
|
|
@@ -125,6 +124,7 @@ var numericBounds = require("./numeric-bounds");
|
|
|
125
124
|
var validateOpts = require("./validate-opts");
|
|
126
125
|
var guardImapCommand = require("./guard-imap-command");
|
|
127
126
|
var mailServerRateLimit = require("./mail-server-rate-limit");
|
|
127
|
+
var mailServerTls = require("./mail-server-tls");
|
|
128
128
|
var { defineClass } = require("./framework-error");
|
|
129
129
|
|
|
130
130
|
var audit = lazyRequire(function () { return require("./audit"); });
|
|
@@ -291,7 +291,7 @@ function create(opts) {
|
|
|
291
291
|
socket.setTimeout(idleTimeoutMs);
|
|
292
292
|
socket.on("timeout", function () {
|
|
293
293
|
_writeUntagged(socket, "BYE Idle timeout");
|
|
294
|
-
_close(socket);
|
|
294
|
+
_close(socket, state);
|
|
295
295
|
});
|
|
296
296
|
socket.on("error", function (err) {
|
|
297
297
|
_emit("mail.server.imap.socket_error",
|
|
@@ -302,6 +302,22 @@ function create(opts) {
|
|
|
302
302
|
_writeUntagged(socket, "OK [CAPABILITY " + _capabilityLine(state) + "] " + greeting);
|
|
303
303
|
|
|
304
304
|
socket.on("data", function (chunk) {
|
|
305
|
+
// Per-line cap MUST gate the concat — a single large TCP chunk
|
|
306
|
+
// (~64 KiB on most kernels) can push the buffer past the line
|
|
307
|
+
// cap BEFORE the drain loop runs, so the cap-check inside the
|
|
308
|
+
// loop sees a buffer that's already grown past the policy
|
|
309
|
+
// floor. When the chunk would itself overrun the line cap AND
|
|
310
|
+
// no literal is pending (where over-cap bytes are legitimate
|
|
311
|
+
// payload), reject here and tear the connection down.
|
|
312
|
+
var pendingLiteral = state.pendingLiteral;
|
|
313
|
+
var room = pendingLiteral
|
|
314
|
+
? (pendingLiteral.size - pendingLiteral.body.length) + maxLineBytes
|
|
315
|
+
: (maxLineBytes - state.lineBuffer.length);
|
|
316
|
+
if (chunk.length > room) {
|
|
317
|
+
_writeUntagged(socket, "BAD Line too long (cap " + maxLineBytes + ")");
|
|
318
|
+
_close(socket, state);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
305
321
|
state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
|
|
306
322
|
_drainBuffer(state, socket);
|
|
307
323
|
});
|
|
@@ -329,7 +345,7 @@ function create(opts) {
|
|
|
329
345
|
if (crlf === -1) {
|
|
330
346
|
if (state.lineBuffer.length > maxLineBytes) {
|
|
331
347
|
_writeUntagged(socket, "BAD Line too long (cap " + maxLineBytes + ")");
|
|
332
|
-
_close(socket);
|
|
348
|
+
_close(socket, state);
|
|
333
349
|
}
|
|
334
350
|
return;
|
|
335
351
|
}
|
|
@@ -498,7 +514,7 @@ function create(opts) {
|
|
|
498
514
|
function _handleLogout(state, socket, tag) {
|
|
499
515
|
_writeUntagged(socket, "BYE Logging out");
|
|
500
516
|
_writeTagged(socket, tag, "OK LOGOUT completed");
|
|
501
|
-
_close(socket);
|
|
517
|
+
_close(socket, state);
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
function _handleStartTls(state, socket, tag) {
|
|
@@ -507,27 +523,40 @@ function create(opts) {
|
|
|
507
523
|
return;
|
|
508
524
|
}
|
|
509
525
|
_writeTagged(socket, tag, "OK Begin TLS negotiation now");
|
|
510
|
-
// Drain pre-handshake
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
526
|
+
// Drain EVERY pre-handshake state field that could carry attacker-
|
|
527
|
+
// controlled bytes past the upgrade boundary (RFC 9051 §11.1 /
|
|
528
|
+
// CVE-2021-33515 class STARTTLS-injection defense):
|
|
529
|
+
// - lineBuffer: unparsed bytes pipelined before the handshake.
|
|
530
|
+
// - pendingLiteral: half-collected APPEND/AUTHENTICATE literal
|
|
531
|
+
// bytes; if not cleared, the literal completes after upgrade
|
|
532
|
+
// using bytes the peer sent in plaintext.
|
|
533
|
+
// - authPending: the AUTHENTICATE step token; a dangling token
|
|
534
|
+
// would let the post-TLS state machine resume an exchange that
|
|
535
|
+
// started in plaintext, conflating cleartext + TLS-protected
|
|
536
|
+
// phases of the same SASL run.
|
|
537
|
+
// Listener-removal + idle-timeout re-arm live in the shared
|
|
538
|
+
// upgradeSocket helper (b.mail.server.tls.upgradeSocket).
|
|
539
|
+
state.lineBuffer = Buffer.alloc(0);
|
|
540
|
+
state.pendingLiteral = null;
|
|
541
|
+
state.authPending = null;
|
|
542
|
+
mailServerTls.upgradeSocket({
|
|
543
|
+
plainSocket: socket,
|
|
517
544
|
secureContext: opts.tlsContext,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
tlsSocket.setTimeout(idleTimeoutMs);
|
|
522
|
-
tlsSocket.on("data", function (chunk) {
|
|
545
|
+
idleTimeoutMs: idleTimeoutMs,
|
|
546
|
+
onSecure: function (_tlsSocket) { state.tls = true; },
|
|
547
|
+
onData: function (tlsSocket, chunk) {
|
|
523
548
|
state.lineBuffer = Buffer.concat([state.lineBuffer, chunk]);
|
|
524
549
|
_drainBuffer(state, tlsSocket);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
550
|
+
},
|
|
551
|
+
onError: function (err) {
|
|
552
|
+
_emit("mail.server.imap.tls_handshake_failed",
|
|
553
|
+
{ connectionId: state.id, error: (err && err.message) || String(err) }, "failure");
|
|
554
|
+
_close(socket, state);
|
|
555
|
+
},
|
|
556
|
+
onTimeout: function (tlsSocket) {
|
|
557
|
+
_writeUntagged(tlsSocket, "BYE Idle timeout");
|
|
558
|
+
_close(tlsSocket, state);
|
|
559
|
+
},
|
|
531
560
|
});
|
|
532
561
|
}
|
|
533
562
|
|
|
@@ -550,7 +579,7 @@ function create(opts) {
|
|
|
550
579
|
{ connectionId: state.id, remoteAddress: state.remoteAddress, reason: authAdmit.reason },
|
|
551
580
|
"denied");
|
|
552
581
|
_writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
|
|
553
|
-
_close(socket);
|
|
582
|
+
_close(socket, state);
|
|
554
583
|
return;
|
|
555
584
|
}
|
|
556
585
|
var mechName = args.split(" ")[0].toUpperCase();
|
|
@@ -637,7 +666,7 @@ function create(opts) {
|
|
|
637
666
|
var authAdmit = rateLimit.checkAuthAdmit(state.remoteAddress);
|
|
638
667
|
if (!authAdmit.ok) {
|
|
639
668
|
_writeTagged(socket, tag, "NO [ALERT] Too many AUTH failures from your IP");
|
|
640
|
-
_close(socket);
|
|
669
|
+
_close(socket, state);
|
|
641
670
|
return;
|
|
642
671
|
}
|
|
643
672
|
// LOGIN args: `user pass` (quoted or atom).
|
|
@@ -987,7 +1016,7 @@ function create(opts) {
|
|
|
987
1016
|
if (state.idle) {
|
|
988
1017
|
_writeUntagged(socket, "BYE IDLE timed out — re-issue");
|
|
989
1018
|
state.idle = null;
|
|
990
|
-
_close(socket);
|
|
1019
|
+
_close(socket, state);
|
|
991
1020
|
}
|
|
992
1021
|
}, IDLE_BANDWIDTH_TIMEOUT_MS);
|
|
993
1022
|
state.idle = { tag: tag, timer: timer };
|
|
@@ -1005,7 +1034,16 @@ function create(opts) {
|
|
|
1005
1034
|
try { socket.write("+ " + msg + "\r\n"); }
|
|
1006
1035
|
catch (_e) { /* socket may be down */ }
|
|
1007
1036
|
}
|
|
1008
|
-
function _close(socket) {
|
|
1037
|
+
function _close(socket, state) {
|
|
1038
|
+
// The drain loop's `if (state.stage === "closed") return;` guard
|
|
1039
|
+
// (around the bottom of _drainBuffer) was dead before this —
|
|
1040
|
+
// _close never wrote the sentinel, so the drain loop kept
|
|
1041
|
+
// processing buffered bytes after the socket was destroyed.
|
|
1042
|
+
// Setting stage="closed" here makes the guard reachable so a
|
|
1043
|
+
// close mid-loop short-circuits the next command dispatch
|
|
1044
|
+
// (defense-in-depth against an exception thrown by a handler
|
|
1045
|
+
// that doesn't tear down the loop).
|
|
1046
|
+
if (state && typeof state === "object") state.stage = "closed";
|
|
1009
1047
|
try { socket.end(); } catch (_e) { /* idempotent */ }
|
|
1010
1048
|
try { socket.destroy(); } catch (_e2) { /* idempotent */ }
|
|
1011
1049
|
connections.delete(socket);
|