@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.
Files changed (82) hide show
  1. package/CHANGELOG.md +952 -908
  2. package/index.js +25 -0
  3. package/lib/_test/crypto-fixtures.js +67 -0
  4. package/lib/agent-event-bus.js +52 -6
  5. package/lib/agent-idempotency.js +169 -16
  6. package/lib/agent-orchestrator.js +263 -9
  7. package/lib/agent-posture-chain.js +163 -5
  8. package/lib/agent-saga.js +146 -16
  9. package/lib/agent-snapshot.js +349 -19
  10. package/lib/agent-stream.js +34 -2
  11. package/lib/agent-tenant.js +179 -23
  12. package/lib/agent-trace.js +84 -21
  13. package/lib/auth/aal.js +8 -1
  14. package/lib/auth/ciba.js +6 -1
  15. package/lib/auth/dpop.js +7 -2
  16. package/lib/auth/fal.js +17 -8
  17. package/lib/auth/jwt-external.js +128 -4
  18. package/lib/auth/oauth.js +232 -10
  19. package/lib/auth/oid4vci.js +67 -7
  20. package/lib/auth/openid-federation.js +71 -25
  21. package/lib/auth/passkey.js +140 -6
  22. package/lib/auth/sd-jwt-vc.js +78 -5
  23. package/lib/circuit-breaker.js +10 -2
  24. package/lib/cli.js +13 -0
  25. package/lib/compliance.js +176 -8
  26. package/lib/crypto-field.js +114 -14
  27. package/lib/crypto.js +216 -20
  28. package/lib/db.js +1 -0
  29. package/lib/guard-graphql.js +37 -0
  30. package/lib/guard-jmap.js +321 -0
  31. package/lib/guard-managesieve-command.js +566 -0
  32. package/lib/guard-pop3-command.js +317 -0
  33. package/lib/guard-regex.js +138 -1
  34. package/lib/guard-smtp-command.js +58 -3
  35. package/lib/guard-xml.js +39 -1
  36. package/lib/mail-agent.js +20 -7
  37. package/lib/mail-arc-sign.js +12 -8
  38. package/lib/mail-auth.js +323 -34
  39. package/lib/mail-crypto-pgp.js +934 -0
  40. package/lib/mail-crypto-smime.js +340 -0
  41. package/lib/mail-crypto.js +108 -0
  42. package/lib/mail-dav.js +1224 -0
  43. package/lib/mail-deploy.js +492 -0
  44. package/lib/mail-dkim.js +431 -26
  45. package/lib/mail-journal.js +435 -0
  46. package/lib/mail-scan.js +502 -0
  47. package/lib/mail-server-imap.js +64 -26
  48. package/lib/mail-server-jmap.js +488 -0
  49. package/lib/mail-server-managesieve.js +853 -0
  50. package/lib/mail-server-mx.js +40 -30
  51. package/lib/mail-server-pop3.js +836 -0
  52. package/lib/mail-server-rate-limit.js +13 -0
  53. package/lib/mail-server-submission.js +70 -24
  54. package/lib/mail-server-tls.js +445 -0
  55. package/lib/mail-sieve.js +557 -0
  56. package/lib/mail-spam-score.js +284 -0
  57. package/lib/mail.js +99 -0
  58. package/lib/metrics.js +80 -3
  59. package/lib/middleware/dpop.js +58 -3
  60. package/lib/middleware/idempotency-key.js +255 -42
  61. package/lib/middleware/protected-resource-metadata.js +114 -2
  62. package/lib/network-dns-resolver.js +33 -0
  63. package/lib/network-tls.js +46 -0
  64. package/lib/otel-export.js +13 -4
  65. package/lib/outbox.js +62 -12
  66. package/lib/pqc-agent.js +13 -5
  67. package/lib/retry.js +23 -9
  68. package/lib/router.js +23 -1
  69. package/lib/safe-ical.js +634 -0
  70. package/lib/safe-icap.js +502 -0
  71. package/lib/safe-mime.js +15 -0
  72. package/lib/safe-sieve.js +684 -0
  73. package/lib/safe-smtp.js +57 -0
  74. package/lib/safe-url.js +37 -0
  75. package/lib/safe-vcard.js +473 -0
  76. package/lib/self-update-standalone-verifier.js +32 -3
  77. package/lib/self-update.js +153 -33
  78. package/lib/vendor/MANIFEST.json +161 -156
  79. package/lib/vendor-data.js +127 -9
  80. package/lib/vex.js +324 -59
  81. package/package.json +1 -1
  82. package/sbom.cdx.json +6 -6
@@ -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
  );