@blamejs/core 0.9.49 → 0.10.2
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 +952 -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 +78 -5
- package/lib/circuit-breaker.js +10 -2
- package/lib/cli.js +13 -0
- 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-graphql.js +37 -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-regex.js +138 -1
- package/lib/guard-smtp-command.js +58 -3
- package/lib/guard-xml.js +39 -1
- 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/otel-export.js +13 -4
- 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/safe-icap.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// codebase-patterns:allow-file raw-byte-literal — RFC 3507 ICAP status-code
|
|
3
|
+
// table (200 / 204 / 400 / 403 / 404 / 405 / 408 / 500 / 504). These are
|
|
4
|
+
// HTTP-style protocol constants, not memory caps.
|
|
5
|
+
/**
|
|
6
|
+
* @module b.safeIcap
|
|
7
|
+
* @nav Parsers
|
|
8
|
+
* @title Safe ICAP
|
|
9
|
+
* @order 218
|
|
10
|
+
*
|
|
11
|
+
* @intro
|
|
12
|
+
* Bounded RFC 3507 Internet Content Adaptation Protocol (ICAP)
|
|
13
|
+
* response parser. ICAP wraps HTTP-shaped request/response objects
|
|
14
|
+
* (REQMOD / RESPMOD / OPTIONS) inside a protocol that shares HTTP's
|
|
15
|
+
* header syntax but adds the `Encapsulated` header to describe a
|
|
16
|
+
* compound body of `req-hdr`, `req-body`, `res-hdr`, `res-body`,
|
|
17
|
+
* `opt-body`, or `null-body` sections at byte offsets.
|
|
18
|
+
*
|
|
19
|
+
* Substrate for `b.mail.scan` (v0.9.x) — every consumer that talks
|
|
20
|
+
* to ClamAV-via-c-icap, Sophos / Trend Micro / Symantec ICAP daemons,
|
|
21
|
+
* or any RFC 3507 server hands raw response bytes through this
|
|
22
|
+
* parser before trusting any field.
|
|
23
|
+
*
|
|
24
|
+
* ## Wire-protocol caps (every dimension an attacker can grow)
|
|
25
|
+
*
|
|
26
|
+
* - **Response header bytes** (default 8 KiB / 32 KiB / 256 KiB).
|
|
27
|
+
* - **Body bytes total** (default 1 MiB / 16 MiB / 256 MiB).
|
|
28
|
+
* - **Header count** (default 64 / 128 / 256).
|
|
29
|
+
* - **Per-header-value bytes** (default 4 KiB / 16 KiB / 64 KiB).
|
|
30
|
+
*
|
|
31
|
+
* ## Refusals
|
|
32
|
+
*
|
|
33
|
+
* - **Bare-CR / bare-LF / NUL inside headers** — RFC 3507 §4.3.1
|
|
34
|
+
* inherits RFC 7230's CRLF-only rule. Bare-LF terminators are the
|
|
35
|
+
* canonical ICAP-response-injection vector (a hostile upstream
|
|
36
|
+
* smuggles a second response by terminating with `\n` instead of
|
|
37
|
+
* `\r\n`; intermediaries that accept bare-LF then desync against
|
|
38
|
+
* this parser).
|
|
39
|
+
* - **Status-code allowlist** — only `100` / `200` / `204` / `400`
|
|
40
|
+
* / `403` / 5xx are honored. RFC 3507 §4.3.3 enumerates these as
|
|
41
|
+
* the legal ICAP response codes; an unexpected `1xx` continuation
|
|
42
|
+
* or `3xx` redirect is refused because it's a classic header-
|
|
43
|
+
* injection class (attacker smuggles `ICAP/1.0 100 X-Inject:`
|
|
44
|
+
* through a permissive proxy).
|
|
45
|
+
* - **`Encapsulated` parse-failure** — header value must be a
|
|
46
|
+
* comma-separated list of `<part>=<offset>` tokens where `<part>`
|
|
47
|
+
* is one of the six legal section names and `<offset>` is a
|
|
48
|
+
* non-negative integer within the body region.
|
|
49
|
+
* - **Body cap** — `res-body` / `opt-body` body section length
|
|
50
|
+
* capped at profile's `maxBodyBytes`. Defends the parser-bomb
|
|
51
|
+
* class (RFC 3507 §3 imposes no body cap on the wire, so a
|
|
52
|
+
* hostile ICAP daemon can ship arbitrary bytes here).
|
|
53
|
+
*
|
|
54
|
+
* ## CVE / threat model
|
|
55
|
+
*
|
|
56
|
+
* No CVE pool exists specifically for "ICAP-response-injection"
|
|
57
|
+
* because the protocol is operationally deployed inside trusted
|
|
58
|
+
* networks — that very assumption is the threat model. Operators
|
|
59
|
+
* tunnelling untrusted client byte streams through ICAP-mediated AV
|
|
60
|
+
* scanning need to refuse hostile ICAP responses just as
|
|
61
|
+
* aggressively as hostile HTTP responses. The same byte-level
|
|
62
|
+
* discipline that defends HTTP request-smuggling (CVE-2019-18801 /
|
|
63
|
+
* -18802 / -18803, CVE-2023-44487 HTTP/2 Rapid Reset) applies here
|
|
64
|
+
* — strict CRLF, strict status-code allowlist, bounded header /
|
|
65
|
+
* body / count dimensions, no continuation-line acceptance.
|
|
66
|
+
*
|
|
67
|
+
* Parser is purely functional — no I/O, no async — operator owns
|
|
68
|
+
* the socket lifecycle (the `b.mail.scan` primitive composes the
|
|
69
|
+
* parser with its own ICAP socket).
|
|
70
|
+
*
|
|
71
|
+
* @card
|
|
72
|
+
* Bounded RFC 3507 ICAP response parser. Refuses bare-CR / bare-LF /
|
|
73
|
+
* NUL in headers; status-code allowlist; per-header / per-body
|
|
74
|
+
* caps; structured Encapsulated parsing. Substrate for b.mail.scan.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
var C = require("./constants");
|
|
78
|
+
var { defineClass } = require("./framework-error");
|
|
79
|
+
|
|
80
|
+
var SafeIcapError = defineClass("SafeIcapError", { alwaysPermanent: true });
|
|
81
|
+
|
|
82
|
+
// allow:raw-byte-literal — RFC 3507 §4.3.3 enumerated ICAP response status codes.
|
|
83
|
+
var ALLOWED_STATUS = Object.freeze({
|
|
84
|
+
100: "Continue",
|
|
85
|
+
200: "OK",
|
|
86
|
+
204: "No Content",
|
|
87
|
+
400: "Bad Request",
|
|
88
|
+
403: "Forbidden",
|
|
89
|
+
404: "ICAP Service Not Found",
|
|
90
|
+
405: "Method Not Allowed",
|
|
91
|
+
408: "Request Timeout",
|
|
92
|
+
500: "Server Error",
|
|
93
|
+
501: "Method Not Implemented",
|
|
94
|
+
502: "Bad Gateway",
|
|
95
|
+
503: "Service Overloaded",
|
|
96
|
+
504: "Gateway Timeout",
|
|
97
|
+
505: "ICAP Version Not Supported",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// allow:raw-byte-literal — RFC 3507 §4.4 Encapsulated section names.
|
|
101
|
+
var ENCAPSULATED_PARTS = Object.freeze({
|
|
102
|
+
"req-hdr": true,
|
|
103
|
+
"req-body": true,
|
|
104
|
+
"res-hdr": true,
|
|
105
|
+
"res-body": true,
|
|
106
|
+
"opt-body": true,
|
|
107
|
+
"null-body": true,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
var DEFAULT_PROFILE = "strict";
|
|
111
|
+
|
|
112
|
+
var PROFILES = Object.freeze({
|
|
113
|
+
strict: {
|
|
114
|
+
maxResponseHeaderBytes: C.BYTES.kib(8),
|
|
115
|
+
maxBodyBytes: C.BYTES.mib(1),
|
|
116
|
+
maxHeaderCount: 64, // allow:raw-byte-literal — count, not bytes
|
|
117
|
+
maxHeaderValueBytes: C.BYTES.kib(4),
|
|
118
|
+
},
|
|
119
|
+
balanced: {
|
|
120
|
+
maxResponseHeaderBytes: C.BYTES.kib(32),
|
|
121
|
+
maxBodyBytes: C.BYTES.mib(16),
|
|
122
|
+
maxHeaderCount: 128, // allow:raw-byte-literal — count, not bytes
|
|
123
|
+
maxHeaderValueBytes: C.BYTES.kib(16),
|
|
124
|
+
},
|
|
125
|
+
permissive: {
|
|
126
|
+
maxResponseHeaderBytes: C.BYTES.kib(256),
|
|
127
|
+
maxBodyBytes: C.BYTES.mib(256),
|
|
128
|
+
maxHeaderCount: 256, // allow:raw-byte-literal — count, not bytes
|
|
129
|
+
maxHeaderValueBytes: C.BYTES.kib(64),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
var COMPLIANCE_POSTURES = Object.freeze({
|
|
134
|
+
hipaa: "strict",
|
|
135
|
+
"pci-dss": "strict",
|
|
136
|
+
gdpr: "strict",
|
|
137
|
+
soc2: "strict",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @primitive b.safeIcap.parse
|
|
142
|
+
* @signature b.safeIcap.parse(buf, opts?)
|
|
143
|
+
* @since 0.9.81
|
|
144
|
+
* @status stable
|
|
145
|
+
* @related b.safeIcap.compliancePosture
|
|
146
|
+
*
|
|
147
|
+
* Parse an ICAP/1.0 response (RFC 3507 §4.3) from a byte buffer.
|
|
148
|
+
* Returns `{ statusCode, statusText, headers, encapsulated,
|
|
149
|
+
* headerByteLength, body, threatFound, threatName? }` where:
|
|
150
|
+
*
|
|
151
|
+
* - `statusCode` / `statusText` come from the status-line (e.g.
|
|
152
|
+
* `ICAP/1.0 200 OK` → 200 / "OK"). Status MUST be one of the
|
|
153
|
+
* RFC 3507 §4.3.3 codes (100 / 200 / 204 / 400 / 403 / 404 /
|
|
154
|
+
* 405 / 408 / 500-505).
|
|
155
|
+
* - `headers` is a lower-cased-key object. Duplicate header names
|
|
156
|
+
* collapse to an Array of values.
|
|
157
|
+
* - `encapsulated` is `{ "req-hdr": offset, "res-body": offset, ... }`
|
|
158
|
+
* parsed from the `Encapsulated` header. `null` if the header is
|
|
159
|
+
* absent (legal for status 100 / 204 / 4xx / 5xx).
|
|
160
|
+
* - `headerByteLength` — the byte offset where the body region
|
|
161
|
+
* starts (after the terminating CRLF CRLF).
|
|
162
|
+
* - `body` — Buffer slice of the body region, length-capped by
|
|
163
|
+
* `maxBodyBytes`. Empty Buffer when the body region is absent
|
|
164
|
+
* or zero-length.
|
|
165
|
+
* - `threatFound` — boolean. `true` when the response signals an
|
|
166
|
+
* infected verdict via the well-known `X-Infection-Found` header
|
|
167
|
+
* (Symantec / ClamAV / Sophos all emit this on a hit) OR the
|
|
168
|
+
* status code is `403` (ICAP convention: 403 = blocked).
|
|
169
|
+
* - `threatName` — string when `X-Infection-Found` parses out a
|
|
170
|
+
* `Threat=<name>` token; absent otherwise.
|
|
171
|
+
*
|
|
172
|
+
* Throws `SafeIcapError` with codes:
|
|
173
|
+
* `safe-icap/bad-input` / `oversize-header` / `oversize-body` /
|
|
174
|
+
* `oversize-header-count` / `oversize-header-value` /
|
|
175
|
+
* `bare-cr-or-lf` / `nul-in-header` / `bad-status-line` /
|
|
176
|
+
* `unexpected-status` / `bad-encapsulated` / `bad-profile`.
|
|
177
|
+
*
|
|
178
|
+
* @opts
|
|
179
|
+
* profile: "strict" | "balanced" | "permissive",
|
|
180
|
+
* posture: "hipaa" | "pci-dss" | "gdpr" | "soc2",
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* var parsed = b.safeIcap.parse(wireBytes);
|
|
184
|
+
* if (parsed.threatFound) refuseMessage(parsed.threatName);
|
|
185
|
+
*/
|
|
186
|
+
function parse(buf, opts) {
|
|
187
|
+
opts = opts || {};
|
|
188
|
+
if (!Buffer.isBuffer(buf)) {
|
|
189
|
+
throw new SafeIcapError("safe-icap/bad-input",
|
|
190
|
+
"safeIcap.parse: buf must be a Buffer; got " + (typeof buf));
|
|
191
|
+
}
|
|
192
|
+
var caps = _resolveProfile(opts);
|
|
193
|
+
|
|
194
|
+
// Locate the end-of-headers CRLF CRLF marker.
|
|
195
|
+
var headerEnd = _findHeaderEnd(buf, caps.maxResponseHeaderBytes);
|
|
196
|
+
if (headerEnd === -1) {
|
|
197
|
+
throw new SafeIcapError("safe-icap/oversize-header",
|
|
198
|
+
"safeIcap.parse: end-of-headers CRLFCRLF not found within maxResponseHeaderBytes=" +
|
|
199
|
+
caps.maxResponseHeaderBytes + " (RFC 3507 §4.3.1)");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Validate header bytes for bare-CR / bare-LF / NUL before we tokenize.
|
|
203
|
+
_refuseBadHeaderBytes(buf, headerEnd);
|
|
204
|
+
|
|
205
|
+
// Tokenize the status-line + header lines on CRLF.
|
|
206
|
+
var lines = _splitCrlf(buf, 0, headerEnd);
|
|
207
|
+
if (lines.length === 0) {
|
|
208
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
209
|
+
"safeIcap.parse: empty response (no status line)");
|
|
210
|
+
}
|
|
211
|
+
var statusLine = lines[0];
|
|
212
|
+
var statusParse = _parseStatusLine(statusLine);
|
|
213
|
+
|
|
214
|
+
var headers = {};
|
|
215
|
+
var headerCount = 0;
|
|
216
|
+
for (var i = 1; i < lines.length; i += 1) {
|
|
217
|
+
var line = lines[i];
|
|
218
|
+
if (line.length === 0) continue; // RFC 7230 §3.2 — blank header lines refused below as bad-header anyway
|
|
219
|
+
headerCount += 1;
|
|
220
|
+
if (headerCount > caps.maxHeaderCount) {
|
|
221
|
+
throw new SafeIcapError("safe-icap/oversize-header-count",
|
|
222
|
+
"safeIcap.parse: header count exceeds maxHeaderCount=" + caps.maxHeaderCount);
|
|
223
|
+
}
|
|
224
|
+
var kv = _parseHeaderLine(line, caps.maxHeaderValueBytes);
|
|
225
|
+
_addHeader(headers, kv.name, kv.value);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
var encapsulated = null;
|
|
229
|
+
if (headers["encapsulated"] !== undefined) {
|
|
230
|
+
encapsulated = _parseEncapsulated(_firstHeader(headers["encapsulated"]));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Body region: from headerEnd (which points AT the first byte of the
|
|
234
|
+
// body, after the CRLFCRLF) through end of buffer, capped.
|
|
235
|
+
var bodyStart = headerEnd;
|
|
236
|
+
var bodyLen = buf.length - bodyStart;
|
|
237
|
+
if (bodyLen < 0) bodyLen = 0;
|
|
238
|
+
if (bodyLen > caps.maxBodyBytes) {
|
|
239
|
+
throw new SafeIcapError("safe-icap/oversize-body",
|
|
240
|
+
"safeIcap.parse: body bytes=" + bodyLen + " exceeds maxBodyBytes=" + caps.maxBodyBytes +
|
|
241
|
+
" (RFC 3507 §3 parser-bomb defense)");
|
|
242
|
+
}
|
|
243
|
+
var body = bodyLen > 0 ? buf.slice(bodyStart, bodyStart + bodyLen) : Buffer.alloc(0);
|
|
244
|
+
|
|
245
|
+
var threat = _detectThreat(statusParse.statusCode, headers);
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
statusCode: statusParse.statusCode,
|
|
249
|
+
statusText: statusParse.statusText,
|
|
250
|
+
headers: headers,
|
|
251
|
+
encapsulated: encapsulated,
|
|
252
|
+
headerByteLength: headerEnd,
|
|
253
|
+
body: body,
|
|
254
|
+
threatFound: threat.found,
|
|
255
|
+
threatName: threat.name,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @primitive b.safeIcap.compliancePosture
|
|
261
|
+
* @signature b.safeIcap.compliancePosture(posture)
|
|
262
|
+
* @since 0.9.81
|
|
263
|
+
* @status stable
|
|
264
|
+
*
|
|
265
|
+
* Return the effective profile name for a compliance posture, or
|
|
266
|
+
* `null` for unknown posture names.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* b.safeIcap.compliancePosture("hipaa"); // → "strict"
|
|
270
|
+
*/
|
|
271
|
+
function compliancePosture(posture) {
|
|
272
|
+
return COMPLIANCE_POSTURES[posture] || null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- internals ----
|
|
276
|
+
|
|
277
|
+
function _findHeaderEnd(buf, maxHeaderBytes) {
|
|
278
|
+
var stop = Math.min(buf.length, maxHeaderBytes);
|
|
279
|
+
for (var i = 0; i + 3 < stop; i += 1) { // allow:raw-byte-literal — 4-byte CRLFCRLF terminator
|
|
280
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a &&
|
|
281
|
+
buf[i + 2] === 0x0d && buf[i + 3] === 0x0a) {
|
|
282
|
+
return i + 4; // allow:raw-byte-literal — past the CRLFCRLF
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _refuseBadHeaderBytes(buf, headerEnd) {
|
|
289
|
+
// RFC 3507 §4.3.1 inherits RFC 7230's CRLF-only rule. Bare-CR /
|
|
290
|
+
// bare-LF / NUL anywhere in the header region is refused. CRLF
|
|
291
|
+
// pairs are legal line terminators; CR not followed by LF or LF
|
|
292
|
+
// not preceded by CR are smuggling vectors.
|
|
293
|
+
for (var i = 0; i < headerEnd; i += 1) {
|
|
294
|
+
var byte = buf[i];
|
|
295
|
+
if (byte === 0) { // allow:raw-byte-literal — NUL byte refusal
|
|
296
|
+
throw new SafeIcapError("safe-icap/nul-in-header",
|
|
297
|
+
"safeIcap.parse: NUL byte in header region at offset=" + i);
|
|
298
|
+
}
|
|
299
|
+
if (byte === 0x0d) { // allow:raw-byte-literal — CR
|
|
300
|
+
if (i + 1 >= headerEnd || buf[i + 1] !== 0x0a) { // allow:raw-byte-literal — LF
|
|
301
|
+
throw new SafeIcapError("safe-icap/bare-cr-or-lf",
|
|
302
|
+
"safeIcap.parse: bare-CR (CR without LF) at offset=" + i +
|
|
303
|
+
" (RFC 3507 §4.3.1 ICAP-response-injection defense)");
|
|
304
|
+
}
|
|
305
|
+
} else if (byte === 0x0a) { // allow:raw-byte-literal — LF
|
|
306
|
+
if (i === 0 || buf[i - 1] !== 0x0d) { // allow:raw-byte-literal — CR
|
|
307
|
+
throw new SafeIcapError("safe-icap/bare-cr-or-lf",
|
|
308
|
+
"safeIcap.parse: bare-LF (LF without CR) at offset=" + i +
|
|
309
|
+
" (RFC 3507 §4.3.1 ICAP-response-injection defense)");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function _splitCrlf(buf, start, end) {
|
|
316
|
+
// Caller has already refused bare-CR / bare-LF, so every \n in
|
|
317
|
+
// [start, end) is preceded by \r. Split on \r\n.
|
|
318
|
+
var lines = [];
|
|
319
|
+
var lineStart = start;
|
|
320
|
+
for (var i = start; i + 1 < end; i += 1) {
|
|
321
|
+
if (buf[i] === 0x0d && buf[i + 1] === 0x0a) { // allow:raw-byte-literal — CRLF terminator
|
|
322
|
+
lines.push(buf.toString("ascii", lineStart, i));
|
|
323
|
+
i += 1;
|
|
324
|
+
lineStart = i + 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (lineStart < end) lines.push(buf.toString("ascii", lineStart, end));
|
|
328
|
+
return lines;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function _parseStatusLine(line) {
|
|
332
|
+
// RFC 3507 §4.3.2 — ICAP-Version SP Status-Code SP Reason-Phrase.
|
|
333
|
+
// ICAP-Version is "ICAP/1.0".
|
|
334
|
+
if (line.indexOf("ICAP/") !== 0) {
|
|
335
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
336
|
+
"safeIcap.parse: status line must start with 'ICAP/' (got '" +
|
|
337
|
+
line.slice(0, 16) + "')"); // allow:raw-byte-literal — bound diagnostic slice
|
|
338
|
+
}
|
|
339
|
+
var sp1 = line.indexOf(" ");
|
|
340
|
+
if (sp1 === -1) {
|
|
341
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
342
|
+
"safeIcap.parse: status line missing space after version");
|
|
343
|
+
}
|
|
344
|
+
var sp2 = line.indexOf(" ", sp1 + 1);
|
|
345
|
+
if (sp2 === -1) sp2 = line.length;
|
|
346
|
+
var codeStr = line.slice(sp1 + 1, sp2);
|
|
347
|
+
if (!/^\d{3}$/.test(codeStr)) { // allow:regex-no-length-cap — fixed 3-digit anchor
|
|
348
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
349
|
+
"safeIcap.parse: status code not 3 ASCII digits (got '" + codeStr + "')");
|
|
350
|
+
}
|
|
351
|
+
var statusCode = parseInt(codeStr, 10); // allow:raw-byte-literal — base-10 radix
|
|
352
|
+
if (!Object.prototype.hasOwnProperty.call(ALLOWED_STATUS, statusCode)) {
|
|
353
|
+
throw new SafeIcapError("safe-icap/unexpected-status",
|
|
354
|
+
"safeIcap.parse: status code " + statusCode +
|
|
355
|
+
" is not in the RFC 3507 §4.3.3 allowlist (smuggling defense)");
|
|
356
|
+
}
|
|
357
|
+
var statusText = sp2 < line.length ? line.slice(sp2 + 1) : ALLOWED_STATUS[statusCode];
|
|
358
|
+
return { statusCode: statusCode, statusText: statusText };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function _parseHeaderLine(line, maxValueBytes) {
|
|
362
|
+
// RFC 7230 §3.2 — field-name ":" OWS field-value OWS. ICAP inherits.
|
|
363
|
+
var colon = line.indexOf(":");
|
|
364
|
+
if (colon === -1) {
|
|
365
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
366
|
+
"safeIcap.parse: header line missing ':' (got '" + line.slice(0, 32) + "')"); // allow:raw-byte-literal — bound diagnostic slice
|
|
367
|
+
}
|
|
368
|
+
var name = line.slice(0, colon).toLowerCase();
|
|
369
|
+
if (name.length === 0) {
|
|
370
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
371
|
+
"safeIcap.parse: header has empty name");
|
|
372
|
+
}
|
|
373
|
+
// RFC 7230 §3.2.6 — field-name token chars (RFC 5234 ALPHA / DIGIT
|
|
374
|
+
// plus a fixed punctuation set). Refuse anything else.
|
|
375
|
+
for (var i = 0; i < name.length; i += 1) {
|
|
376
|
+
var cc = name.charCodeAt(i);
|
|
377
|
+
var ok = (cc >= 0x30 && cc <= 0x39) || // allow:raw-byte-literal — DIGIT 0-9
|
|
378
|
+
(cc >= 0x41 && cc <= 0x5a) || // allow:raw-byte-literal — UPPER (lowercased above; defensive)
|
|
379
|
+
(cc >= 0x61 && cc <= 0x7a) || // allow:raw-byte-literal — lower a-z
|
|
380
|
+
cc === 0x21 || cc === 0x23 || cc === 0x24 || cc === 0x25 || // allow:raw-byte-literal — ! # $ %
|
|
381
|
+
cc === 0x26 || cc === 0x27 || cc === 0x2a || cc === 0x2b || // allow:raw-byte-literal — & ' * +
|
|
382
|
+
cc === 0x2d || cc === 0x2e || cc === 0x5e || cc === 0x5f || // allow:raw-byte-literal — - . ^ _
|
|
383
|
+
cc === 0x60 || cc === 0x7c || cc === 0x7e; // allow:raw-byte-literal — ` | ~
|
|
384
|
+
if (!ok) {
|
|
385
|
+
throw new SafeIcapError("safe-icap/bad-status-line",
|
|
386
|
+
"safeIcap.parse: invalid char in header name '" + name + "' (RFC 7230 §3.2.6 tchar)");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Manual trim — avoids the polynomial-regex shape `/^\s+|\s+$/g`
|
|
390
|
+
// CodeQL flags, where the alternation can backtrack against itself
|
|
391
|
+
// on `\t` repetitions even though the upstream line cap bounds the
|
|
392
|
+
// input length.
|
|
393
|
+
var raw = line.slice(colon + 1);
|
|
394
|
+
var start = 0;
|
|
395
|
+
var end = raw.length;
|
|
396
|
+
while (start < end && (raw.charCodeAt(start) === 0x20 || raw.charCodeAt(start) === 0x09)) start += 1;
|
|
397
|
+
while (end > start && (raw.charCodeAt(end - 1) === 0x20 || raw.charCodeAt(end - 1) === 0x09)) end -= 1;
|
|
398
|
+
var value = raw.slice(start, end);
|
|
399
|
+
if (Buffer.byteLength(value, "ascii") > maxValueBytes) {
|
|
400
|
+
throw new SafeIcapError("safe-icap/oversize-header-value",
|
|
401
|
+
"safeIcap.parse: header '" + name + "' value " + value.length +
|
|
402
|
+
" bytes exceeds maxHeaderValueBytes=" + maxValueBytes);
|
|
403
|
+
}
|
|
404
|
+
return { name: name, value: value };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function _addHeader(headers, name, value) {
|
|
408
|
+
if (headers[name] === undefined) {
|
|
409
|
+
headers[name] = value;
|
|
410
|
+
} else if (Array.isArray(headers[name])) {
|
|
411
|
+
headers[name].push(value);
|
|
412
|
+
} else {
|
|
413
|
+
headers[name] = [headers[name], value];
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function _firstHeader(headerValue) {
|
|
418
|
+
return Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _parseEncapsulated(value) {
|
|
422
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
423
|
+
throw new SafeIcapError("safe-icap/bad-encapsulated",
|
|
424
|
+
"safeIcap.parse: Encapsulated header must be a non-empty string");
|
|
425
|
+
}
|
|
426
|
+
// RFC 3507 §4.4 — comma-separated `<part>=<offset>` tokens.
|
|
427
|
+
var parts = value.split(",");
|
|
428
|
+
var out = {};
|
|
429
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
430
|
+
var token = parts[i].replace(/^\s+|\s+$/g, ""); // allow:regex-no-length-cap — bounded by per-header cap
|
|
431
|
+
if (token.length === 0) continue;
|
|
432
|
+
var eq = token.indexOf("=");
|
|
433
|
+
if (eq === -1) {
|
|
434
|
+
throw new SafeIcapError("safe-icap/bad-encapsulated",
|
|
435
|
+
"safeIcap.parse: Encapsulated token '" + token + "' missing '='");
|
|
436
|
+
}
|
|
437
|
+
var part = token.slice(0, eq);
|
|
438
|
+
var offStr = token.slice(eq + 1);
|
|
439
|
+
if (!ENCAPSULATED_PARTS[part]) {
|
|
440
|
+
throw new SafeIcapError("safe-icap/bad-encapsulated",
|
|
441
|
+
"safeIcap.parse: Encapsulated part '" + part + "' is not one of " +
|
|
442
|
+
Object.keys(ENCAPSULATED_PARTS).join(", "));
|
|
443
|
+
}
|
|
444
|
+
if (!/^\d+$/.test(offStr)) { // allow:regex-no-length-cap — bounded by per-header cap
|
|
445
|
+
throw new SafeIcapError("safe-icap/bad-encapsulated",
|
|
446
|
+
"safeIcap.parse: Encapsulated offset for '" + part + "' must be a non-negative integer (got '" +
|
|
447
|
+
offStr + "')");
|
|
448
|
+
}
|
|
449
|
+
var off = parseInt(offStr, 10); // allow:raw-byte-literal — base-10 radix
|
|
450
|
+
if (!isFinite(off) || off < 0) {
|
|
451
|
+
throw new SafeIcapError("safe-icap/bad-encapsulated",
|
|
452
|
+
"safeIcap.parse: Encapsulated offset for '" + part + "' must be a non-negative integer (got '" +
|
|
453
|
+
offStr + "')");
|
|
454
|
+
}
|
|
455
|
+
out[part] = off;
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function _detectThreat(statusCode, headers) {
|
|
461
|
+
// RFC 3507 §4.3.3 — 403 is the conventional "ICAP service refused
|
|
462
|
+
// the request" code; AV scanners emit 403 with X-Block-Reason on a
|
|
463
|
+
// hit, or 200 + the modified-message with X-Infection-Found set.
|
|
464
|
+
var found = false;
|
|
465
|
+
var name;
|
|
466
|
+
if (statusCode === 403) found = true;
|
|
467
|
+
var inf = _firstHeader(headers["x-infection-found"]);
|
|
468
|
+
if (typeof inf === "string" && inf.length > 0) {
|
|
469
|
+
found = true;
|
|
470
|
+
var m = inf.match(/Threat=([^;,\s]+)/i); // allow:regex-no-length-cap — bounded by per-header cap
|
|
471
|
+
if (m) name = m[1];
|
|
472
|
+
}
|
|
473
|
+
var virus = _firstHeader(headers["x-virus-id"]) || _firstHeader(headers["x-violations-found"]);
|
|
474
|
+
if (typeof virus === "string" && virus.length > 0 && !name) {
|
|
475
|
+
found = true;
|
|
476
|
+
name = virus;
|
|
477
|
+
}
|
|
478
|
+
return { found: found, name: name };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function _resolveProfile(opts) {
|
|
482
|
+
if (opts.posture && COMPLIANCE_POSTURES[opts.posture]) {
|
|
483
|
+
return PROFILES[COMPLIANCE_POSTURES[opts.posture]];
|
|
484
|
+
}
|
|
485
|
+
var p = opts.profile || DEFAULT_PROFILE;
|
|
486
|
+
if (!PROFILES[p]) {
|
|
487
|
+
throw new SafeIcapError("safe-icap/bad-profile",
|
|
488
|
+
"safeIcap: unknown profile '" + p + "' (valid: strict / balanced / permissive)");
|
|
489
|
+
}
|
|
490
|
+
return PROFILES[p];
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
module.exports = {
|
|
494
|
+
parse: parse,
|
|
495
|
+
compliancePosture: compliancePosture,
|
|
496
|
+
PROFILES: PROFILES,
|
|
497
|
+
COMPLIANCE_POSTURES: COMPLIANCE_POSTURES,
|
|
498
|
+
ALLOWED_STATUS: ALLOWED_STATUS,
|
|
499
|
+
SafeIcapError: SafeIcapError,
|
|
500
|
+
NAME: "icap",
|
|
501
|
+
KIND: "icap-response",
|
|
502
|
+
};
|
package/lib/safe-mime.js
CHANGED
|
@@ -593,6 +593,21 @@ function _decodeRfc2047Words(value) {
|
|
|
593
593
|
raw = Buffer.from(text.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g,
|
|
594
594
|
function (__, hex) { return String.fromCharCode(parseInt(hex, 16)); }), "binary"); // allow:raw-byte-literal — parseInt radix 16, not bytes
|
|
595
595
|
}
|
|
596
|
+
// RFC 2047 §5 / CVE-2020-7244 header-injection defense — after
|
|
597
|
+
// base64 / Q-encoded decode, check the DECODED bytes for header
|
|
598
|
+
// separators (CR, LF, NUL). A sender that base64-encodes
|
|
599
|
+
// `\r\nBcc: attacker@x.com` would otherwise reach the consumer's
|
|
600
|
+
// header parser as a fresh header line; refuse the whole encoded
|
|
601
|
+
// word by returning a placeholder so the caller doesn't see the
|
|
602
|
+
// injection bytes.
|
|
603
|
+
for (var bi = 0; bi < raw.length; bi += 1) {
|
|
604
|
+
var b = raw[bi];
|
|
605
|
+
if (b === 0x0d /* CR */ || b === 0x0a /* LF */ || b === 0x00 /* NUL */) {
|
|
606
|
+
throw new SafeMimeError("safe-mime/rfc2047-header-injection",
|
|
607
|
+
"RFC 2047 encoded-word decoded to bytes containing CR/LF/NUL " +
|
|
608
|
+
"(byte index " + bi + "); refusing per RFC 2047 §5 / CVE-2020-7244 class");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
596
611
|
return _decodeBufferAs(raw, charset);
|
|
597
612
|
}
|
|
598
613
|
);
|