@blamejs/core 0.18.53 → 0.18.55
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -0
- package/NOTICE +1 -1
- package/README.md +5 -5
- package/lib/agent-audit.js +27 -2
- package/lib/ai-adverse-decision.js +18 -2
- package/lib/audit-sign.js +24 -5
- package/lib/auth/passkey.js +4 -1
- package/lib/codepoint-class.js +72 -0
- package/lib/cookies.js +7 -10
- package/lib/credential-hash.js +8 -1
- package/lib/crypto.js +7 -5
- package/lib/db-file-lifecycle.js +14 -3
- package/lib/db.js +505 -49
- package/lib/guard-auth.js +34 -11
- package/lib/guard-filename.js +41 -33
- package/lib/guard-html.js +10 -2
- package/lib/guard-list-unsubscribe.js +6 -1
- package/lib/guard-managesieve-command.js +73 -12
- package/lib/guard-regex.js +3 -5
- package/lib/guard-smtp-command.js +20 -4
- package/lib/guard-svg.js +6 -1
- package/lib/guard-yaml.js +60 -15
- package/lib/http-client.js +17 -3
- package/lib/mail-agent.js +29 -13
- package/lib/mail-arc-sign.js +40 -7
- package/lib/mail-auth.js +134 -22
- package/lib/mail-crypto-pgp.js +1 -1
- package/lib/mail-dkim.js +80 -11
- package/lib/mail-helo.js +10 -0
- package/lib/mail-rbl.js +10 -3
- package/lib/mail-send-deliver.js +151 -32
- package/lib/mail-server-imap.js +186 -89
- package/lib/mail-server-jmap.js +31 -4
- package/lib/mail-server-managesieve.js +198 -42
- package/lib/mail-server-mx.js +191 -38
- package/lib/mail-server-net.js +281 -1
- package/lib/mail-server-pop3.js +89 -41
- package/lib/mail-server-rate-limit.js +104 -6
- package/lib/mail-server-submission.js +183 -35
- package/lib/mail-server-tls.js +48 -3
- package/lib/mail-store.js +33 -11
- package/lib/mail.js +355 -17
- package/lib/mcp.js +11 -3
- package/lib/middleware/bearer-auth.js +6 -1
- package/lib/middleware/fetch-metadata.js +5 -1
- package/lib/middleware/headers.js +7 -10
- package/lib/middleware/require-mtls.js +8 -1
- package/lib/network-dns-resolver.js +71 -8
- package/lib/network-dns.js +26 -0
- package/lib/network-smtp-policy.js +42 -10
- package/lib/network-tls.js +18 -0
- package/lib/redact.js +13 -3
- package/lib/retention.js +22 -2
- package/lib/safe-mount-info.js +39 -6
- package/lib/safe-smtp.js +96 -1
- package/lib/safe-url.js +8 -2
- package/lib/self-update.js +4 -1
- package/lib/vendor/MANIFEST.json +12 -12
- package/lib/vendor/blamejs-pki.cjs +672 -75
- package/lib/watcher.js +31 -6
- package/lib/ws-client.js +17 -2
- package/lib/yaml-lex.js +55 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/mail-server-net.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// Copyright (c) blamejs contributors
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
|
+
var codepointClass = require("./codepoint-class");
|
|
6
|
+
var bCrypto = require("./crypto");
|
|
7
|
+
|
|
5
8
|
// mail-server-net — the TCP-listener lifecycle shared by the mailbox / transfer
|
|
6
9
|
// servers (b.mail.server.imap / pop3 / mx / managesieve / submission). Each of
|
|
7
10
|
// those keeps its OWN connection set and close() drain because those diverge:
|
|
@@ -14,6 +17,14 @@
|
|
|
14
17
|
// EACCES) rejects the listen promise instead of crashing the process. That, plus
|
|
15
18
|
// the listening/server state, is what createTcpListener owns.
|
|
16
19
|
|
|
20
|
+
// The listener's own ceiling when an operator names none. Every mail listener
|
|
21
|
+
// shares it, so a deployment that raises or lowers it does so in one place and
|
|
22
|
+
// the five listeners cannot drift apart on what "too many" means. Set well
|
|
23
|
+
// above any single-host mail deployment's working set and far below what an
|
|
24
|
+
// unbounded accept loop will take: the point is that a ceiling EXISTS, since
|
|
25
|
+
// the per-address cap alone leaves the total at (cap x source addresses).
|
|
26
|
+
var DEFAULT_MAX_CONNECTIONS = 1024;
|
|
27
|
+
|
|
17
28
|
// createTcpListener(net, cfg) — build a listener lifecycle.
|
|
18
29
|
// cfg.defaultPort port used when listenOpts.port is omitted (an explicit
|
|
19
30
|
// 0 is honored, for an ephemeral test bind).
|
|
@@ -24,6 +35,15 @@
|
|
|
24
35
|
// cfg.listeningEvent the "...listening" audit action.
|
|
25
36
|
// cfg.listeningExtra optional () => object merged onto the listening event
|
|
26
37
|
// payload (Submission reports implicitTls).
|
|
38
|
+
// cfg.maxConnections the listener's own ceiling on concurrently accepted
|
|
39
|
+
// sockets. The per-address cap in b.mail.server.rateLimit
|
|
40
|
+
// bounds ONE peer; it says nothing about how many peers
|
|
41
|
+
// there are, so the process-wide total was the per-address
|
|
42
|
+
// cap times however many source addresses the caller could
|
|
43
|
+
// speak from — a number a botnet, a NAT pool or a single
|
|
44
|
+
// v6 /64 makes large. Enforced by the runtime, which closes
|
|
45
|
+
// the excess socket before handleConnection ever sees it,
|
|
46
|
+
// so a refusal costs no descriptor and no state machine.
|
|
27
47
|
// Returns { listen, getServer, isListening, markClosed } — the server wires its
|
|
28
48
|
// own close() through getServer()/isListening()/markClosed().
|
|
29
49
|
function createTcpListener(net, cfg) {
|
|
@@ -38,6 +58,7 @@ function createTcpListener(net, cfg) {
|
|
|
38
58
|
var port = listenOpts.port === undefined ? cfg.defaultPort : listenOpts.port;
|
|
39
59
|
var address = listenOpts.address || "0.0.0.0";
|
|
40
60
|
server = net.createServer(function (socket) { cfg.handleConnection(socket); });
|
|
61
|
+
server.maxConnections = cfg.maxConnections || DEFAULT_MAX_CONNECTIONS;
|
|
41
62
|
return new Promise(function (resolve, reject) {
|
|
42
63
|
server.once("error", reject);
|
|
43
64
|
server.listen(port, address, function () {
|
|
@@ -108,6 +129,7 @@ function createStoreServer(net, cfg) {
|
|
|
108
129
|
var ErrorClass = cfg.errorClass;
|
|
109
130
|
var listener = createTcpListener(net, {
|
|
110
131
|
defaultPort: cfg.defaultPort,
|
|
132
|
+
maxConnections: cfg.maxConnections,
|
|
111
133
|
handleConnection: cfg.handleConnection,
|
|
112
134
|
errorFactory: function (code, message) { return new ErrorClass(cfg.errorCodePrefix + code, message); },
|
|
113
135
|
emit: cfg.emit,
|
|
@@ -120,7 +142,137 @@ function createStoreServer(net, cfg) {
|
|
|
120
142
|
closedEvent: cfg.eventBase + ".closed",
|
|
121
143
|
});
|
|
122
144
|
}
|
|
123
|
-
return {
|
|
145
|
+
return {
|
|
146
|
+
listen: listener.listen,
|
|
147
|
+
close: close,
|
|
148
|
+
connectionCount: function () { return cfg.connections.size; },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// trackConnection(socket, cfg) — the other half of admitConnection, and the
|
|
153
|
+
// reason it is here rather than written out per listener.
|
|
154
|
+
//
|
|
155
|
+
// A connection occupies two ledgers: the rate limiter's per-address count, and
|
|
156
|
+
// the listener's live-socket set that shutdown drains. Both are released by the
|
|
157
|
+
// SAME event — the socket closing — and it does not matter who closed it. Five
|
|
158
|
+
// listeners each wrote that pairing by hand and one of them registered only the
|
|
159
|
+
// release, so a peer that opened a connection, took the greeting and dropped TCP
|
|
160
|
+
// freed its rate-limit slot (and could reconnect at once) while its set entry
|
|
161
|
+
// stayed forever. Nothing authenticates before that point, so the growth was
|
|
162
|
+
// unauthenticated and unbounded.
|
|
163
|
+
//
|
|
164
|
+
// Registering both in one handler is what makes the pair impossible to
|
|
165
|
+
// half-write.
|
|
166
|
+
// cfg.connections the listener's live-socket Set.
|
|
167
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
168
|
+
// cfg.remoteAddress the address admitConnection returned.
|
|
169
|
+
// cfg.closeSource optional socket whose "close" drives teardown, when the
|
|
170
|
+
// tracked socket is a wrapper (Submission's implicit-TLS
|
|
171
|
+
// path tracks the TLSSocket but the raw socket is the one
|
|
172
|
+
// that carries the FIN).
|
|
173
|
+
function trackConnection(socket, cfg) {
|
|
174
|
+
cfg.connections.add(socket);
|
|
175
|
+
var source = cfg.closeSource || socket;
|
|
176
|
+
source.once("close", function () {
|
|
177
|
+
cfg.rateLimit.releaseConnection(cfg.remoteAddress);
|
|
178
|
+
cfg.connections.delete(socket);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// createBodyRateWindow(rateLimit) — the body-rate floor, measured over BOUNDED
|
|
183
|
+
// windows instead of the whole transfer.
|
|
184
|
+
//
|
|
185
|
+
// A lifetime average lets an early burst pay for an arbitrarily slow tail: at
|
|
186
|
+
// the default 100 B/s an 8 MiB burst buys about a day of credit and 50 MiB buys
|
|
187
|
+
// six, which a peer spends holding a connection and its slot in the per-address
|
|
188
|
+
// cap while sending a byte at a time. The floor was enforced and still
|
|
189
|
+
// bypassable, which is the worse of the two states — it reads as covered.
|
|
190
|
+
//
|
|
191
|
+
// Each window has to meet the floor on its own, so nothing sent earlier pays
|
|
192
|
+
// for what is sent now. A window only rolls forward once it has run long enough
|
|
193
|
+
// to be a real judgement; inside that stretch no verdict is reached, so a
|
|
194
|
+
// sender pausing to read from its own spool is not cut off for the pause, and a
|
|
195
|
+
// fast chunk cannot keep resetting the clock to dodge the next measurement.
|
|
196
|
+
//
|
|
197
|
+
// `now` is a parameter rather than a call to the clock so the whole thing can
|
|
198
|
+
// be driven across days of simulated time without waiting for them.
|
|
199
|
+
// rateLimit the resolved b.mail.server.rateLimit (owns the floor + grace).
|
|
200
|
+
function createBodyRateWindow(rateLimit) {
|
|
201
|
+
var windowStart = 0;
|
|
202
|
+
var windowBytes = 0;
|
|
203
|
+
return {
|
|
204
|
+
// Open the first window. `bytesSeen` is the caller's running total AT THIS
|
|
205
|
+
// MOMENT, which becomes the baseline every later reading is measured
|
|
206
|
+
// against — so the caller is free to keep one connection-lifetime counter
|
|
207
|
+
// incremented at the wire boundary rather than a per-transfer one. That
|
|
208
|
+
// matters where a listener re-feeds part of a chunk through its own parser:
|
|
209
|
+
// a counter maintained inside the parser credits those bytes twice, and a
|
|
210
|
+
// peer that pipelines a one-byte payload with the next command gets roughly
|
|
211
|
+
// double the rate it is actually sending.
|
|
212
|
+
start: function (now, bytesSeen) {
|
|
213
|
+
windowStart = now;
|
|
214
|
+
windowBytes = bytesSeen || 0;
|
|
215
|
+
},
|
|
216
|
+
// `bytesSeen` is the running total for the whole body; the window's own
|
|
217
|
+
// count is derived, so the caller keeps one counter rather than two.
|
|
218
|
+
starved: function (bytesSeen, now) {
|
|
219
|
+
var elapsed = now - windowStart;
|
|
220
|
+
if (rateLimit.bodyRateStarved(bytesSeen - windowBytes, elapsed)) return true;
|
|
221
|
+
// The limiter says how long a window must run before rolling, because the
|
|
222
|
+
// limiter is what decides when a measurement is old enough to mean
|
|
223
|
+
// something. Rolling on a number held HERE would ask a limiter that
|
|
224
|
+
// judges over a longer stretch only ever before it can answer: every call
|
|
225
|
+
// returning "too early", the window resetting underneath it, and its rate
|
|
226
|
+
// protection silently disabled while looking wired.
|
|
227
|
+
if (elapsed >= rateLimit.bodyRateWindowMs()) {
|
|
228
|
+
windowStart = now;
|
|
229
|
+
windowBytes = bytesSeen;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// acceptConnection(rawSocket, cfg) — everything a listener does between the TCP
|
|
237
|
+
// accept and its own state machine: gate the address, mint the connection id,
|
|
238
|
+
// wrap the socket if the protocol starts in TLS, and enter both ledgers.
|
|
239
|
+
//
|
|
240
|
+
// Every listener performed these four steps in the same order, and the parts
|
|
241
|
+
// that differ between them are three strings and an optional wrap. Keeping the
|
|
242
|
+
// order in one place is what stops a fifth listener from being written with one
|
|
243
|
+
// of the steps missing, which is how the tracking-set entry and the rate-limit
|
|
244
|
+
// slot came apart in the first place.
|
|
245
|
+
//
|
|
246
|
+
// Returns null when the address was refused — the caller returns immediately;
|
|
247
|
+
// the refusal line and teardown are already done.
|
|
248
|
+
// cfg.rateLimit the resolved b.mail.server.rateLimit.
|
|
249
|
+
// cfg.connections the listener's live-socket Set.
|
|
250
|
+
// cfg.emit the listener's audit emitter.
|
|
251
|
+
// cfg.refusedEvent the "<...>.rate_limit_refused" audit action.
|
|
252
|
+
// cfg.refusalLine the protocol's refusal bytes.
|
|
253
|
+
// cfg.idPrefix "imapconn-" / "mxconn-" / … prefix for the connection id.
|
|
254
|
+
// cfg.wrap optional (rawSocket) => socket, for a protocol that
|
|
255
|
+
// starts inside TLS. The RAW socket still drives teardown:
|
|
256
|
+
// a handshake that never completes closes it without the
|
|
257
|
+
// wrapper ever being established.
|
|
258
|
+
function acceptConnection(rawSocket, cfg) {
|
|
259
|
+
var remoteAddress = admitConnection(rawSocket, cfg.rateLimit, cfg.emit, {
|
|
260
|
+
refusedEvent: cfg.refusedEvent,
|
|
261
|
+
refusalLine: cfg.refusalLine,
|
|
262
|
+
});
|
|
263
|
+
if (remoteAddress === null) return null;
|
|
264
|
+
var socket = cfg.wrap ? cfg.wrap(rawSocket) : rawSocket;
|
|
265
|
+
trackConnection(socket, {
|
|
266
|
+
connections: cfg.connections,
|
|
267
|
+
rateLimit: cfg.rateLimit,
|
|
268
|
+
remoteAddress: remoteAddress,
|
|
269
|
+
closeSource: socket === rawSocket ? null : rawSocket,
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
socket: socket,
|
|
273
|
+
remoteAddress: remoteAddress,
|
|
274
|
+
connectionId: cfg.idPrefix + bCrypto.generateToken(8), // connection-id length
|
|
275
|
+
};
|
|
124
276
|
}
|
|
125
277
|
|
|
126
278
|
// admitConnection(socket, rateLimit, emit, cfg) — the per-connection rate-limit
|
|
@@ -171,9 +323,137 @@ function validateDomainHardened(d, label, cfg) {
|
|
|
171
323
|
return verdict;
|
|
172
324
|
}
|
|
173
325
|
|
|
326
|
+
// saslChallengeOrNull(challenge) — the operator's SASL challenge, checked for
|
|
327
|
+
// the bytes that would end the line it is written on.
|
|
328
|
+
//
|
|
329
|
+
// Every listener that supports a multi-step SASL exchange writes this value
|
|
330
|
+
// straight to the wire, and it is not always the operator's own text: a SCRAM
|
|
331
|
+
// or CRAM mechanism composes its challenge from the client's nonce, so client
|
|
332
|
+
// bytes reach this line. A CR, LF or NUL in it terminates the server's reply
|
|
333
|
+
// early and the remainder is read by the client as a second protocol line —
|
|
334
|
+
// the same injection class the outbound SMTP transport refuses at config time
|
|
335
|
+
// (GHSA-c7w3-x93f-qmm8). Returns null when the challenge cannot be written
|
|
336
|
+
// safely; the caller fails the exchange rather than emitting a smuggled line.
|
|
337
|
+
function saslChallengeOrNull(challenge) {
|
|
338
|
+
if (typeof challenge !== "string") return null;
|
|
339
|
+
return codepointClass.firstLineInjectionCharOffset(challenge) === -1 ? challenge : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// replyTextOrFallback(text, fallback) — operator-supplied prose, made safe to
|
|
343
|
+
// write into a line-oriented protocol reply.
|
|
344
|
+
//
|
|
345
|
+
// A refusal reason is the common case, and it is rarely the operator's own
|
|
346
|
+
// words: a directory wrapper answers "No such user: <address>", and the address
|
|
347
|
+
// came from the peer. A CR or LF in it ends the reply line early and everything
|
|
348
|
+
// after is read by the peer as a second server response, so a `550` refusal can
|
|
349
|
+
// carry a forged `250` acceptance.
|
|
350
|
+
//
|
|
351
|
+
// Unlike a SASL challenge, a refusal must still happen: dropping the whole
|
|
352
|
+
// reply would turn an injection attempt into a hang. So the unsafe text is
|
|
353
|
+
// replaced by the caller's fallback and the refusal is delivered.
|
|
354
|
+
function replyTextOrFallback(text, fallback) {
|
|
355
|
+
if (typeof text !== "string" || text.length === 0) return fallback;
|
|
356
|
+
return codepointClass.firstLineInjectionCharOffset(text) === -1 ? text : fallback;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// runSaslStep(cfg) — one round of a multi-step SASL exchange.
|
|
360
|
+
//
|
|
361
|
+
// IMAP, POP3, ManageSieve and submission all run the same loop: call the
|
|
362
|
+
// operator's verifier with the current step and the client's latest response,
|
|
363
|
+
// then either write a challenge and wait, complete the authentication, or fail
|
|
364
|
+
// it. Only the wire syntax of each outcome differs, so the loop lives here once
|
|
365
|
+
// and each listener supplies the four writers. Keeping it in one place is what
|
|
366
|
+
// stops the listeners drifting apart again — the whole reason POP3 and
|
|
367
|
+
// ManageSieve did not honour `pending` was that each had its own copy.
|
|
368
|
+
//
|
|
369
|
+
// cfg.exchange { mech, step } — mutated: step increments each round.
|
|
370
|
+
// cfg.verify the operator's verify(mechanism, credentials).
|
|
371
|
+
// cfg.credentials extra fields merged into the credentials object
|
|
372
|
+
// (tls, remoteAddress).
|
|
373
|
+
// cfg.clientResponse this round's client response, or null.
|
|
374
|
+
// cfg.writeChallenge (challenge) => boolean — write it, false if unsafe.
|
|
375
|
+
// cfg.onChallengeUnsafe () => void — the challenge could not be written.
|
|
376
|
+
// cfg.onSuccess (result) => void — verify returned { ok, actor }.
|
|
377
|
+
// cfg.onFailure (result) => void — verify declined.
|
|
378
|
+
// cfg.onError (err) => void — verify threw.
|
|
379
|
+
//
|
|
380
|
+
// A `pending` verdict is NOT a failure and none of the failure paths run for
|
|
381
|
+
// it: it is a normal protocol round trip, and charging it against an
|
|
382
|
+
// authentication-failure budget would spend the defence that budget exists to
|
|
383
|
+
// provide.
|
|
384
|
+
function runSaslStep(cfg) {
|
|
385
|
+
var ex = cfg.exchange;
|
|
386
|
+
// A client can put several lines in one TCP segment, and a listener's drain
|
|
387
|
+
// loop dispatches them without awaiting. Two SASL responses arriving together
|
|
388
|
+
// therefore each started a verifier call at the SAME `ex.step`: concurrent
|
|
389
|
+
// rounds, results landing out of order, and a later response able to complete
|
|
390
|
+
// authentication before the challenge for it had been issued.
|
|
391
|
+
//
|
|
392
|
+
// A client that answers before it has been asked is not following the
|
|
393
|
+
// protocol, so the second response fails the exchange rather than queueing:
|
|
394
|
+
// queueing would preserve the ordering but still credit a response the server
|
|
395
|
+
// never solicited.
|
|
396
|
+
// Once a pipelining violation has been reported the exchange is DEAD, and
|
|
397
|
+
// stays dead. A listener is expected to tear the connection down, but if it
|
|
398
|
+
// does not, a resumed exchange must not become authenticable just because the
|
|
399
|
+
// violation has scrolled past.
|
|
400
|
+
if (ex.abandoned) {
|
|
401
|
+
cfg.onFailure({ reason: "pipelined-sasl-response" });
|
|
402
|
+
return Promise.resolve();
|
|
403
|
+
}
|
|
404
|
+
if (ex.inFlight) {
|
|
405
|
+
// The round already in flight is ABANDONED, not merely reported. Its
|
|
406
|
+
// verifier has already been called and can still resolve `{ ok: true }`,
|
|
407
|
+
// and invoking onSuccess then authenticates the connection whose pipelined
|
|
408
|
+
// response was just refused — so reporting the refusal would have decided
|
|
409
|
+
// nothing. Every completion below checks this before calling back.
|
|
410
|
+
ex.abandoned = true;
|
|
411
|
+
cfg.onFailure({ reason: "pipelined-sasl-response" });
|
|
412
|
+
return Promise.resolve();
|
|
413
|
+
}
|
|
414
|
+
ex.inFlight = true;
|
|
415
|
+
return Promise.resolve()
|
|
416
|
+
.then(function () {
|
|
417
|
+
var creds = { step: ex.step, clientResponse: cfg.clientResponse };
|
|
418
|
+
if (cfg.credentials) {
|
|
419
|
+
Object.keys(cfg.credentials).forEach(function (k) { creds[k] = cfg.credentials[k]; });
|
|
420
|
+
}
|
|
421
|
+
return cfg.verify(ex.mech, creds);
|
|
422
|
+
})
|
|
423
|
+
.then(function (result) {
|
|
424
|
+
// Cleared before the callbacks run, so the next round — which a
|
|
425
|
+
// challenge invites — is free to start.
|
|
426
|
+
ex.inFlight = false;
|
|
427
|
+
// Abandoned while this was in flight: the listener has already answered
|
|
428
|
+
// the pipelining violation, and a second verdict on a dead exchange is
|
|
429
|
+
// the bypass this guard exists to close.
|
|
430
|
+
if (ex.abandoned) return;
|
|
431
|
+
ex.step += 1;
|
|
432
|
+
if (result && result.pending && typeof result.challenge === "string") {
|
|
433
|
+
if (cfg.writeChallenge(result.challenge)) return;
|
|
434
|
+
cfg.onChallengeUnsafe();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (result && result.ok === true && result.actor) { cfg.onSuccess(result); return; }
|
|
438
|
+
cfg.onFailure(result);
|
|
439
|
+
})
|
|
440
|
+
.catch(function (err) {
|
|
441
|
+
ex.inFlight = false;
|
|
442
|
+
if (ex.abandoned) return; // same reason as the resolve path
|
|
443
|
+
cfg.onError(err);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
174
447
|
module.exports = {
|
|
175
448
|
createTcpListener: createTcpListener,
|
|
449
|
+
runSaslStep: runSaslStep,
|
|
176
450
|
createStoreServer: createStoreServer,
|
|
177
451
|
admitConnection: admitConnection,
|
|
452
|
+
trackConnection: trackConnection,
|
|
453
|
+
acceptConnection: acceptConnection,
|
|
454
|
+
createBodyRateWindow: createBodyRateWindow,
|
|
455
|
+
DEFAULT_MAX_CONNECTIONS: DEFAULT_MAX_CONNECTIONS,
|
|
178
456
|
validateDomainHardened: validateDomainHardened,
|
|
457
|
+
saslChallengeOrNull: saslChallengeOrNull,
|
|
458
|
+
replyTextOrFallback: replyTextOrFallback,
|
|
179
459
|
};
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -52,9 +52,10 @@
|
|
|
52
52
|
* `authFailuresPerIpPer15Min` cap applies to USER+PASS / APOP /
|
|
53
53
|
* AUTH refusals.
|
|
54
54
|
*
|
|
55
|
-
* - **Slow-loris
|
|
56
|
-
*
|
|
57
|
-
*
|
|
55
|
+
* - **Slow-loris** — per-connection `idleTimeoutMs` bounds a peer that
|
|
56
|
+
* stops making progress, in either direction: a client that sends no
|
|
57
|
+
* further command, and one that stops taking a RETR / TOP response.
|
|
58
|
+
* `maxLineBytes` bounds a command line that never ends.
|
|
58
59
|
*
|
|
59
60
|
* ## Audit lifecycle
|
|
60
61
|
*
|
|
@@ -107,7 +108,6 @@
|
|
|
107
108
|
var net = require("node:net");
|
|
108
109
|
var safeBuffer = require("./safe-buffer");
|
|
109
110
|
var C = require("./constants");
|
|
110
|
-
var bCrypto = require("./crypto");
|
|
111
111
|
var numericBounds = require("./numeric-bounds");
|
|
112
112
|
var validateOpts = require("./validate-opts");
|
|
113
113
|
var guardPop3Command = require("./guard-pop3-command");
|
|
@@ -152,6 +152,7 @@ var ERR_CLAMP = 200;
|
|
|
152
152
|
* greeting: string, // default "blamejs POP3"
|
|
153
153
|
* maxLineBytes: number, // default 1024
|
|
154
154
|
* idleTimeoutMs: number, // default 10 min
|
|
155
|
+
* maxConnections: number, // default 1024 — listener-wide ceiling
|
|
155
156
|
* commitTimeoutMs: number, // default 30 s (UPDATE-state mailStore.commitPop3Drop cap)
|
|
156
157
|
* profile: "strict" | "balanced" | "permissive",
|
|
157
158
|
* auth: {
|
|
@@ -189,7 +190,7 @@ function create(opts) {
|
|
|
189
190
|
"getMessage/listMessages/markDelete; compose b.mailStore.create or operator-supplied backend)");
|
|
190
191
|
}
|
|
191
192
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
192
|
-
["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs"],
|
|
193
|
+
["maxLineBytes", "idleTimeoutMs", "commitTimeoutMs", "maxConnections"],
|
|
193
194
|
"mail.server.pop3.", MailServerPop3Error, "mail-server-pop3/bad-bound");
|
|
194
195
|
|
|
195
196
|
var greeting = opts.greeting || DEFAULT_GREETING_VENDOR;
|
|
@@ -239,23 +240,18 @@ function create(opts) {
|
|
|
239
240
|
var _emit = auditEmit.emit;
|
|
240
241
|
|
|
241
242
|
function _handleConnection(rawSocket) {
|
|
242
|
-
var
|
|
243
|
+
var accepted = mailServerNet.acceptConnection(rawSocket, {
|
|
244
|
+
rateLimit: rateLimit,
|
|
245
|
+
connections: connections,
|
|
246
|
+
emit: _emit,
|
|
243
247
|
refusedEvent: "mail.server.pop3.rate_limit_refused",
|
|
244
248
|
refusalLine: "-ERR Too many connections from your IP\r\n",
|
|
249
|
+
idPrefix: "pop3conn-",
|
|
245
250
|
});
|
|
246
|
-
if (
|
|
247
|
-
var
|
|
248
|
-
var
|
|
249
|
-
|
|
250
|
-
// Single close handler covers BOTH operator-driven `_close(socket)`
|
|
251
|
-
// and client-initiated disconnects (TCP FIN / RST without a
|
|
252
|
-
// server-side close call) — releases the rate-limit slot AND
|
|
253
|
-
// removes the socket from the tracking set so it can't accumulate
|
|
254
|
-
// stale entries across long-lived deployments.
|
|
255
|
-
rawSocket.once("close", function () {
|
|
256
|
-
rateLimit.releaseConnection(remoteAddress);
|
|
257
|
-
connections.delete(socket);
|
|
258
|
-
});
|
|
251
|
+
if (accepted === null) return;
|
|
252
|
+
var remoteAddress = accepted.remoteAddress;
|
|
253
|
+
var connectionId = accepted.connectionId;
|
|
254
|
+
var socket = accepted.socket;
|
|
259
255
|
|
|
260
256
|
var state = {
|
|
261
257
|
id: connectionId,
|
|
@@ -264,6 +260,7 @@ function create(opts) {
|
|
|
264
260
|
stage: "authorization",
|
|
265
261
|
actor: null,
|
|
266
262
|
tentativeUser: null, // USER name pending PASS
|
|
263
|
+
authPending: null, // in-flight multi-step SASL exchange (RFC 5034 §4)
|
|
267
264
|
dropId: null, // mailStore-issued drop handle on TRANSACTION entry
|
|
268
265
|
lineBuffer: Buffer.alloc(0),
|
|
269
266
|
};
|
|
@@ -307,6 +304,14 @@ function create(opts) {
|
|
|
307
304
|
}
|
|
308
305
|
|
|
309
306
|
function _handleLine(state, socket, line) {
|
|
307
|
+
// Mid-SASL: the client's next line is a base64 response to the server's
|
|
308
|
+
// challenge, not a POP3 verb, so it goes to the exchange rather than the
|
|
309
|
+
// wire guard — which would refuse it as an unknown command. Same ordering
|
|
310
|
+
// the submission listener uses for its own AUTH continuation.
|
|
311
|
+
if (state.authPending) {
|
|
312
|
+
_continueAuthExchange(state, socket, line);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
310
315
|
var parsed;
|
|
311
316
|
try {
|
|
312
317
|
parsed = guardPop3Command.validate(line, {
|
|
@@ -596,28 +601,59 @@ function create(opts) {
|
|
|
596
601
|
}
|
|
597
602
|
var mech = args[0].toUpperCase();
|
|
598
603
|
var initialResp = args.length > 1 ? args.slice(1).join(" ") : null;
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
604
|
+
state.authPending = { mech: mech, step: 0 };
|
|
605
|
+
_runAuthStep(state, socket, initialResp);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// One round of a SASL exchange (RFC 5034 §4). The verifier may answer with
|
|
609
|
+
// `{ pending: true, challenge }` to ask for another client response, exactly
|
|
610
|
+
// as it may on the IMAP and submission listeners; POP3 used to call verify
|
|
611
|
+
// once with no `step`, so a pending verdict fell into the failure branch —
|
|
612
|
+
// and spent the client's authentication-failure budget for what is a normal
|
|
613
|
+
// protocol round trip, weakening the defence that budget exists to provide.
|
|
614
|
+
function _runAuthStep(state, socket, clientResponse) {
|
|
615
|
+
var pending = state.authPending;
|
|
616
|
+
function _fail(reason, outcome) {
|
|
617
|
+
state.authPending = null;
|
|
618
|
+
rateLimit.noteAuthFailure(state.remoteAddress);
|
|
619
|
+
_emit("mail.server.pop3.auth_failed",
|
|
620
|
+
{ connectionId: state.id, verb: "AUTH", mech: pending.mech, reason: reason },
|
|
621
|
+
outcome);
|
|
622
|
+
_writeErr(socket, "Authentication failed");
|
|
623
|
+
}
|
|
624
|
+
mailServerNet.runSaslStep({
|
|
625
|
+
// `pending` IS the exchange: runSaslStep increments its `step`, and it
|
|
626
|
+
// stays on `state.authPending` across rounds.
|
|
627
|
+
exchange: pending,
|
|
628
|
+
verify: authConfig.verify,
|
|
629
|
+
credentials: { tls: state.tls, remoteAddress: state.remoteAddress },
|
|
630
|
+
clientResponse: clientResponse,
|
|
631
|
+
// RFC 5034 §4 — the server's challenge is a `+ <base64>` line, and the
|
|
632
|
+
// connection stays mid-exchange until the client answers.
|
|
633
|
+
writeChallenge: function (ch) { return _writeContinuation(socket, ch); },
|
|
634
|
+
onChallengeUnsafe: function () { _fail("challenge-contains-line-terminator", "denied"); },
|
|
635
|
+
onSuccess: function (result) {
|
|
636
|
+
state.authPending = null;
|
|
637
|
+
if (!_assertTenantOrRefuse(state, socket, result)) return;
|
|
638
|
+
state.actor = result.actor;
|
|
639
|
+
_enterTransaction(state, socket, "AUTH/" + pending.mech);
|
|
640
|
+
},
|
|
641
|
+
onFailure: function (result) {
|
|
642
|
+
_fail((result && result.reason) || "verify-returned-fail", "denied");
|
|
643
|
+
},
|
|
644
|
+
onError: function (err) { _fail((err && err.message) || String(err), "failure"); },
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function _continueAuthExchange(state, socket, line) {
|
|
649
|
+
// RFC 5034 §4 — a bare `*` cancels the exchange. Cancelling is the client
|
|
650
|
+
// withdrawing, not failing to authenticate, so it costs no budget either.
|
|
651
|
+
if (line === "*") {
|
|
652
|
+
state.authPending = null;
|
|
653
|
+
_writeErr(socket, "Authentication cancelled");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
_runAuthStep(state, socket, line);
|
|
621
657
|
}
|
|
622
658
|
|
|
623
659
|
function _enterTransaction(state, socket, verb) {
|
|
@@ -840,6 +876,17 @@ function create(opts) {
|
|
|
840
876
|
|
|
841
877
|
function _writeOk(socket, msg) { try { socket.write("+OK " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
|
|
842
878
|
function _writeErr(socket, msg) { try { socket.write("-ERR " + msg + "\r\n"); } catch (_e) { /* socket down */ } }
|
|
879
|
+
// RFC 5034 §4 SASL continuation — `+ <base64>`, or a bare `+` for an empty
|
|
880
|
+
// challenge. Returns false when the challenge carries bytes that would end
|
|
881
|
+
// the line, so the caller fails the exchange instead of emitting a second,
|
|
882
|
+
// smuggled protocol line.
|
|
883
|
+
function _writeContinuation(socket, challenge) {
|
|
884
|
+
var b64 = mailServerNet.saslChallengeOrNull(challenge);
|
|
885
|
+
if (b64 === null) return false;
|
|
886
|
+
try { socket.write(b64.length > 0 ? "+ " + b64 + "\r\n" : "+\r\n"); }
|
|
887
|
+
catch (_e) { /* socket down */ }
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
843
890
|
function _close(socket) {
|
|
844
891
|
try { socket.end(); } catch (_e) { /* idempotent */ }
|
|
845
892
|
try { socket.destroy(); } catch (_e2) { /* idempotent */ }
|
|
@@ -849,6 +896,7 @@ function create(opts) {
|
|
|
849
896
|
// ---- Lifecycle ----------------------------------------------------------
|
|
850
897
|
return mailServerNet.createStoreServer(net, {
|
|
851
898
|
defaultPort: 110, // RFC 1939 POP3 port (IANA)
|
|
899
|
+
maxConnections: opts.maxConnections,
|
|
852
900
|
handleConnection: _handleConnection,
|
|
853
901
|
errorClass: MailServerPop3Error,
|
|
854
902
|
errorCodePrefix: "mail-server-pop3/",
|