spamscanner 7.0.4 → 7.0.5
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/dist/cjs/arf.cjs +245 -11
- package/dist/cjs/arf.cjs.map +3 -3
- package/dist/cjs/index.cjs +858 -408
- package/dist/cjs/index.cjs.map +4 -4
- package/dist/esm/arf.js +246 -12
- package/dist/esm/arf.js.map +3 -3
- package/dist/esm/cli.js +993 -444
- package/dist/esm/cli.js.map +4 -4
- package/dist/esm/index.js +835 -383
- package/dist/esm/index.js.map +4 -4
- package/dist/types/arf.d.ts +4 -4
- package/dist/types/attachments.d.ts +3 -1
- package/dist/types/auth.d.ts +32 -2
- package/dist/types/milter.d.ts +7 -1
- package/dist/types/model.d.ts +3 -2
- package/dist/types/parse.d.ts +66 -4
- package/dist/types/reputation.d.ts +2 -1
- package/dist/types/server.d.ts +17 -0
- package/dist/types/spamd.d.ts +2 -0
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -3258,17 +3258,22 @@ function zipEntries(buffer, limit = 2e3) {
|
|
|
3258
3258
|
records.push(i);
|
|
3259
3259
|
}
|
|
3260
3260
|
}
|
|
3261
|
-
const
|
|
3262
|
-
|
|
3261
|
+
const state = {
|
|
3262
|
+
entries: [],
|
|
3263
|
+
limit,
|
|
3264
|
+
read: /* @__PURE__ */ new Set(),
|
|
3265
|
+
names: MAX_NAME_BYTES
|
|
3266
|
+
};
|
|
3263
3267
|
for (const eocd of records) {
|
|
3264
3268
|
const offset = directoryOf(buffer, eocd);
|
|
3265
|
-
if (offset !== -1
|
|
3266
|
-
|
|
3267
|
-
readDirectory(buffer, offset, limit - entries.length, entries);
|
|
3269
|
+
if (offset !== -1) {
|
|
3270
|
+
readDirectory(buffer, offset, state);
|
|
3268
3271
|
}
|
|
3269
3272
|
}
|
|
3270
|
-
return entries;
|
|
3273
|
+
return state.entries;
|
|
3271
3274
|
}
|
|
3275
|
+
var MAX_NAME_LENGTH = 1024;
|
|
3276
|
+
var MAX_NAME_BYTES = 1024 * 1024;
|
|
3272
3277
|
var DIRECTORY = 33639248;
|
|
3273
3278
|
var isDirectory = (buffer, offset) => offset >= 0 && offset + 46 <= buffer.length && buffer.readUInt32LE(offset) === DIRECTORY;
|
|
3274
3279
|
function directoryOf(buffer, eocd) {
|
|
@@ -3295,18 +3300,32 @@ function directoryOf(buffer, eocd) {
|
|
|
3295
3300
|
}
|
|
3296
3301
|
return -1;
|
|
3297
3302
|
}
|
|
3298
|
-
function readDirectory(buffer, offset,
|
|
3299
|
-
|
|
3303
|
+
function readDirectory(buffer, offset, state) {
|
|
3304
|
+
while (state.entries.length < state.limit && isDirectory(buffer, offset) && !state.read.has(offset)) {
|
|
3305
|
+
state.read.add(offset);
|
|
3300
3306
|
const flags = buffer.readUInt16LE(offset + 8);
|
|
3301
3307
|
const size = buffer.readUInt32LE(offset + 24);
|
|
3302
3308
|
const nameLength = buffer.readUInt16LE(offset + 28);
|
|
3303
3309
|
const extraLength = buffer.readUInt16LE(offset + 30);
|
|
3304
3310
|
const commentLength = buffer.readUInt16LE(offset + 32);
|
|
3305
|
-
const
|
|
3306
|
-
|
|
3311
|
+
const encoding = flags & 2048 ? "utf8" : "latin1";
|
|
3312
|
+
const start = offset + 46;
|
|
3313
|
+
const end = Math.min(buffer.length, start + nameLength);
|
|
3314
|
+
const kept = Math.min(end - start, MAX_NAME_LENGTH);
|
|
3315
|
+
state.names -= kept;
|
|
3316
|
+
if (state.names < 0) {
|
|
3317
|
+
return;
|
|
3318
|
+
}
|
|
3319
|
+
const half = MAX_NAME_LENGTH / 2;
|
|
3320
|
+
const name = end - start > MAX_NAME_LENGTH ? `${buffer.toString(encoding, start, start + half)}\u2026${buffer.toString(encoding, end - half, end)}` : buffer.toString(encoding, start, end);
|
|
3321
|
+
state.entries.push({ name, encrypted: (flags & 1) === 1, size });
|
|
3307
3322
|
offset += 46 + nameLength + extraLength + commentLength;
|
|
3308
3323
|
}
|
|
3309
3324
|
}
|
|
3325
|
+
function shorten(name, max = 100) {
|
|
3326
|
+
const characters = [...name];
|
|
3327
|
+
return characters.length <= max ? name : `${characters.slice(0, max - 41).join("")}\u2026${characters.slice(-40).join("")}`;
|
|
3328
|
+
}
|
|
3310
3329
|
function extensionOf(name) {
|
|
3311
3330
|
const base = trimEndOf(String(name).toLowerCase().split(/[/\\]/).pop(), ". ");
|
|
3312
3331
|
const dot = base.lastIndexOf(".");
|
|
@@ -3369,7 +3388,7 @@ function inspectAttachments(attachments = []) {
|
|
|
3369
3388
|
}
|
|
3370
3389
|
const inner = entries.filter((entry) => EXECUTABLE_EXTENSIONS.has(extensionOf(entry.name)) && !(office && OFFICE_EMBEDDED.has(extensionOf(entry.name))));
|
|
3371
3390
|
const details = { extension: extensionOf(inner[0]?.name ?? ""), extensions: [...new Set(inner.map((entry) => extensionOf(entry.name)))] };
|
|
3372
|
-
const listed = inner.slice(0, 3).map((entry) => entry.name.split("/").pop()).join(", ");
|
|
3391
|
+
const listed = inner.slice(0, 3).map((entry) => shorten(entry.name.split("/").pop())).join(", ");
|
|
3373
3392
|
if (inner.some((entry) => !SCRIPT_EXTENSIONS.has(extensionOf(entry.name)))) {
|
|
3374
3393
|
add("executable_in_archive", `Archive "${name}" contains ${inner.length === 1 ? "a program" : `${inner.length} programs`} (${listed})`, details);
|
|
3375
3394
|
} else if (inner.length > 0) {
|
|
@@ -3399,10 +3418,486 @@ function inspectAttachments(attachments = []) {
|
|
|
3399
3418
|
}
|
|
3400
3419
|
|
|
3401
3420
|
// src/auth.js
|
|
3402
|
-
import { Buffer as
|
|
3421
|
+
import { Buffer as Buffer4, File } from "node:buffer";
|
|
3403
3422
|
import dns2 from "node:dns";
|
|
3404
3423
|
import { debuglog } from "node:util";
|
|
3405
3424
|
|
|
3425
|
+
// src/parse.js
|
|
3426
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
3427
|
+
import { createHash } from "node:crypto";
|
|
3428
|
+
import { Readable } from "node:stream";
|
|
3429
|
+
import { MailParser, simpleParser } from "mailparser";
|
|
3430
|
+
var ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "to", "cc", "bcc", "sender", "reply-to", "delivered-to", "return-path", "disposition-notification-to"]);
|
|
3431
|
+
var MAX_ADDRESS_FIELD = 16 * 1024;
|
|
3432
|
+
var MAX_ADDRESS_SEPARATORS = 2e3;
|
|
3433
|
+
var MAX_ADDRESS_GROUPS = 16;
|
|
3434
|
+
var MAX_ADDRESS_TOTAL = 64 * 1024;
|
|
3435
|
+
var AUTH_ADDRESS_FIELDS = /* @__PURE__ */ new Set(["from", "return-path"]);
|
|
3436
|
+
var ADDRESS_OPERATORS = /* @__PURE__ */ new Map([['"', '"'], ["(", ")"], ["<", ">"], [",", ""], [":", ";"], [";", ""]]);
|
|
3437
|
+
var SINGLE_FIELDS = /* @__PURE__ */ new Set(["message-id", "content-id", "from", "sender", "in-reply-to", "reply-to", "subject", "date", "content-disposition", "content-type", "content-transfer-encoding", "priority", "mime-version", "content-description", "precedence", "errors-to", "disposition-notification-to"]);
|
|
3438
|
+
var PRIORITY_FIELDS = /* @__PURE__ */ new Set(["priority", "x-priority", "x-msmail-priority", "importance"]);
|
|
3439
|
+
var BOUNDED2 = /* @__PURE__ */ Symbol("bounded address fields");
|
|
3440
|
+
var ROOM = /* @__PURE__ */ Symbol("room left for address fields");
|
|
3441
|
+
var PARSER_OPTIONS = {
|
|
3442
|
+
skipHtmlToText: true,
|
|
3443
|
+
skipImageLinks: true,
|
|
3444
|
+
skipTextToHtml: true,
|
|
3445
|
+
skipTextLinks: true,
|
|
3446
|
+
[BOUNDED2]: true
|
|
3447
|
+
};
|
|
3448
|
+
function boundAddressField(line, room) {
|
|
3449
|
+
const start = line.indexOf(":") + 1;
|
|
3450
|
+
const limit = Math.min(line.length, MAX_ADDRESS_FIELD, room);
|
|
3451
|
+
let end = start;
|
|
3452
|
+
let expecting = "";
|
|
3453
|
+
let escaped = false;
|
|
3454
|
+
let separators = 0;
|
|
3455
|
+
let groups = 0;
|
|
3456
|
+
let index = start;
|
|
3457
|
+
for (; index < limit; index++) {
|
|
3458
|
+
const chr = line[index];
|
|
3459
|
+
if ((chr === "," || chr === ";") && ++separators > MAX_ADDRESS_SEPARATORS) {
|
|
3460
|
+
break;
|
|
3461
|
+
}
|
|
3462
|
+
if (escaped) {
|
|
3463
|
+
escaped = false;
|
|
3464
|
+
} else if (expecting !== "" && chr === expecting) {
|
|
3465
|
+
expecting = "";
|
|
3466
|
+
end = chr === ";" ? index + 1 : end;
|
|
3467
|
+
} else if (expecting === "" && ADDRESS_OPERATORS.has(chr)) {
|
|
3468
|
+
expecting = ADDRESS_OPERATORS.get(chr);
|
|
3469
|
+
end = chr === "," || chr === ";" ? index : end;
|
|
3470
|
+
} else if (expecting === '"' && chr === "\\") {
|
|
3471
|
+
escaped = true;
|
|
3472
|
+
}
|
|
3473
|
+
if (chr === ":" && expecting === ";" && ++groups > MAX_ADDRESS_GROUPS) {
|
|
3474
|
+
break;
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
return index === line.length ? line : line.slice(0, end).trimEnd();
|
|
3478
|
+
}
|
|
3479
|
+
function boundAddressLines(lines2, budget, only = null) {
|
|
3480
|
+
const cut = /* @__PURE__ */ new Set();
|
|
3481
|
+
const bounded = lines2.map((entry) => {
|
|
3482
|
+
if (only ? !only.has(entry.key) : !ADDRESS_FIELDS.has(entry.key) && !entry.key.startsWith("list-")) {
|
|
3483
|
+
return entry;
|
|
3484
|
+
}
|
|
3485
|
+
const line = boundAddressField(entry.line, budget.room);
|
|
3486
|
+
budget.room = Math.max(budget.room - line.length, 0);
|
|
3487
|
+
if (line === entry.line) {
|
|
3488
|
+
return entry;
|
|
3489
|
+
}
|
|
3490
|
+
cut.add(entry.key);
|
|
3491
|
+
return { ...entry, line };
|
|
3492
|
+
});
|
|
3493
|
+
return { lines: bounded, cut };
|
|
3494
|
+
}
|
|
3495
|
+
var { processHeaders } = MailParser.prototype;
|
|
3496
|
+
MailParser.prototype.processHeaders = function(lines2) {
|
|
3497
|
+
if (!this.options[BOUNDED2]) {
|
|
3498
|
+
return processHeaders.call(this, lines2);
|
|
3499
|
+
}
|
|
3500
|
+
this[ROOM] ??= { room: MAX_ADDRESS_TOTAL };
|
|
3501
|
+
const bounded = boundAddressLines(lines2, this[ROOM]);
|
|
3502
|
+
const headers = processHeaders.call(this, bounded.lines);
|
|
3503
|
+
const keyOf = (entry) => PRIORITY_FIELDS.has(entry.key) ? "priority" : entry.key;
|
|
3504
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3505
|
+
for (const key of bounded.lines.map((entry) => keyOf(entry)).filter((key2) => SINGLE_FIELDS.has(key2))) {
|
|
3506
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
3507
|
+
}
|
|
3508
|
+
for (const key of [...counts.keys()].filter((key2) => counts.get(key2) > 1)) {
|
|
3509
|
+
const last = bounded.lines.findLast((entry) => keyOf(entry) === key && processHeaders.call(this, [entry]).has(key));
|
|
3510
|
+
if (last) {
|
|
3511
|
+
headers.set(key, processHeaders.call(this, [last]).get(key));
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
for (const value of [...bounded.cut].flatMap((key) => headers.get(key) ?? [])) {
|
|
3515
|
+
value.truncated = true;
|
|
3516
|
+
}
|
|
3517
|
+
return headers;
|
|
3518
|
+
};
|
|
3519
|
+
function boundedForAuth(raw) {
|
|
3520
|
+
const ends = [raw.indexOf("\n\n"), raw.indexOf("\n\r\n")].filter((index) => index !== -1);
|
|
3521
|
+
const end = ends.length > 0 ? Math.min(...ends) + 1 : raw.length;
|
|
3522
|
+
const body = raw.subarray(ends.length > 0 ? raw.indexOf(10, end) + 1 : raw.length);
|
|
3523
|
+
const text = raw.subarray(0, end).toString("latin1");
|
|
3524
|
+
let { length } = text;
|
|
3525
|
+
while (length > 0 && (text[length - 1] === "\n" || text[length - 1] === "\r")) {
|
|
3526
|
+
length--;
|
|
3527
|
+
}
|
|
3528
|
+
const fields = [];
|
|
3529
|
+
let folds = 0;
|
|
3530
|
+
let work = 0;
|
|
3531
|
+
for (let start = 0; start <= length; ) {
|
|
3532
|
+
let stop = text.indexOf("\n", start);
|
|
3533
|
+
stop = stop === -1 || stop > length ? length : stop;
|
|
3534
|
+
const lineEnd = stop > start && text[stop - 1] === "\r" ? stop - 1 : stop;
|
|
3535
|
+
const last = fields.at(-1);
|
|
3536
|
+
if (last && /\s/.test(text[start])) {
|
|
3537
|
+
last.end = lineEnd;
|
|
3538
|
+
last.folds++;
|
|
3539
|
+
folds++;
|
|
3540
|
+
work += last.folds;
|
|
3541
|
+
} else {
|
|
3542
|
+
fields.push({ start, end: lineEnd, folds: 0 });
|
|
3543
|
+
}
|
|
3544
|
+
start = stop + 1;
|
|
3545
|
+
}
|
|
3546
|
+
let signatures = 0;
|
|
3547
|
+
let dkim = 0;
|
|
3548
|
+
for (const field of fields) {
|
|
3549
|
+
field.line = text.slice(field.start, field.end);
|
|
3550
|
+
const colon = field.line.indexOf(":");
|
|
3551
|
+
field.key = (colon === -1 ? field.line : field.line.slice(0, colon)).trim().toLowerCase();
|
|
3552
|
+
field.dropped = field.key === "dkim-signature" && ++dkim > MAX_DKIM_SIGNATURES;
|
|
3553
|
+
if (SIGNATURE_FIELDS.has(field.key) && !field.dropped) {
|
|
3554
|
+
signatures += field.line.split(":").length - 1;
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
const read = dkim > MAX_DKIM_SIGNATURES ? fields.filter((field) => !field.dropped) : fields;
|
|
3558
|
+
work += (folds + signatures) * read.length;
|
|
3559
|
+
const bounded = boundAddressLines(read, { room: MAX_ADDRESS_TOTAL }, AUTH_ADDRESS_FIELDS);
|
|
3560
|
+
let kept = bounded.lines;
|
|
3561
|
+
let fromCut = bounded.cut.has("from");
|
|
3562
|
+
if (work > MAX_AUTH_WORK) {
|
|
3563
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3564
|
+
kept = kept.filter((field) => {
|
|
3565
|
+
counts.set(field.key, (counts.get(field.key) || 0) + 1);
|
|
3566
|
+
const keep = AUTH_FIELDS.has(field.key) && counts.get(field.key) <= (SIGNATURE_FIELDS.has(field.key) ? MAX_SIGNATURES : MAX_REPEATED);
|
|
3567
|
+
fromCut ||= field.key === "from" && !keep;
|
|
3568
|
+
return keep;
|
|
3569
|
+
}).map((field) => {
|
|
3570
|
+
const line = field.line.replaceAll(/\r?\n(?=\s)/g, "");
|
|
3571
|
+
return { ...field, line: ADDRESS_FIELDS.has(field.key) ? line : line.slice(0, MAX_FIELD) };
|
|
3572
|
+
});
|
|
3573
|
+
} else if (bounded.cut.size === 0 && read === fields) {
|
|
3574
|
+
return { raw, fromCut };
|
|
3575
|
+
}
|
|
3576
|
+
const header = kept.map((field) => field.line).join("\r\n");
|
|
3577
|
+
return { raw: Buffer2.concat([Buffer2.from(`${header}\r
|
|
3578
|
+
\r
|
|
3579
|
+
`, "latin1"), body]), fromCut };
|
|
3580
|
+
}
|
|
3581
|
+
var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
|
|
3582
|
+
var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
|
|
3583
|
+
var MAX_PART_LINES = 990;
|
|
3584
|
+
var MAX_PREFIX_BYTES = 1e6;
|
|
3585
|
+
var MAX_LINE = 998;
|
|
3586
|
+
var MAX_FIELD = 8192;
|
|
3587
|
+
var MAX_HEADER = 512 * 1024;
|
|
3588
|
+
var MAX_REPEATED = 100;
|
|
3589
|
+
var KEPT_FIELDS = /* @__PURE__ */ new Set([
|
|
3590
|
+
"from",
|
|
3591
|
+
"sender",
|
|
3592
|
+
"reply-to",
|
|
3593
|
+
"to",
|
|
3594
|
+
"cc",
|
|
3595
|
+
"subject",
|
|
3596
|
+
"date",
|
|
3597
|
+
"message-id",
|
|
3598
|
+
"return-path",
|
|
3599
|
+
"received",
|
|
3600
|
+
"authentication-results",
|
|
3601
|
+
"arc-authentication-results",
|
|
3602
|
+
"dkim-signature",
|
|
3603
|
+
"mime-version",
|
|
3604
|
+
"content-type",
|
|
3605
|
+
"content-transfer-encoding",
|
|
3606
|
+
"list-unsubscribe",
|
|
3607
|
+
"list-id"
|
|
3608
|
+
]);
|
|
3609
|
+
var MAX_AUTH_WORK = 1e7;
|
|
3610
|
+
var SIGNATURE_FIELDS = /* @__PURE__ */ new Set(["dkim-signature", "arc-message-signature"]);
|
|
3611
|
+
var AUTH_FIELDS = /* @__PURE__ */ new Set([...KEPT_FIELDS, "arc-seal", "arc-message-signature"]);
|
|
3612
|
+
var MAX_SIGNATURES = 10;
|
|
3613
|
+
var MAX_DKIM_SIGNATURES = 8;
|
|
3614
|
+
var MAX_NESTED_DEPTH = 3;
|
|
3615
|
+
var MAX_ATTACHMENTS = 100;
|
|
3616
|
+
var MAX_NESTED_BYTES = 25 * 1024 * 1024;
|
|
3617
|
+
var PARSE_TIMEOUT = 6e4;
|
|
3618
|
+
async function simpleParse(raw, { timeout = PARSE_TIMEOUT, parser = simpleParser } = {}) {
|
|
3619
|
+
const input = Readable.from([finishable(unfoldHeaders(raw))], { objectMode: false, autoDestroy: false });
|
|
3620
|
+
input.on("error", () => {
|
|
3621
|
+
});
|
|
3622
|
+
const { pipe } = input;
|
|
3623
|
+
input.pipe = function(parser2, options) {
|
|
3624
|
+
parser2.splitter?.on("error", (error) => input.destroy(error));
|
|
3625
|
+
return pipe.call(this, parser2, options);
|
|
3626
|
+
};
|
|
3627
|
+
let timer;
|
|
3628
|
+
try {
|
|
3629
|
+
return await Promise.race([
|
|
3630
|
+
parser(input, PARSER_OPTIONS),
|
|
3631
|
+
new Promise((resolve, reject) => {
|
|
3632
|
+
timer = setTimeout(() => {
|
|
3633
|
+
const error = Object.assign(new Error(`Message not parsed within ${timeout} ms`), { code: "ETIMEDOUT" });
|
|
3634
|
+
input.destroy(error);
|
|
3635
|
+
reject(error);
|
|
3636
|
+
}, timeout);
|
|
3637
|
+
})
|
|
3638
|
+
]);
|
|
3639
|
+
} finally {
|
|
3640
|
+
clearTimeout(timer);
|
|
3641
|
+
input.destroy();
|
|
3642
|
+
}
|
|
3643
|
+
}
|
|
3644
|
+
async function parseMessage(raw, options = {}) {
|
|
3645
|
+
const buffer = Buffer2.isBuffer(raw) ? raw : Buffer2.from(raw);
|
|
3646
|
+
const mail = await simpleParse(buffer, options);
|
|
3647
|
+
const added = buffer.at(-1) === 10 ? 2 : 3;
|
|
3648
|
+
for (const key of ["text", "html"]) {
|
|
3649
|
+
if (typeof mail[key] === "string") {
|
|
3650
|
+
mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
const tail = Buffer2.from("\r\n".repeat(added));
|
|
3654
|
+
const last = mail.attachments?.at(-1);
|
|
3655
|
+
const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
|
|
3656
|
+
const own = Buffer2.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
|
|
3657
|
+
if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
|
|
3658
|
+
last.content = own;
|
|
3659
|
+
last.size = own.length;
|
|
3660
|
+
last.checksum = createHash("md5").update(own).digest("hex");
|
|
3661
|
+
}
|
|
3662
|
+
return mail;
|
|
3663
|
+
}
|
|
3664
|
+
function finishable(raw) {
|
|
3665
|
+
const buffer = Buffer2.isBuffer(raw) ? raw : Buffer2.from(raw);
|
|
3666
|
+
return Buffer2.concat([buffer, Buffer2.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
|
|
3667
|
+
}
|
|
3668
|
+
var MAX_FOLDS = 256;
|
|
3669
|
+
function headerValue2(line) {
|
|
3670
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
3671
|
+
let key = null;
|
|
3672
|
+
let inKey = false;
|
|
3673
|
+
let quoted = false;
|
|
3674
|
+
let escaped = false;
|
|
3675
|
+
let value = "";
|
|
3676
|
+
for (const chr of line.slice(line.indexOf(":") + 1).replaceAll(/\r?\n[ \t]*/g, " ")) {
|
|
3677
|
+
if (inKey && chr === "=") {
|
|
3678
|
+
key = value.trim().toLowerCase();
|
|
3679
|
+
inKey = false;
|
|
3680
|
+
value = "";
|
|
3681
|
+
} else if (inKey || escaped) {
|
|
3682
|
+
value += chr;
|
|
3683
|
+
escaped = false;
|
|
3684
|
+
} else if (chr === "\\") {
|
|
3685
|
+
escaped = true;
|
|
3686
|
+
} else if (chr === '"') {
|
|
3687
|
+
quoted = !quoted;
|
|
3688
|
+
} else if (chr === ";" && !quoted) {
|
|
3689
|
+
parameters.set(key, value.trim());
|
|
3690
|
+
inKey = true;
|
|
3691
|
+
value = "";
|
|
3692
|
+
} else {
|
|
3693
|
+
value += chr;
|
|
3694
|
+
}
|
|
3695
|
+
}
|
|
3696
|
+
if (!inKey) {
|
|
3697
|
+
parameters.set(key, value.trim());
|
|
3698
|
+
}
|
|
3699
|
+
return { value: parameters.get(null).toLowerCase(), boundary: parameters.get("boundary") ?? "" };
|
|
3700
|
+
}
|
|
3701
|
+
function delimiter(raw, start, stop, boundary) {
|
|
3702
|
+
const rest = start + 2 + boundary.length;
|
|
3703
|
+
if (stop - start < boundary.length + 2 || stop - rest > 4 || raw[start] !== 45 || raw[start + 1] !== 45 || !raw.subarray(start + 2, rest).equals(boundary)) {
|
|
3704
|
+
return 0;
|
|
3705
|
+
}
|
|
3706
|
+
if (raw[rest] === 13 || raw[rest] === 10) {
|
|
3707
|
+
return 1;
|
|
3708
|
+
}
|
|
3709
|
+
const tail = raw.subarray(rest, stop).toString("latin1");
|
|
3710
|
+
return /^-{0,2}$|^--\r?\n$|^--\r$/.test(tail) ? 2 : 0;
|
|
3711
|
+
}
|
|
3712
|
+
function unfoldHeaders(raw) {
|
|
3713
|
+
const boundaries = [];
|
|
3714
|
+
const unfold = [];
|
|
3715
|
+
let head = true;
|
|
3716
|
+
let fields = [];
|
|
3717
|
+
const endHead = (blank) => {
|
|
3718
|
+
unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
|
|
3719
|
+
const first = /* @__PURE__ */ new Map();
|
|
3720
|
+
for (const field of fields) {
|
|
3721
|
+
const name = raw.toString("latin1", field.start, Math.min(field.end, field.start + 32));
|
|
3722
|
+
const key = name.slice(0, name.indexOf(":")).trim().toLowerCase();
|
|
3723
|
+
if (["content-type", "content-transfer-encoding", "content-disposition"].includes(key) && !first.has(key)) {
|
|
3724
|
+
first.set(key, headerValue2(raw.toString("latin1", field.start, field.end)));
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
fields = [];
|
|
3728
|
+
const type = first.get("content-type") ?? { value: "", boundary: "" };
|
|
3729
|
+
const encoding = (first.get("content-transfer-encoding")?.value ?? "").replaceAll(/\(.*\)/g, "").trim();
|
|
3730
|
+
if (blank && type.boundary) {
|
|
3731
|
+
boundaries.push(Buffer2.from(type.boundary));
|
|
3732
|
+
}
|
|
3733
|
+
head = blank && type.value === "message/rfc822" && ["", "7bit", "8bit", "binary"].includes(encoding) && first.get("content-disposition")?.value === "inline";
|
|
3734
|
+
};
|
|
3735
|
+
for (let start = 0; start < raw.length; ) {
|
|
3736
|
+
const lf = raw.indexOf(10, start);
|
|
3737
|
+
const stop = lf === -1 ? raw.length : lf + 1;
|
|
3738
|
+
const end = lf === -1 ? stop : raw[lf - 1] === 13 ? lf - 1 : lf;
|
|
3739
|
+
const kind = boundaries.length > 0 ? delimiter(raw, start, stop, boundaries.at(-1)) : 0;
|
|
3740
|
+
if (kind !== 0) {
|
|
3741
|
+
if (head) {
|
|
3742
|
+
endHead(false);
|
|
3743
|
+
}
|
|
3744
|
+
head = kind === 1;
|
|
3745
|
+
if (kind === 2) {
|
|
3746
|
+
boundaries.pop();
|
|
3747
|
+
}
|
|
3748
|
+
} else if (head && end === start) {
|
|
3749
|
+
endHead(true);
|
|
3750
|
+
} else if (head && fields.length > 0 && (raw[start] === 32 || raw[start] === 9)) {
|
|
3751
|
+
fields.at(-1).end = end;
|
|
3752
|
+
fields.at(-1).folds++;
|
|
3753
|
+
} else if (head) {
|
|
3754
|
+
fields.push({ start, end, folds: 0 });
|
|
3755
|
+
}
|
|
3756
|
+
start = stop;
|
|
3757
|
+
}
|
|
3758
|
+
unfold.push(...fields.filter((field) => field.folds > MAX_FOLDS));
|
|
3759
|
+
if (unfold.length === 0) {
|
|
3760
|
+
return raw;
|
|
3761
|
+
}
|
|
3762
|
+
const chunks = [];
|
|
3763
|
+
let copied = 0;
|
|
3764
|
+
for (const field of unfold) {
|
|
3765
|
+
chunks.push(raw.subarray(copied, field.start), Buffer2.from(raw.toString("latin1", field.start, field.end).replaceAll(/\r?\n[ \t]*/g, " "), "latin1"));
|
|
3766
|
+
copied = field.end;
|
|
3767
|
+
}
|
|
3768
|
+
chunks.push(raw.subarray(copied));
|
|
3769
|
+
return Buffer2.concat(chunks);
|
|
3770
|
+
}
|
|
3771
|
+
function upToLine(raw, offset) {
|
|
3772
|
+
return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
|
|
3773
|
+
}
|
|
3774
|
+
function fewerParts(raw) {
|
|
3775
|
+
let offset = -1;
|
|
3776
|
+
for (let count = 0; count < MAX_PART_LINES; count++) {
|
|
3777
|
+
offset = raw.indexOf("\n--", offset + 1);
|
|
3778
|
+
if (offset === -1) {
|
|
3779
|
+
return upToLine(raw, Math.floor(raw.length / 2));
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
return raw.subarray(0, offset + 1);
|
|
3783
|
+
}
|
|
3784
|
+
function smallerHeader(raw) {
|
|
3785
|
+
const text = raw.toString("latin1");
|
|
3786
|
+
const blank = /\r?\n\r?\n/.exec(text);
|
|
3787
|
+
const end = blank ? blank.index : text.length;
|
|
3788
|
+
const fields = [];
|
|
3789
|
+
let start = 0;
|
|
3790
|
+
while (start < end) {
|
|
3791
|
+
let stop = text.indexOf("\n", start);
|
|
3792
|
+
stop = stop === -1 || stop > end ? end : stop;
|
|
3793
|
+
const last = fields.at(-1);
|
|
3794
|
+
const whole = text.slice(start, stop).replace(/\r$/, "");
|
|
3795
|
+
if (last && (whole.startsWith(" ") || whole.startsWith(" "))) {
|
|
3796
|
+
const line = last.address ? whole : whole.slice(0, MAX_LINE);
|
|
3797
|
+
if (last.address || last.size + line.length <= MAX_FIELD) {
|
|
3798
|
+
last.lines.push(line);
|
|
3799
|
+
last.size += line.length;
|
|
3800
|
+
}
|
|
3801
|
+
} else {
|
|
3802
|
+
const name = whole.slice(0, whole.indexOf(":")).trim().toLowerCase();
|
|
3803
|
+
const address2 = ADDRESS_FIELDS.has(name) || name.startsWith("list-");
|
|
3804
|
+
const line = address2 ? whole : whole.slice(0, MAX_LINE);
|
|
3805
|
+
fields.push({
|
|
3806
|
+
name,
|
|
3807
|
+
address: address2,
|
|
3808
|
+
lines: [line],
|
|
3809
|
+
size: line.length
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
start = stop + 1;
|
|
3813
|
+
}
|
|
3814
|
+
let fromCut = false;
|
|
3815
|
+
for (const field of fields.filter((field2) => field2.address)) {
|
|
3816
|
+
const line = field.lines.join("\r\n");
|
|
3817
|
+
const bounded = boundAddressField(line, Number.POSITIVE_INFINITY);
|
|
3818
|
+
fromCut ||= field.name === "from" && bounded !== line;
|
|
3819
|
+
field.lines = [bounded];
|
|
3820
|
+
field.size = bounded.length;
|
|
3821
|
+
}
|
|
3822
|
+
const total = fields.reduce((sum, field) => sum + field.size, 0);
|
|
3823
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3824
|
+
const kept = total > MAX_HEADER ? fields.filter((field) => {
|
|
3825
|
+
counts.set(field.name, (counts.get(field.name) || 0) + 1);
|
|
3826
|
+
const keep = KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
|
|
3827
|
+
fromCut ||= field.name === "from" && !keep;
|
|
3828
|
+
return keep;
|
|
3829
|
+
}) : fields;
|
|
3830
|
+
const header = kept.flatMap((field) => field.lines).join("\r\n");
|
|
3831
|
+
return { raw: Buffer2.concat([Buffer2.from(`${header}\r
|
|
3832
|
+
\r
|
|
3833
|
+
`, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]), fromCut };
|
|
3834
|
+
}
|
|
3835
|
+
async function parseWithin(raw, parse = parseMessage, deadline = Date.now() + PARSE_TIMEOUT) {
|
|
3836
|
+
let input = raw;
|
|
3837
|
+
let reason = null;
|
|
3838
|
+
let fromCut = false;
|
|
3839
|
+
for (; ; ) {
|
|
3840
|
+
try {
|
|
3841
|
+
const mail = await parse(input, { timeout: Math.max(1, deadline - Date.now()) });
|
|
3842
|
+
if (fromCut && mail.from) {
|
|
3843
|
+
mail.from.truncated = true;
|
|
3844
|
+
}
|
|
3845
|
+
return { mail, error: reason };
|
|
3846
|
+
} catch (error) {
|
|
3847
|
+
const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
|
|
3848
|
+
let shorter = input;
|
|
3849
|
+
if (limit === TOO_MANY_PARTS) {
|
|
3850
|
+
shorter = fewerParts(input);
|
|
3851
|
+
} else if (limit === HEADER_TOO_LARGE) {
|
|
3852
|
+
const smaller = smallerHeader(input);
|
|
3853
|
+
shorter = smaller.raw.length < input.length ? smaller.raw : upToLine(input, MAX_PREFIX_BYTES);
|
|
3854
|
+
fromCut ||= smaller.raw === shorter && smaller.fromCut;
|
|
3855
|
+
}
|
|
3856
|
+
if (shorter.length >= input.length) {
|
|
3857
|
+
throw error;
|
|
3858
|
+
}
|
|
3859
|
+
reason ??= limit;
|
|
3860
|
+
input = shorter;
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
}
|
|
3864
|
+
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
3865
|
+
const all = [];
|
|
3866
|
+
const deadline = Date.now() + PARSE_TIMEOUT;
|
|
3867
|
+
let budget = MAX_NESTED_BYTES;
|
|
3868
|
+
let nested = 0;
|
|
3869
|
+
const visit = async (list, prefix, depth) => {
|
|
3870
|
+
for (const attachment of list) {
|
|
3871
|
+
if (prefix) {
|
|
3872
|
+
if (nested >= MAX_ATTACHMENTS) {
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
nested++;
|
|
3876
|
+
}
|
|
3877
|
+
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
3878
|
+
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
3879
|
+
const { content } = attachment;
|
|
3880
|
+
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
3881
|
+
if (attached && depth < MAX_NESTED_DEPTH && Buffer2.isBuffer(content) && content.length <= budget) {
|
|
3882
|
+
budget -= content.length;
|
|
3883
|
+
let inner;
|
|
3884
|
+
try {
|
|
3885
|
+
inner = await parseWithin(content, parse, deadline);
|
|
3886
|
+
} catch (error) {
|
|
3887
|
+
errors.push(`${name || "attached message"}: ${error.message}`);
|
|
3888
|
+
continue;
|
|
3889
|
+
}
|
|
3890
|
+
if (inner.error) {
|
|
3891
|
+
errors.push(inner.error);
|
|
3892
|
+
}
|
|
3893
|
+
await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
};
|
|
3897
|
+
await visit(attachments, "", 0);
|
|
3898
|
+
return all;
|
|
3899
|
+
}
|
|
3900
|
+
|
|
3406
3901
|
// src/reputation.js
|
|
3407
3902
|
import { isIP as isIP2 } from "node:net";
|
|
3408
3903
|
import { domainToASCII } from "node:url";
|
|
@@ -3651,14 +4146,14 @@ var DnsChecker = class {
|
|
|
3651
4146
|
};
|
|
3652
4147
|
|
|
3653
4148
|
// src/http.js
|
|
3654
|
-
import { Buffer as
|
|
4149
|
+
import { Buffer as Buffer3 } from "node:buffer";
|
|
3655
4150
|
import http from "node:http";
|
|
3656
4151
|
import https from "node:https";
|
|
3657
4152
|
function requestJson(method, url, body, options = {}) {
|
|
3658
4153
|
const { headers = {}, timeout = 3e4, maxResponseBytes = 1048576, ca } = options;
|
|
3659
4154
|
const target = new URL(url);
|
|
3660
4155
|
const transport = target.protocol === "https:" ? https : http;
|
|
3661
|
-
const payload = body === null || body === void 0 ? null :
|
|
4156
|
+
const payload = body === null || body === void 0 ? null : Buffer3.from(JSON.stringify(body));
|
|
3662
4157
|
return new Promise((resolve, reject) => {
|
|
3663
4158
|
let settled = false;
|
|
3664
4159
|
const finish = (error, value) => {
|
|
@@ -3696,7 +4191,7 @@ function requestJson(method, url, body, options = {}) {
|
|
|
3696
4191
|
});
|
|
3697
4192
|
response.on("error", (error) => finish(error));
|
|
3698
4193
|
response.on("end", () => {
|
|
3699
|
-
const text =
|
|
4194
|
+
const text = Buffer3.concat(chunks).toString("utf8");
|
|
3700
4195
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
3701
4196
|
const error = new Error(`HTTP ${response.statusCode} from ${target.origin}${target.pathname}: ${text.slice(0, 300)}`);
|
|
3702
4197
|
error.statusCode = response.statusCode;
|
|
@@ -3972,7 +4467,7 @@ function trustedReputationValues(mail = {}, session = {}, authentication = null)
|
|
|
3972
4467
|
...[spfAligned].filter((signer) => signer !== null && (alignment?.spf?.strict !== true || signer === domain))
|
|
3973
4468
|
])];
|
|
3974
4469
|
const fromValues = [];
|
|
3975
|
-
for (const { address: address2 } of mail.from?.value || []) {
|
|
4470
|
+
for (const { address: address2 } of mail.from?.truncated ? [] : mail.from?.value || []) {
|
|
3976
4471
|
const domain = domainOf(address2);
|
|
3977
4472
|
const values = reputationValues({ from: { value: [{ address: address2 }] } });
|
|
3978
4473
|
if (domain !== null && dmarcDomain !== null && (domain === dmarcDomain || registrableDomain(domain) === dmarcDomain)) {
|
|
@@ -4035,6 +4530,37 @@ function createResolver(timeout = 1e4, servers) {
|
|
|
4035
4530
|
}
|
|
4036
4531
|
};
|
|
4037
4532
|
}
|
|
4533
|
+
var MAX_DNS_QUERIES = 300;
|
|
4534
|
+
var MAX_AUTH_RESULTS_LENGTH = 8192;
|
|
4535
|
+
function dnsTimeout(message) {
|
|
4536
|
+
const error = new Error(message);
|
|
4537
|
+
error.code = "ETIMEOUT";
|
|
4538
|
+
return error;
|
|
4539
|
+
}
|
|
4540
|
+
function limitResolver(resolver, { maxQueries = MAX_DNS_QUERIES, deadline = Number.POSITIVE_INFINITY } = {}) {
|
|
4541
|
+
let count = 0;
|
|
4542
|
+
return async (...args) => {
|
|
4543
|
+
count++;
|
|
4544
|
+
if (count > maxQueries) {
|
|
4545
|
+
throw dnsTimeout(`More than ${maxQueries} DNS lookups for one message`);
|
|
4546
|
+
}
|
|
4547
|
+
const left = deadline - Date.now();
|
|
4548
|
+
if (left <= 0) {
|
|
4549
|
+
throw dnsTimeout("Out of time for this message's DNS lookups");
|
|
4550
|
+
}
|
|
4551
|
+
let timer;
|
|
4552
|
+
try {
|
|
4553
|
+
return await Promise.race([
|
|
4554
|
+
resolver(...args),
|
|
4555
|
+
new Promise((_resolve, reject) => {
|
|
4556
|
+
timer = setTimeout(() => reject(dnsTimeout("Out of time for this message's DNS lookups")), Math.min(left, 2147483647));
|
|
4557
|
+
})
|
|
4558
|
+
]);
|
|
4559
|
+
} finally {
|
|
4560
|
+
clearTimeout(timer);
|
|
4561
|
+
}
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
4038
4564
|
function emptyResult() {
|
|
4039
4565
|
return {
|
|
4040
4566
|
dkim: { status: { result: "none", comment: "not checked" }, results: [], aligned: null },
|
|
@@ -4174,9 +4700,14 @@ async function authenticate(message, options = {}) {
|
|
|
4174
4700
|
}
|
|
4175
4701
|
try {
|
|
4176
4702
|
const { authenticate: run } = await getMailauth();
|
|
4177
|
-
const resolver = options.resolver || createResolver(timeout, options.dnsServers)
|
|
4178
|
-
|
|
4179
|
-
|
|
4703
|
+
const resolver = limitResolver(options.resolver || createResolver(timeout, options.dnsServers), {
|
|
4704
|
+
maxQueries: options.maxDnsQueries ?? MAX_DNS_QUERIES,
|
|
4705
|
+
// (a little after `timeout`, so a caller that waits that long for the
|
|
4706
|
+
// result, as scan() does, has its answer first)
|
|
4707
|
+
deadline: Date.now() + timeout + 100
|
|
4708
|
+
});
|
|
4709
|
+
const bounded = boundedForAuth(Buffer4.isBuffer(message) ? message : Buffer4.from(String(message)));
|
|
4710
|
+
const output = await run(bounded.raw, {
|
|
4180
4711
|
ip,
|
|
4181
4712
|
helo: helo || void 0,
|
|
4182
4713
|
sender: sender ?? void 0,
|
|
@@ -4184,6 +4715,10 @@ async function authenticate(message, options = {}) {
|
|
|
4184
4715
|
resolver
|
|
4185
4716
|
});
|
|
4186
4717
|
Object.assign(result, normalizeAuthOutput(output, { trustedArcSealers: options.trustedArcSealers }));
|
|
4718
|
+
if (bounded.fromCut) {
|
|
4719
|
+
result.dmarc = { ...result.dmarc, status: { result: "fail", comment: "From field too long to read whole" } };
|
|
4720
|
+
result.headers = "";
|
|
4721
|
+
}
|
|
4187
4722
|
} catch (error) {
|
|
4188
4723
|
debug("authentication failed: %s", error.message);
|
|
4189
4724
|
result.error = error.message;
|
|
@@ -4234,11 +4769,24 @@ function calculateAuthScore(auth, weights = {}) {
|
|
|
4234
4769
|
}
|
|
4235
4770
|
return { score: tests.reduce((sum, test) => sum + test.score, 0), tests };
|
|
4236
4771
|
}
|
|
4772
|
+
function limitResults(value) {
|
|
4773
|
+
if (value.length <= MAX_AUTH_RESULTS_LENGTH) {
|
|
4774
|
+
return value;
|
|
4775
|
+
}
|
|
4776
|
+
const [server, ...results] = value.split(/;(?=\s*(?:dkim|spf|dmarc|arc|bimi)=)/i);
|
|
4777
|
+
let kept = server;
|
|
4778
|
+
for (const result of results) {
|
|
4779
|
+
if (kept.length + 1 + result.length <= MAX_AUTH_RESULTS_LENGTH) {
|
|
4780
|
+
kept += `;${result}`;
|
|
4781
|
+
}
|
|
4782
|
+
}
|
|
4783
|
+
return kept;
|
|
4784
|
+
}
|
|
4237
4785
|
function formatAuthResultsHeader(auth, hostname) {
|
|
4238
4786
|
if (typeof auth?.headers === "string") {
|
|
4239
4787
|
const match = auth.headers.match(/^authentication-results:[ \t]*([\s\S]*?)(?=\r?\n\S|(?![\s\S]))/im);
|
|
4240
4788
|
if (match) {
|
|
4241
|
-
const value = match[1].replaceAll(/\r?\n/g, "\r\n").trim();
|
|
4789
|
+
const value = limitResults(match[1].replaceAll(/\r?\n/g, "\r\n").trim());
|
|
4242
4790
|
return hostname ? value.replace(/^[^;\s]+/, hostname) : value;
|
|
4243
4791
|
}
|
|
4244
4792
|
}
|
|
@@ -4253,7 +4801,7 @@ function summarizeAuth(auth) {
|
|
|
4253
4801
|
}
|
|
4254
4802
|
|
|
4255
4803
|
// src/clamav.js
|
|
4256
|
-
import { Buffer as
|
|
4804
|
+
import { Buffer as Buffer5 } from "node:buffer";
|
|
4257
4805
|
import { existsSync } from "node:fs";
|
|
4258
4806
|
import net from "node:net";
|
|
4259
4807
|
var SOCKET_PATHS = ["/var/run/clamav/clamd.ctl", "/run/clamav/clamd.ctl", "/run/clamd.scan/clamd.sock", "/var/run/clamd.scan/clamd.sock", "/var/run/clamav/clamd.sock", "/tmp/clamd.socket", "/opt/homebrew/var/run/clamav/clamd.sock", "/usr/local/var/run/clamav/clamd.sock"];
|
|
@@ -4280,10 +4828,10 @@ function exchange(address2, write, timeout) {
|
|
|
4280
4828
|
resolve(value);
|
|
4281
4829
|
}
|
|
4282
4830
|
};
|
|
4283
|
-
const timer = setTimeout(() => finish(new Error(`clamd did not answer within ${timeout} ms`)), timeout);
|
|
4831
|
+
const timer = setTimeout(() => finish(Object.assign(new Error(`clamd did not answer within ${timeout} ms`), { code: "ETIMEDOUT", timedOut: true })), timeout);
|
|
4284
4832
|
socket.on("connect", () => write(socket));
|
|
4285
4833
|
socket.on("data", (chunk2) => chunks.push(chunk2));
|
|
4286
|
-
socket.on("end", () => finish(null,
|
|
4834
|
+
socket.on("end", () => finish(null, Buffer5.concat(chunks).toString("utf8").replaceAll("\0", "").trim()));
|
|
4287
4835
|
socket.on("error", (error) => finish(error));
|
|
4288
4836
|
});
|
|
4289
4837
|
}
|
|
@@ -4297,12 +4845,12 @@ async function scanBuffer(buffer, options = {}) {
|
|
|
4297
4845
|
socket.write("zINSTREAM\0");
|
|
4298
4846
|
for (let offset = 0; offset < buffer.length; offset += chunkSize) {
|
|
4299
4847
|
const chunk2 = buffer.subarray(offset, offset + chunkSize);
|
|
4300
|
-
const size =
|
|
4848
|
+
const size = Buffer5.alloc(4);
|
|
4301
4849
|
size.writeUInt32BE(chunk2.length);
|
|
4302
4850
|
socket.write(size);
|
|
4303
4851
|
socket.write(chunk2);
|
|
4304
4852
|
}
|
|
4305
|
-
socket.end(
|
|
4853
|
+
socket.end(Buffer5.alloc(4));
|
|
4306
4854
|
}, options.timeout ?? 3e4);
|
|
4307
4855
|
if (/\bOK$/.test(reply2)) {
|
|
4308
4856
|
return { infected: false, viruses: [], reply: reply2 };
|
|
@@ -4315,11 +4863,11 @@ async function scanBuffer(buffer, options = {}) {
|
|
|
4315
4863
|
}
|
|
4316
4864
|
|
|
4317
4865
|
// src/classifier.js
|
|
4318
|
-
import { Buffer as
|
|
4866
|
+
import { Buffer as Buffer6 } from "node:buffer";
|
|
4319
4867
|
var MODEL_VERSION = 2;
|
|
4320
4868
|
function hashFeature(value) {
|
|
4321
4869
|
let hash = 2166136261;
|
|
4322
|
-
const bytes =
|
|
4870
|
+
const bytes = Buffer6.from(value, "utf8");
|
|
4323
4871
|
for (const byte of bytes) {
|
|
4324
4872
|
hash ^= byte;
|
|
4325
4873
|
hash = Math.imul(hash, 16777619);
|
|
@@ -4645,7 +5193,7 @@ var Classifier = class _Classifier {
|
|
|
4645
5193
|
spam[i] = s;
|
|
4646
5194
|
ham[i] = h;
|
|
4647
5195
|
}
|
|
4648
|
-
const encode = (array) =>
|
|
5196
|
+
const encode = (array) => Buffer6.from(array.buffer, array.byteOffset, array.byteLength).toString("base64");
|
|
4649
5197
|
return {
|
|
4650
5198
|
type: "spamscanner-classifier",
|
|
4651
5199
|
version: MODEL_VERSION,
|
|
@@ -4696,7 +5244,7 @@ var Classifier = class _Classifier {
|
|
|
4696
5244
|
throw new TypeError(`Unsupported classifier model version ${data.version}; this release reads version ${MODEL_VERSION}`);
|
|
4697
5245
|
}
|
|
4698
5246
|
const decode = (value, name) => {
|
|
4699
|
-
const buffer = typeof value === "string" ?
|
|
5247
|
+
const buffer = typeof value === "string" ? Buffer6.from(value, "base64") : null;
|
|
4700
5248
|
if (!buffer || buffer.byteLength % 4 !== 0) {
|
|
4701
5249
|
throw corrupt(`${name} is not a list of 32-bit numbers`);
|
|
4702
5250
|
}
|
|
@@ -5061,8 +5609,8 @@ ${withoutHelp(markup)}`) || attributeText(markup).some(({ text: text2, start, en
|
|
|
5061
5609
|
}
|
|
5062
5610
|
|
|
5063
5611
|
// src/llm.js
|
|
5064
|
-
import { Buffer as
|
|
5065
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
5612
|
+
import { Buffer as Buffer7 } from "node:buffer";
|
|
5613
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
5066
5614
|
import os from "node:os";
|
|
5067
5615
|
import process from "node:process";
|
|
5068
5616
|
var PROVIDERS = {
|
|
@@ -5422,7 +5970,7 @@ function authHeaders(config) {
|
|
|
5422
5970
|
case "basic": {
|
|
5423
5971
|
const user = config.username ?? "";
|
|
5424
5972
|
const pass = config.password ?? config.apiKey ?? "";
|
|
5425
|
-
headers.authorization = `Basic ${
|
|
5973
|
+
headers.authorization = `Basic ${Buffer7.from(`${user}:${pass}`).toString("base64")}`;
|
|
5426
5974
|
break;
|
|
5427
5975
|
}
|
|
5428
5976
|
case "x-api-key": {
|
|
@@ -5989,7 +6537,7 @@ var LLMClassifier = class {
|
|
|
5989
6537
|
* @returns {Promise<{verdict: string, confidence: number, language: string|null, reasons: string[], provider: string, model: string, cached: boolean, time: number}>}
|
|
5990
6538
|
*/
|
|
5991
6539
|
async classifyText(text) {
|
|
5992
|
-
const key =
|
|
6540
|
+
const key = createHash2("sha256").update(text).digest("hex");
|
|
5993
6541
|
if (this.cache.has(key)) {
|
|
5994
6542
|
const hit = this.cache.get(key);
|
|
5995
6543
|
this.cache.delete(key);
|
|
@@ -6051,7 +6599,11 @@ var LLMClassifier = class {
|
|
|
6051
6599
|
|
|
6052
6600
|
// src/model.js
|
|
6053
6601
|
import {
|
|
6602
|
+
closeSync,
|
|
6054
6603
|
existsSync as existsSync2,
|
|
6604
|
+
fchmodSync,
|
|
6605
|
+
fsyncSync,
|
|
6606
|
+
openSync,
|
|
6055
6607
|
readFileSync,
|
|
6056
6608
|
writeFileSync,
|
|
6057
6609
|
mkdirSync,
|
|
@@ -6127,9 +6679,19 @@ function saveModel(classifier, file, options = {}) {
|
|
|
6127
6679
|
const resolved = path.resolve(file);
|
|
6128
6680
|
mkdirSync(path.dirname(resolved), { recursive: true });
|
|
6129
6681
|
const temporary = `${resolved}.${process2.pid}.${randomBytes2(6).toString("hex")}.tmp`;
|
|
6682
|
+
const mode = statSync(resolved, { throwIfNoEntry: false })?.mode;
|
|
6683
|
+
const fd = openSync(temporary, "wx");
|
|
6130
6684
|
try {
|
|
6131
|
-
|
|
6685
|
+
try {
|
|
6686
|
+
if (mode !== void 0) {
|
|
6687
|
+
fchmodSync(fd, mode % 512);
|
|
6688
|
+
}
|
|
6689
|
+
writeFileSync(fd, `${JSON.stringify(classifier.toJSON(options))}
|
|
6132
6690
|
`);
|
|
6691
|
+
fsyncSync(fd);
|
|
6692
|
+
} finally {
|
|
6693
|
+
closeSync(fd);
|
|
6694
|
+
}
|
|
6133
6695
|
renameSync(temporary, resolved);
|
|
6134
6696
|
} catch (error) {
|
|
6135
6697
|
rmSync(temporary, { force: true });
|
|
@@ -6138,175 +6700,6 @@ function saveModel(classifier, file, options = {}) {
|
|
|
6138
6700
|
loaded.delete(resolved);
|
|
6139
6701
|
}
|
|
6140
6702
|
|
|
6141
|
-
// src/parse.js
|
|
6142
|
-
import { Buffer as Buffer7 } from "node:buffer";
|
|
6143
|
-
import { createHash as createHash2 } from "node:crypto";
|
|
6144
|
-
import { simpleParser } from "mailparser";
|
|
6145
|
-
var PARSER_OPTIONS = {
|
|
6146
|
-
skipHtmlToText: true,
|
|
6147
|
-
skipImageLinks: true,
|
|
6148
|
-
skipTextToHtml: true,
|
|
6149
|
-
skipTextLinks: true
|
|
6150
|
-
};
|
|
6151
|
-
var TOO_MANY_PARTS = "Max allowed child nodes exceeded";
|
|
6152
|
-
var HEADER_TOO_LARGE = "Max header size for a MIME node exceeded";
|
|
6153
|
-
var MAX_PART_LINES = 990;
|
|
6154
|
-
var MAX_PREFIX_BYTES = 1e6;
|
|
6155
|
-
var MAX_LINE = 998;
|
|
6156
|
-
var MAX_FIELD = 8192;
|
|
6157
|
-
var MAX_HEADER = 512 * 1024;
|
|
6158
|
-
var MAX_REPEATED = 100;
|
|
6159
|
-
var KEPT_FIELDS = /* @__PURE__ */ new Set([
|
|
6160
|
-
"from",
|
|
6161
|
-
"sender",
|
|
6162
|
-
"reply-to",
|
|
6163
|
-
"to",
|
|
6164
|
-
"cc",
|
|
6165
|
-
"subject",
|
|
6166
|
-
"date",
|
|
6167
|
-
"message-id",
|
|
6168
|
-
"return-path",
|
|
6169
|
-
"received",
|
|
6170
|
-
"authentication-results",
|
|
6171
|
-
"arc-authentication-results",
|
|
6172
|
-
"dkim-signature",
|
|
6173
|
-
"mime-version",
|
|
6174
|
-
"content-type",
|
|
6175
|
-
"content-transfer-encoding",
|
|
6176
|
-
"list-unsubscribe",
|
|
6177
|
-
"list-id"
|
|
6178
|
-
]);
|
|
6179
|
-
var MAX_NESTED_DEPTH = 3;
|
|
6180
|
-
var MAX_ATTACHMENTS = 100;
|
|
6181
|
-
var MAX_NESTED_BYTES = 25 * 1024 * 1024;
|
|
6182
|
-
async function parseMessage(raw) {
|
|
6183
|
-
const buffer = Buffer7.isBuffer(raw) ? raw : Buffer7.from(raw);
|
|
6184
|
-
const mail = await simpleParser(finishable(buffer), PARSER_OPTIONS);
|
|
6185
|
-
const added = buffer.at(-1) === 10 ? 2 : 3;
|
|
6186
|
-
for (const key of ["text", "html"]) {
|
|
6187
|
-
if (typeof mail[key] === "string") {
|
|
6188
|
-
mail[key] = mail[key].replace(new RegExp(`\\n{0,${added}}$`), "");
|
|
6189
|
-
}
|
|
6190
|
-
}
|
|
6191
|
-
const tail = Buffer7.from("\r\n".repeat(added));
|
|
6192
|
-
const last = mail.attachments?.at(-1);
|
|
6193
|
-
const encoding = String(last?.headers?.get?.("content-transfer-encoding") ?? "7bit").trim().toLowerCase();
|
|
6194
|
-
const own = Buffer7.isBuffer(last?.content) && last.content.length >= tail.length && last.content.subarray(-tail.length).equals(tail) ? last.content.subarray(0, -tail.length) : null;
|
|
6195
|
-
if (own && ["7bit", "8bit", "binary"].includes(encoding) && own.length <= buffer.length && buffer.subarray(buffer.length - own.length).equals(own)) {
|
|
6196
|
-
last.content = own;
|
|
6197
|
-
last.size = own.length;
|
|
6198
|
-
last.checksum = createHash2("md5").update(own).digest("hex");
|
|
6199
|
-
}
|
|
6200
|
-
return mail;
|
|
6201
|
-
}
|
|
6202
|
-
function finishable(raw) {
|
|
6203
|
-
const buffer = Buffer7.isBuffer(raw) ? raw : Buffer7.from(raw);
|
|
6204
|
-
return Buffer7.concat([buffer, Buffer7.from(buffer.at(-1) === 10 ? "\r\n\r\n" : "\r\n\r\n\r\n")]);
|
|
6205
|
-
}
|
|
6206
|
-
function upToLine(raw, offset) {
|
|
6207
|
-
return raw.subarray(0, Math.max(raw.lastIndexOf(10, offset - 1) + 1, 0));
|
|
6208
|
-
}
|
|
6209
|
-
function fewerParts(raw) {
|
|
6210
|
-
let offset = -1;
|
|
6211
|
-
for (let count = 0; count < MAX_PART_LINES; count++) {
|
|
6212
|
-
offset = raw.indexOf("\n--", offset + 1);
|
|
6213
|
-
if (offset === -1) {
|
|
6214
|
-
return upToLine(raw, Math.floor(raw.length / 2));
|
|
6215
|
-
}
|
|
6216
|
-
}
|
|
6217
|
-
return raw.subarray(0, offset + 1);
|
|
6218
|
-
}
|
|
6219
|
-
function smallerHeader(raw) {
|
|
6220
|
-
const text = raw.toString("latin1");
|
|
6221
|
-
const blank = /\r?\n\r?\n/.exec(text);
|
|
6222
|
-
const end = blank ? blank.index : text.length;
|
|
6223
|
-
const fields = [];
|
|
6224
|
-
let start = 0;
|
|
6225
|
-
while (start < end) {
|
|
6226
|
-
let stop = text.indexOf("\n", start);
|
|
6227
|
-
stop = stop === -1 || stop > end ? end : stop;
|
|
6228
|
-
const line = text.slice(start, Math.min(stop, start + MAX_LINE)).replace(/\r$/, "");
|
|
6229
|
-
const last = fields.at(-1);
|
|
6230
|
-
if (last && (line.startsWith(" ") || line.startsWith(" "))) {
|
|
6231
|
-
if (last.size + line.length <= MAX_FIELD) {
|
|
6232
|
-
last.lines.push(line);
|
|
6233
|
-
last.size += line.length;
|
|
6234
|
-
}
|
|
6235
|
-
} else {
|
|
6236
|
-
fields.push({ name: line.slice(0, line.indexOf(":")).trim().toLowerCase(), lines: [line], size: line.length });
|
|
6237
|
-
}
|
|
6238
|
-
start = stop + 1;
|
|
6239
|
-
}
|
|
6240
|
-
const total = fields.reduce((sum, field) => sum + field.size, 0);
|
|
6241
|
-
const counts = /* @__PURE__ */ new Map();
|
|
6242
|
-
const kept = total > MAX_HEADER ? fields.filter((field) => {
|
|
6243
|
-
counts.set(field.name, (counts.get(field.name) || 0) + 1);
|
|
6244
|
-
return KEPT_FIELDS.has(field.name) && counts.get(field.name) <= MAX_REPEATED;
|
|
6245
|
-
}) : fields;
|
|
6246
|
-
const header = kept.flatMap((field) => field.lines).join("\r\n");
|
|
6247
|
-
return Buffer7.concat([Buffer7.from(`${header}\r
|
|
6248
|
-
\r
|
|
6249
|
-
`, "latin1"), raw.subarray(blank ? blank.index + blank[0].length : raw.length)]);
|
|
6250
|
-
}
|
|
6251
|
-
async function parseWithin(raw, parse = parseMessage) {
|
|
6252
|
-
let input = raw;
|
|
6253
|
-
let reason = null;
|
|
6254
|
-
for (; ; ) {
|
|
6255
|
-
try {
|
|
6256
|
-
return { mail: await parse(input), error: reason };
|
|
6257
|
-
} catch (error) {
|
|
6258
|
-
const limit = error.code === "EMAXLEN" && [TOO_MANY_PARTS, HEADER_TOO_LARGE].includes(error.message) ? error.message : null;
|
|
6259
|
-
let shorter = input;
|
|
6260
|
-
if (limit === TOO_MANY_PARTS) {
|
|
6261
|
-
shorter = fewerParts(input);
|
|
6262
|
-
} else if (limit === HEADER_TOO_LARGE) {
|
|
6263
|
-
shorter = smallerHeader(input);
|
|
6264
|
-
shorter = shorter.length < input.length ? shorter : upToLine(input, MAX_PREFIX_BYTES);
|
|
6265
|
-
}
|
|
6266
|
-
if (shorter.length >= input.length) {
|
|
6267
|
-
throw error;
|
|
6268
|
-
}
|
|
6269
|
-
reason ??= limit;
|
|
6270
|
-
input = shorter;
|
|
6271
|
-
}
|
|
6272
|
-
}
|
|
6273
|
-
}
|
|
6274
|
-
async function expandAttachments(attachments = [], errors = [], parse = parseMessage) {
|
|
6275
|
-
const all = [];
|
|
6276
|
-
let budget = MAX_NESTED_BYTES;
|
|
6277
|
-
let nested = 0;
|
|
6278
|
-
const visit = async (list, prefix, depth) => {
|
|
6279
|
-
for (const attachment of list) {
|
|
6280
|
-
if (prefix) {
|
|
6281
|
-
if (nested >= MAX_ATTACHMENTS) {
|
|
6282
|
-
return;
|
|
6283
|
-
}
|
|
6284
|
-
nested++;
|
|
6285
|
-
}
|
|
6286
|
-
const name = attachment.filename || (prefix ? "unnamed attachment" : "");
|
|
6287
|
-
all.push(prefix ? { ...attachment, filename: `${prefix}/${name}` } : attachment);
|
|
6288
|
-
const { content } = attachment;
|
|
6289
|
-
const attached = attachment.contentType === "message/rfc822" || /\.eml$/i.test(attachment.filename || "");
|
|
6290
|
-
if (attached && depth < MAX_NESTED_DEPTH && Buffer7.isBuffer(content) && content.length <= budget) {
|
|
6291
|
-
budget -= content.length;
|
|
6292
|
-
let inner;
|
|
6293
|
-
try {
|
|
6294
|
-
inner = await parseWithin(content, parse);
|
|
6295
|
-
} catch (error) {
|
|
6296
|
-
errors.push(`${name || "attached message"}: ${error.message}`);
|
|
6297
|
-
continue;
|
|
6298
|
-
}
|
|
6299
|
-
if (inner.error) {
|
|
6300
|
-
errors.push(inner.error);
|
|
6301
|
-
}
|
|
6302
|
-
await visit(inner.mail.attachments, prefix ? `${prefix}/${name}` : name || "attached message", depth + 1);
|
|
6303
|
-
}
|
|
6304
|
-
}
|
|
6305
|
-
};
|
|
6306
|
-
await visit(attachments, "", 0);
|
|
6307
|
-
return all;
|
|
6308
|
-
}
|
|
6309
|
-
|
|
6310
6703
|
// src/score.js
|
|
6311
6704
|
var BAYES_SCALE = Math.log10(99) / 0.8;
|
|
6312
6705
|
var DEFAULT_SCORES = {
|
|
@@ -6523,7 +6916,7 @@ function scoreResults(results, options) {
|
|
|
6523
6916
|
var package_default = {
|
|
6524
6917
|
name: "spamscanner",
|
|
6525
6918
|
description: "Spam, phishing and malware filter for email in any language: a trainable classifier, link and attachment checks, SPF/DKIM/DMARC, and an optional AI second opinion from any LLM, including local Ollama models. Works as a library, CLI, Postfix milter or HTTP API.",
|
|
6526
|
-
version: "7.0.
|
|
6919
|
+
version: "7.0.5",
|
|
6527
6920
|
author: "Forward Email (https://forwardemail.net)",
|
|
6528
6921
|
bin: {
|
|
6529
6922
|
spamscanner: "./dist/esm/cli.js"
|
|
@@ -7127,9 +7520,198 @@ async function evaluate(classifier, examples) {
|
|
|
7127
7520
|
}
|
|
7128
7521
|
|
|
7129
7522
|
// src/milter.js
|
|
7130
|
-
import { Buffer as
|
|
7523
|
+
import { Buffer as Buffer11 } from "node:buffer";
|
|
7131
7524
|
import { EventEmitter } from "node:events";
|
|
7525
|
+
import net3 from "node:net";
|
|
7526
|
+
|
|
7527
|
+
// src/server.js
|
|
7528
|
+
import { Buffer as Buffer10 } from "node:buffer";
|
|
7529
|
+
import { timingSafeEqual } from "node:crypto";
|
|
7530
|
+
import http2 from "node:http";
|
|
7132
7531
|
import net2 from "node:net";
|
|
7532
|
+
var MAX_MESSAGE_SIZE = 25 * 1024 * 1024;
|
|
7533
|
+
var MAX_CONNECTIONS = 200;
|
|
7534
|
+
function serializeResult(result, options = {}) {
|
|
7535
|
+
const { mail, tokens, ...rest } = result;
|
|
7536
|
+
const output = {
|
|
7537
|
+
...rest,
|
|
7538
|
+
results: { ...result.results }
|
|
7539
|
+
};
|
|
7540
|
+
if (output.results.authentication) {
|
|
7541
|
+
const { receivedChain, ...auth } = output.results.authentication;
|
|
7542
|
+
output.results.authentication = auth;
|
|
7543
|
+
}
|
|
7544
|
+
if (options.verbose) {
|
|
7545
|
+
output.tokens = tokens;
|
|
7546
|
+
output.subject = mail?.subject ?? null;
|
|
7547
|
+
}
|
|
7548
|
+
return output;
|
|
7549
|
+
}
|
|
7550
|
+
function sessionFromQuery(query) {
|
|
7551
|
+
const session = {};
|
|
7552
|
+
if (query.get("ip")) {
|
|
7553
|
+
session.remoteAddress = query.get("ip");
|
|
7554
|
+
}
|
|
7555
|
+
if (query.get("hostname")) {
|
|
7556
|
+
session.resolvedClientHostname = query.get("hostname");
|
|
7557
|
+
}
|
|
7558
|
+
if (query.get("helo")) {
|
|
7559
|
+
session.helo = query.get("helo");
|
|
7560
|
+
}
|
|
7561
|
+
const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
7562
|
+
if (query.has("from") || recipients.length > 0) {
|
|
7563
|
+
session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
|
|
7564
|
+
}
|
|
7565
|
+
return session;
|
|
7566
|
+
}
|
|
7567
|
+
function sameToken(given, expected) {
|
|
7568
|
+
const a = Buffer10.from(String(given));
|
|
7569
|
+
const b = Buffer10.from(String(expected));
|
|
7570
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
7571
|
+
}
|
|
7572
|
+
function readBody(request, limit) {
|
|
7573
|
+
return new Promise((resolve, reject) => {
|
|
7574
|
+
const chunks = [];
|
|
7575
|
+
let size = 0;
|
|
7576
|
+
request.on("data", (chunk2) => {
|
|
7577
|
+
size += chunk2.length;
|
|
7578
|
+
if (size <= limit) {
|
|
7579
|
+
chunks.push(chunk2);
|
|
7580
|
+
}
|
|
7581
|
+
});
|
|
7582
|
+
request.on("end", () => {
|
|
7583
|
+
if (size > limit) {
|
|
7584
|
+
const error = new Error(`Message larger than ${limit} bytes`);
|
|
7585
|
+
error.statusCode = 413;
|
|
7586
|
+
reject(error);
|
|
7587
|
+
return;
|
|
7588
|
+
}
|
|
7589
|
+
resolve(Buffer10.concat(chunks));
|
|
7590
|
+
});
|
|
7591
|
+
request.on("error", reject);
|
|
7592
|
+
});
|
|
7593
|
+
}
|
|
7594
|
+
function createHttpServer(scanner, options = {}) {
|
|
7595
|
+
const {
|
|
7596
|
+
token = null,
|
|
7597
|
+
maxSize = MAX_MESSAGE_SIZE,
|
|
7598
|
+
modelPath = null,
|
|
7599
|
+
maxConnections = MAX_CONNECTIONS
|
|
7600
|
+
} = options;
|
|
7601
|
+
let active = 0;
|
|
7602
|
+
const server = http2.createServer(async (request, response) => {
|
|
7603
|
+
const send = (status, body, headers = {}) => {
|
|
7604
|
+
const isBuffer = Buffer10.isBuffer(body);
|
|
7605
|
+
response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
7606
|
+
response.end(isBuffer ? body : `${JSON.stringify(body)}
|
|
7607
|
+
`);
|
|
7608
|
+
};
|
|
7609
|
+
try {
|
|
7610
|
+
const url = new URL(request.url, "http://localhost");
|
|
7611
|
+
if (request.method === "GET" && url.pathname === "/health") {
|
|
7612
|
+
send(200, { ok: true, version: VERSION });
|
|
7613
|
+
return;
|
|
7614
|
+
}
|
|
7615
|
+
if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
|
|
7616
|
+
send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
|
|
7617
|
+
return;
|
|
7618
|
+
}
|
|
7619
|
+
if (request.method !== "POST") {
|
|
7620
|
+
send(405, { error: "Method not allowed" }, { allow: "POST" });
|
|
7621
|
+
return;
|
|
7622
|
+
}
|
|
7623
|
+
const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
|
|
7624
|
+
if (!["/scan", "/check"].includes(url.pathname) && !learn) {
|
|
7625
|
+
send(404, { error: "Not found" });
|
|
7626
|
+
return;
|
|
7627
|
+
}
|
|
7628
|
+
if (learn && !token) {
|
|
7629
|
+
send(403, { error: "Learning needs the server to be started with a token" });
|
|
7630
|
+
return;
|
|
7631
|
+
}
|
|
7632
|
+
const length = request.headers["content-length"];
|
|
7633
|
+
if (length !== void 0 && Number(length) > maxSize) {
|
|
7634
|
+
send(413, { error: `Message larger than ${maxSize} bytes` }, { connection: "close" });
|
|
7635
|
+
return;
|
|
7636
|
+
}
|
|
7637
|
+
if (active >= maxConnections) {
|
|
7638
|
+
send(503, { error: "Too many requests at once" }, { connection: "close", "retry-after": "1" });
|
|
7639
|
+
return;
|
|
7640
|
+
}
|
|
7641
|
+
active++;
|
|
7642
|
+
try {
|
|
7643
|
+
const raw = await readBody(request, maxSize);
|
|
7644
|
+
if (learn) {
|
|
7645
|
+
await scanner.learn(raw, learn[1]);
|
|
7646
|
+
if (modelPath) {
|
|
7647
|
+
scanner.saveModel(modelPath);
|
|
7648
|
+
}
|
|
7649
|
+
send(200, { ok: true, learned: learn[1] });
|
|
7650
|
+
return;
|
|
7651
|
+
}
|
|
7652
|
+
const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
|
|
7653
|
+
if (url.pathname === "/scan") {
|
|
7654
|
+
send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
|
|
7655
|
+
return;
|
|
7656
|
+
}
|
|
7657
|
+
const tag = url.searchParams.get("subjectTag");
|
|
7658
|
+
const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
|
|
7659
|
+
send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
|
|
7660
|
+
} finally {
|
|
7661
|
+
active--;
|
|
7662
|
+
}
|
|
7663
|
+
} catch (error) {
|
|
7664
|
+
send(error.statusCode || 500, { error: error.message });
|
|
7665
|
+
}
|
|
7666
|
+
});
|
|
7667
|
+
server.maxConnections = maxConnections;
|
|
7668
|
+
return server;
|
|
7669
|
+
}
|
|
7670
|
+
function createTcpServer(scanner, options = {}) {
|
|
7671
|
+
const {
|
|
7672
|
+
json = true,
|
|
7673
|
+
maxSize = MAX_MESSAGE_SIZE,
|
|
7674
|
+
timeout = 6e4,
|
|
7675
|
+
maxConnections = MAX_CONNECTIONS
|
|
7676
|
+
} = options;
|
|
7677
|
+
const server = net2.createServer({ allowHalfOpen: true }, (socket) => {
|
|
7678
|
+
const chunks = [];
|
|
7679
|
+
let size = 0;
|
|
7680
|
+
socket.setTimeout(timeout, () => socket.destroy());
|
|
7681
|
+
const onEnd = async () => {
|
|
7682
|
+
socket.setTimeout(0);
|
|
7683
|
+
try {
|
|
7684
|
+
const result = await scanner.scan(Buffer10.concat(chunks));
|
|
7685
|
+
socket.end(json ? `${JSON.stringify(serializeResult(result))}
|
|
7686
|
+
` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
|
|
7687
|
+
`);
|
|
7688
|
+
} catch (error) {
|
|
7689
|
+
socket.end(json ? `${JSON.stringify({ error: error.message })}
|
|
7690
|
+
` : `ERROR ${error.message}
|
|
7691
|
+
`);
|
|
7692
|
+
}
|
|
7693
|
+
};
|
|
7694
|
+
const onData = (chunk2) => {
|
|
7695
|
+
size += chunk2.length;
|
|
7696
|
+
if (size > maxSize) {
|
|
7697
|
+
socket.off("data", onData);
|
|
7698
|
+
socket.off("end", onEnd);
|
|
7699
|
+
socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
|
|
7700
|
+
`, () => socket.destroy());
|
|
7701
|
+
return;
|
|
7702
|
+
}
|
|
7703
|
+
chunks.push(chunk2);
|
|
7704
|
+
};
|
|
7705
|
+
socket.on("data", onData);
|
|
7706
|
+
socket.on("end", onEnd);
|
|
7707
|
+
socket.on("error", () => {
|
|
7708
|
+
});
|
|
7709
|
+
});
|
|
7710
|
+
server.maxConnections = maxConnections;
|
|
7711
|
+
return server;
|
|
7712
|
+
}
|
|
7713
|
+
|
|
7714
|
+
// src/milter.js
|
|
7133
7715
|
var COMMANDS = {
|
|
7134
7716
|
ABORT: "A",
|
|
7135
7717
|
BODY: "B",
|
|
@@ -7193,12 +7775,12 @@ var PROTOCOL = {
|
|
|
7193
7775
|
HDR_LEADSPC: 1048576
|
|
7194
7776
|
};
|
|
7195
7777
|
var MAX_PACKET = 1024 * 1024;
|
|
7196
|
-
function packet(command, data =
|
|
7197
|
-
const payload =
|
|
7198
|
-
const header =
|
|
7778
|
+
function packet(command, data = Buffer11.alloc(0)) {
|
|
7779
|
+
const payload = Buffer11.isBuffer(data) ? data : Buffer11.from(data, "latin1");
|
|
7780
|
+
const header = Buffer11.alloc(5);
|
|
7199
7781
|
header.writeUInt32BE(payload.length + 1);
|
|
7200
7782
|
header.write(command, 4, "latin1");
|
|
7201
|
-
return
|
|
7783
|
+
return Buffer11.concat([header, payload]);
|
|
7202
7784
|
}
|
|
7203
7785
|
function cstrings(data) {
|
|
7204
7786
|
const parts = data.toString("latin1").split("\0");
|
|
@@ -7208,7 +7790,7 @@ function cstrings(data) {
|
|
|
7208
7790
|
return parts;
|
|
7209
7791
|
}
|
|
7210
7792
|
function cstring(value) {
|
|
7211
|
-
return
|
|
7793
|
+
return Buffer11.from(`${value}\0`, "latin1");
|
|
7212
7794
|
}
|
|
7213
7795
|
function address(value) {
|
|
7214
7796
|
const text = String(value ?? "").trim();
|
|
@@ -7244,6 +7826,15 @@ var MilterSession = class {
|
|
|
7244
7826
|
this.body = [];
|
|
7245
7827
|
this.size = 0;
|
|
7246
7828
|
this.truncated = false;
|
|
7829
|
+
this.headerSize = 0;
|
|
7830
|
+
}
|
|
7831
|
+
// The envelope and headers are kept whole for the scan, so beyond the
|
|
7832
|
+
// message size limit the message is not scanned (see onError).
|
|
7833
|
+
keep(data) {
|
|
7834
|
+
this.headerSize += data.length;
|
|
7835
|
+
if (this.headerSize > this.server.options.maxSize) {
|
|
7836
|
+
throw new Error(`Message headers larger than ${this.server.options.maxSize} bytes`);
|
|
7837
|
+
}
|
|
7247
7838
|
}
|
|
7248
7839
|
send(command, data) {
|
|
7249
7840
|
if (!this.socket.destroyed) {
|
|
@@ -7256,7 +7847,7 @@ var MilterSession = class {
|
|
|
7256
7847
|
if (this.buffered < this.needed) {
|
|
7257
7848
|
return;
|
|
7258
7849
|
}
|
|
7259
|
-
let buffer = this.chunks.length === 1 ? this.chunks[0] :
|
|
7850
|
+
let buffer = this.chunks.length === 1 ? this.chunks[0] : Buffer11.concat(this.chunks);
|
|
7260
7851
|
this.needed = 4;
|
|
7261
7852
|
while (buffer.length >= 4) {
|
|
7262
7853
|
const length = buffer.readUInt32BE(0);
|
|
@@ -7301,7 +7892,7 @@ var MilterSession = class {
|
|
|
7301
7892
|
const protocol = data.readUInt32BE(8);
|
|
7302
7893
|
const wanted = ACTIONS.ADDHDRS | ACTIONS.CHGHDRS | ACTIONS.QUARANTINE;
|
|
7303
7894
|
this.leadingSpace = Boolean(protocol & PROTOCOL.HDR_LEADSPC);
|
|
7304
|
-
const reply2 =
|
|
7895
|
+
const reply2 = Buffer11.alloc(12);
|
|
7305
7896
|
reply2.writeUInt32BE(Math.min(version, 6), 0);
|
|
7306
7897
|
reply2.writeUInt32BE(actions & wanted, 4);
|
|
7307
7898
|
reply2.writeUInt32BE(protocol & (PROTOCOL.HDR_LEADSPC | PROTOCOL.NOUNKNOWN), 8);
|
|
@@ -7344,16 +7935,19 @@ var MilterSession = class {
|
|
|
7344
7935
|
}
|
|
7345
7936
|
case COMMANDS.MAIL: {
|
|
7346
7937
|
this.resetMessage();
|
|
7938
|
+
this.keep(data);
|
|
7347
7939
|
this.envelope.mailFrom = { address: address(cstrings(data)[0]) };
|
|
7348
7940
|
this.send(RESPONSES.CONTINUE);
|
|
7349
7941
|
break;
|
|
7350
7942
|
}
|
|
7351
7943
|
case COMMANDS.RCPT: {
|
|
7944
|
+
this.keep(data);
|
|
7352
7945
|
this.envelope.rcptTo.push({ address: address(cstrings(data)[0]) });
|
|
7353
7946
|
this.send(RESPONSES.CONTINUE);
|
|
7354
7947
|
break;
|
|
7355
7948
|
}
|
|
7356
7949
|
case COMMANDS.HEADER: {
|
|
7950
|
+
this.keep(data);
|
|
7357
7951
|
const [name, value = ""] = cstrings(data);
|
|
7358
7952
|
this.headers.push([name, value]);
|
|
7359
7953
|
this.send(RESPONSES.CONTINUE);
|
|
@@ -7363,14 +7957,19 @@ var MilterSession = class {
|
|
|
7363
7957
|
if (this.truncated || this.size + data.length > options.maxSize) {
|
|
7364
7958
|
this.truncated = true;
|
|
7365
7959
|
} else {
|
|
7366
|
-
this.body.push(
|
|
7960
|
+
this.body.push(Buffer11.from(data));
|
|
7367
7961
|
this.size += data.length;
|
|
7368
7962
|
}
|
|
7369
7963
|
this.send(RESPONSES.CONTINUE);
|
|
7370
7964
|
break;
|
|
7371
7965
|
}
|
|
7372
7966
|
case COMMANDS.BODYEOB: {
|
|
7373
|
-
|
|
7967
|
+
this.socket.pause();
|
|
7968
|
+
try {
|
|
7969
|
+
await this.endOfMessage();
|
|
7970
|
+
} finally {
|
|
7971
|
+
this.socket.resume();
|
|
7972
|
+
}
|
|
7374
7973
|
break;
|
|
7375
7974
|
}
|
|
7376
7975
|
case COMMANDS.ABORT: {
|
|
@@ -7393,7 +7992,7 @@ var MilterSession = class {
|
|
|
7393
7992
|
/** @returns {Buffer} */
|
|
7394
7993
|
rawMessage() {
|
|
7395
7994
|
const head = this.headers.map(([name, value]) => `${name}:${this.leadingSpace || value.startsWith(" ") || value.startsWith(" ") ? "" : " "}${value}`).join("\r\n");
|
|
7396
|
-
return
|
|
7995
|
+
return Buffer11.concat([Buffer11.from(this.headers.length > 0 ? `${head}\r
|
|
7397
7996
|
\r
|
|
7398
7997
|
` : "\r\n", "latin1"), ...this.body]);
|
|
7399
7998
|
}
|
|
@@ -7404,9 +8003,9 @@ var MilterSession = class {
|
|
|
7404
8003
|
const key = name.toLowerCase();
|
|
7405
8004
|
counts.set(key, (counts.get(key) || 0) + 1);
|
|
7406
8005
|
if (key.startsWith("x-spam-")) {
|
|
7407
|
-
const index =
|
|
8006
|
+
const index = Buffer11.alloc(4);
|
|
7408
8007
|
index.writeUInt32BE(counts.get(key));
|
|
7409
|
-
this.send(RESPONSES.CHGHEADER,
|
|
8008
|
+
this.send(RESPONSES.CHGHEADER, Buffer11.concat([index, cstring(name), cstring("")]));
|
|
7410
8009
|
}
|
|
7411
8010
|
}
|
|
7412
8011
|
}
|
|
@@ -7429,7 +8028,7 @@ var MilterSession = class {
|
|
|
7429
8028
|
}
|
|
7430
8029
|
this.server.report(error);
|
|
7431
8030
|
this.removeForgedHeaders();
|
|
7432
|
-
this.send(RESPONSES.ADDHEADER,
|
|
8031
|
+
this.send(RESPONSES.ADDHEADER, Buffer11.concat([cstring("X-Spam-Virus-Scan"), cstring(`${this.leadingSpace ? " " : ""}failed`)]));
|
|
7433
8032
|
this.send(RESPONSES.CONTINUE);
|
|
7434
8033
|
this.resetMessage();
|
|
7435
8034
|
return;
|
|
@@ -7450,15 +8049,15 @@ var MilterSession = class {
|
|
|
7450
8049
|
headers.unshift(["Authentication-Results", formatAuthResultsHeader(result.results.authentication, options.hostname)]);
|
|
7451
8050
|
}
|
|
7452
8051
|
for (const [name, value] of headers) {
|
|
7453
|
-
this.send(RESPONSES.ADDHEADER,
|
|
8052
|
+
this.send(RESPONSES.ADDHEADER, Buffer11.concat([cstring(name), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
|
|
7454
8053
|
}
|
|
7455
8054
|
if (result.isSpam && options.subjectTag) {
|
|
7456
8055
|
const subject = this.headers.find(([name]) => name.toLowerCase() === "subject");
|
|
7457
8056
|
const value = taggedSubject(options.subjectTag, subject ? subject[1].trim() : "", "\n");
|
|
7458
8057
|
if (value !== null) {
|
|
7459
|
-
const index =
|
|
8058
|
+
const index = Buffer11.alloc(4);
|
|
7460
8059
|
index.writeUInt32BE(1);
|
|
7461
|
-
this.send(RESPONSES.CHGHEADER,
|
|
8060
|
+
this.send(RESPONSES.CHGHEADER, Buffer11.concat([index, cstring("Subject"), cstring(`${this.leadingSpace ? " " : ""}${value}`.replaceAll("\r\n", "\n"))]));
|
|
7462
8061
|
}
|
|
7463
8062
|
}
|
|
7464
8063
|
if (result.action === "reject" && options.reject) {
|
|
@@ -7485,8 +8084,10 @@ var MilterServer = class extends EventEmitter {
|
|
|
7485
8084
|
* @param {boolean} [options.quarantine] - hold spam in the MTA's quarantine
|
|
7486
8085
|
* @param {string|null} [options.subjectTag] - e.g. "[SPAM]"
|
|
7487
8086
|
* @param {string} [options.hostname] - this server's name, for Authentication-Results
|
|
7488
|
-
* @param {number} [options.maxSize] - body bytes kept for scanning
|
|
8087
|
+
* @param {number} [options.maxSize] - body bytes kept for scanning (and the
|
|
8088
|
+
* most bytes of envelope and headers: beyond them a message is not scanned)
|
|
7489
8089
|
* @param {'tempfail'|'accept'} [options.onError] - answer when a scan fails
|
|
8090
|
+
* @param {number} [options.maxConnections] - connections kept open at once; more are closed at once
|
|
7490
8091
|
*/
|
|
7491
8092
|
constructor(scanner, options = {}) {
|
|
7492
8093
|
super();
|
|
@@ -7498,19 +8099,21 @@ var MilterServer = class extends EventEmitter {
|
|
|
7498
8099
|
quarantine: false,
|
|
7499
8100
|
subjectTag: null,
|
|
7500
8101
|
hostname: "spamscanner",
|
|
7501
|
-
maxSize:
|
|
8102
|
+
maxSize: MAX_MESSAGE_SIZE,
|
|
7502
8103
|
onError: "tempfail",
|
|
8104
|
+
maxConnections: MAX_CONNECTIONS,
|
|
7503
8105
|
...options
|
|
7504
8106
|
};
|
|
7505
8107
|
if (!/^[45]\d\d$/.test(String(this.options.rejectCode))) {
|
|
7506
8108
|
throw new RangeError(`rejectCode must be a 4xx or 5xx SMTP code, not ${this.options.rejectCode}`);
|
|
7507
8109
|
}
|
|
7508
8110
|
this.sockets = /* @__PURE__ */ new Set();
|
|
7509
|
-
this.server =
|
|
8111
|
+
this.server = net3.createServer((socket) => {
|
|
7510
8112
|
this.sockets.add(socket);
|
|
7511
8113
|
socket.on("close", () => this.sockets.delete(socket));
|
|
7512
8114
|
return new MilterSession(socket, this);
|
|
7513
8115
|
});
|
|
8116
|
+
this.server.maxConnections = this.options.maxConnections;
|
|
7514
8117
|
this.server.on("error", (error) => this.report(error));
|
|
7515
8118
|
}
|
|
7516
8119
|
// Errors go to "error" listeners; with none, a broken connection or failed
|
|
@@ -7556,162 +8159,6 @@ var MilterServer = class extends EventEmitter {
|
|
|
7556
8159
|
}
|
|
7557
8160
|
};
|
|
7558
8161
|
|
|
7559
|
-
// src/server.js
|
|
7560
|
-
import { Buffer as Buffer11 } from "node:buffer";
|
|
7561
|
-
import { timingSafeEqual } from "node:crypto";
|
|
7562
|
-
import http2 from "node:http";
|
|
7563
|
-
import net3 from "node:net";
|
|
7564
|
-
function serializeResult(result, options = {}) {
|
|
7565
|
-
const { mail, tokens, ...rest } = result;
|
|
7566
|
-
const output = {
|
|
7567
|
-
...rest,
|
|
7568
|
-
results: { ...result.results }
|
|
7569
|
-
};
|
|
7570
|
-
if (output.results.authentication) {
|
|
7571
|
-
const { receivedChain, ...auth } = output.results.authentication;
|
|
7572
|
-
output.results.authentication = auth;
|
|
7573
|
-
}
|
|
7574
|
-
if (options.verbose) {
|
|
7575
|
-
output.tokens = tokens;
|
|
7576
|
-
output.subject = mail?.subject ?? null;
|
|
7577
|
-
}
|
|
7578
|
-
return output;
|
|
7579
|
-
}
|
|
7580
|
-
function sessionFromQuery(query) {
|
|
7581
|
-
const session = {};
|
|
7582
|
-
if (query.get("ip")) {
|
|
7583
|
-
session.remoteAddress = query.get("ip");
|
|
7584
|
-
}
|
|
7585
|
-
if (query.get("hostname")) {
|
|
7586
|
-
session.resolvedClientHostname = query.get("hostname");
|
|
7587
|
-
}
|
|
7588
|
-
if (query.get("helo")) {
|
|
7589
|
-
session.helo = query.get("helo");
|
|
7590
|
-
}
|
|
7591
|
-
const recipients = query.getAll("to").flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
7592
|
-
if (query.has("from") || recipients.length > 0) {
|
|
7593
|
-
session.envelope = { mailFrom: { address: query.get("from") || "" }, rcptTo: recipients.map((address2) => ({ address: address2 })) };
|
|
7594
|
-
}
|
|
7595
|
-
return session;
|
|
7596
|
-
}
|
|
7597
|
-
function sameToken(given, expected) {
|
|
7598
|
-
const a = Buffer11.from(String(given));
|
|
7599
|
-
const b = Buffer11.from(String(expected));
|
|
7600
|
-
return a.length === b.length && timingSafeEqual(a, b);
|
|
7601
|
-
}
|
|
7602
|
-
function readBody(request, limit) {
|
|
7603
|
-
return new Promise((resolve, reject) => {
|
|
7604
|
-
const chunks = [];
|
|
7605
|
-
let size = 0;
|
|
7606
|
-
request.on("data", (chunk2) => {
|
|
7607
|
-
size += chunk2.length;
|
|
7608
|
-
if (size <= limit) {
|
|
7609
|
-
chunks.push(chunk2);
|
|
7610
|
-
}
|
|
7611
|
-
});
|
|
7612
|
-
request.on("end", () => {
|
|
7613
|
-
if (size > limit) {
|
|
7614
|
-
const error = new Error(`Message larger than ${limit} bytes`);
|
|
7615
|
-
error.statusCode = 413;
|
|
7616
|
-
reject(error);
|
|
7617
|
-
return;
|
|
7618
|
-
}
|
|
7619
|
-
resolve(Buffer11.concat(chunks));
|
|
7620
|
-
});
|
|
7621
|
-
request.on("error", reject);
|
|
7622
|
-
});
|
|
7623
|
-
}
|
|
7624
|
-
function createHttpServer(scanner, options = {}) {
|
|
7625
|
-
const { token = null, maxSize = 25 * 1024 * 1024, modelPath = null } = options;
|
|
7626
|
-
return http2.createServer(async (request, response) => {
|
|
7627
|
-
const send = (status, body, headers = {}) => {
|
|
7628
|
-
const isBuffer = Buffer11.isBuffer(body);
|
|
7629
|
-
response.writeHead(status, { "content-type": isBuffer ? "message/rfc822" : "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
|
|
7630
|
-
response.end(isBuffer ? body : `${JSON.stringify(body)}
|
|
7631
|
-
`);
|
|
7632
|
-
};
|
|
7633
|
-
try {
|
|
7634
|
-
const url = new URL(request.url, "http://localhost");
|
|
7635
|
-
if (request.method === "GET" && url.pathname === "/health") {
|
|
7636
|
-
send(200, { ok: true, version: VERSION });
|
|
7637
|
-
return;
|
|
7638
|
-
}
|
|
7639
|
-
if (token && !sameToken((request.headers.authorization || "").replace(/^bearer\s+/i, ""), token)) {
|
|
7640
|
-
send(401, { error: "Unauthorized" }, { "www-authenticate": "Bearer" });
|
|
7641
|
-
return;
|
|
7642
|
-
}
|
|
7643
|
-
if (request.method !== "POST") {
|
|
7644
|
-
send(405, { error: "Method not allowed" }, { allow: "POST" });
|
|
7645
|
-
return;
|
|
7646
|
-
}
|
|
7647
|
-
const learn = /^\/learn\/(spam|ham)$/.exec(url.pathname);
|
|
7648
|
-
if (!["/scan", "/check"].includes(url.pathname) && !learn) {
|
|
7649
|
-
send(404, { error: "Not found" });
|
|
7650
|
-
return;
|
|
7651
|
-
}
|
|
7652
|
-
if (learn && !token) {
|
|
7653
|
-
send(403, { error: "Learning needs the server to be started with a token" });
|
|
7654
|
-
return;
|
|
7655
|
-
}
|
|
7656
|
-
const raw = await readBody(request, maxSize);
|
|
7657
|
-
if (learn) {
|
|
7658
|
-
await scanner.learn(raw, learn[1]);
|
|
7659
|
-
if (modelPath) {
|
|
7660
|
-
scanner.saveModel(modelPath);
|
|
7661
|
-
}
|
|
7662
|
-
send(200, { ok: true, learned: learn[1] });
|
|
7663
|
-
return;
|
|
7664
|
-
}
|
|
7665
|
-
const result = await scanner.scan(raw, { session: sessionFromQuery(url.searchParams) });
|
|
7666
|
-
if (url.pathname === "/scan") {
|
|
7667
|
-
send(200, serializeResult(result, { verbose: url.searchParams.get("verbose") === "1" }));
|
|
7668
|
-
return;
|
|
7669
|
-
}
|
|
7670
|
-
const tag = url.searchParams.get("subjectTag");
|
|
7671
|
-
const rewritten = rewriteMessage(raw, spamHeaders(result, { version: VERSION }), { subjectTag: result.isSpam && tag ? tag : null });
|
|
7672
|
-
send(200, rewritten, { "x-spam-flag": result.isSpam ? "YES" : "NO", "x-spam-score": result.score.toFixed(1), "x-spam-action": result.action });
|
|
7673
|
-
} catch (error) {
|
|
7674
|
-
send(error.statusCode || 500, { error: error.message });
|
|
7675
|
-
}
|
|
7676
|
-
});
|
|
7677
|
-
}
|
|
7678
|
-
function createTcpServer(scanner, options = {}) {
|
|
7679
|
-
const { json = true, maxSize = 25 * 1024 * 1024, timeout = 6e4 } = options;
|
|
7680
|
-
return net3.createServer({ allowHalfOpen: true }, (socket) => {
|
|
7681
|
-
const chunks = [];
|
|
7682
|
-
let size = 0;
|
|
7683
|
-
socket.setTimeout(timeout, () => socket.destroy());
|
|
7684
|
-
const onEnd = async () => {
|
|
7685
|
-
socket.setTimeout(0);
|
|
7686
|
-
try {
|
|
7687
|
-
const result = await scanner.scan(Buffer11.concat(chunks));
|
|
7688
|
-
socket.end(json ? `${JSON.stringify(serializeResult(result))}
|
|
7689
|
-
` : `${result.isSpam ? "SPAM" : "HAM"} ${result.score.toFixed(1)}/${result.threshold.toFixed(1)} ${result.tests.map((test) => test.name).join(",")}
|
|
7690
|
-
`);
|
|
7691
|
-
} catch (error) {
|
|
7692
|
-
socket.end(json ? `${JSON.stringify({ error: error.message })}
|
|
7693
|
-
` : `ERROR ${error.message}
|
|
7694
|
-
`);
|
|
7695
|
-
}
|
|
7696
|
-
};
|
|
7697
|
-
const onData = (chunk2) => {
|
|
7698
|
-
size += chunk2.length;
|
|
7699
|
-
if (size > maxSize) {
|
|
7700
|
-
socket.off("data", onData);
|
|
7701
|
-
socket.off("end", onEnd);
|
|
7702
|
-
socket.end(`${JSON.stringify({ error: `Message larger than ${maxSize} bytes` })}
|
|
7703
|
-
`, () => socket.destroy());
|
|
7704
|
-
return;
|
|
7705
|
-
}
|
|
7706
|
-
chunks.push(chunk2);
|
|
7707
|
-
};
|
|
7708
|
-
socket.on("data", onData);
|
|
7709
|
-
socket.on("end", onEnd);
|
|
7710
|
-
socket.on("error", () => {
|
|
7711
|
-
});
|
|
7712
|
-
});
|
|
7713
|
-
}
|
|
7714
|
-
|
|
7715
8162
|
// src/spamd.js
|
|
7716
8163
|
import { Buffer as Buffer12 } from "node:buffer";
|
|
7717
8164
|
import net4 from "node:net";
|
|
@@ -7807,14 +8254,15 @@ async function answer(scanner, request, message, options) {
|
|
|
7807
8254
|
}
|
|
7808
8255
|
function createSpamdServer(scanner, options = {}) {
|
|
7809
8256
|
const settings = {
|
|
7810
|
-
maxSize:
|
|
8257
|
+
maxSize: MAX_MESSAGE_SIZE,
|
|
7811
8258
|
allowTell: false,
|
|
7812
8259
|
modelPath: null,
|
|
7813
8260
|
subjectTag: null,
|
|
7814
8261
|
timeout: 6e4,
|
|
8262
|
+
maxConnections: MAX_CONNECTIONS,
|
|
7815
8263
|
...options
|
|
7816
8264
|
};
|
|
7817
|
-
|
|
8265
|
+
const server = net4.createServer({ allowHalfOpen: true }, (socket) => {
|
|
7818
8266
|
let head = Buffer12.alloc(0);
|
|
7819
8267
|
const chunks = [];
|
|
7820
8268
|
let size = 0;
|
|
@@ -7896,6 +8344,8 @@ function createSpamdServer(scanner, options = {}) {
|
|
|
7896
8344
|
socket.on("error", () => {
|
|
7897
8345
|
});
|
|
7898
8346
|
});
|
|
8347
|
+
server.maxConnections = settings.maxConnections;
|
|
8348
|
+
return server;
|
|
7899
8349
|
}
|
|
7900
8350
|
|
|
7901
8351
|
// src/models.js
|
|
@@ -8472,6 +8922,7 @@ var SpamScanner = class {
|
|
|
8472
8922
|
const settings = config.clamav === true ? {} : config.clamav;
|
|
8473
8923
|
const budget = settings.timeout ?? CLAMAV_TIMEOUT;
|
|
8474
8924
|
const deadline = Date.now() + budget;
|
|
8925
|
+
let expired = false;
|
|
8475
8926
|
const viruses = [];
|
|
8476
8927
|
const errors = [];
|
|
8477
8928
|
const skipped = [];
|
|
@@ -8491,7 +8942,7 @@ var SpamScanner = class {
|
|
|
8491
8942
|
while (queue.length > 0) {
|
|
8492
8943
|
const attachment = queue.shift();
|
|
8493
8944
|
const filename = attachment.filename || "unnamed attachment";
|
|
8494
|
-
if (deadline <= Date.now()) {
|
|
8945
|
+
if (expired || deadline <= Date.now()) {
|
|
8495
8946
|
errors.push({ filename, error: `not scanned within ${budget} ms` });
|
|
8496
8947
|
continue;
|
|
8497
8948
|
}
|
|
@@ -8511,6 +8962,7 @@ var SpamScanner = class {
|
|
|
8511
8962
|
}
|
|
8512
8963
|
} catch (error) {
|
|
8513
8964
|
debug2("virus scan failed: %s", error.message);
|
|
8965
|
+
expired ||= Boolean(error.timedOut);
|
|
8514
8966
|
if (CLAMAV_LIMITS.test(error.message)) {
|
|
8515
8967
|
skipped.push({ filename, reason: error.message.replace(/^clamd: (?:stream: )?/, "") });
|
|
8516
8968
|
} else {
|