Compare commits
3
Commits
d2a778edc0
...
c731a98a0f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c731a98a0f | ||
|
|
5f86457848 | ||
|
|
00feb7c1c7 |
@@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **The level-2 rate limit and an application's client IP list held only for
|
||||||
|
the first message of a connection.** After a refused `MAIL FROM`, a second
|
||||||
|
one on the same connection was accepted, and so was every message after the
|
||||||
|
first that a connection carried: an application over its limit could go on
|
||||||
|
sending, and one restricted to certain client addresses could send from any
|
||||||
|
other with a retry. In the code since `1.9.0` (the client IP list) and
|
||||||
|
earlier (the limit); found by the technical review of 2026-10-12 and
|
||||||
|
reproduced on the test stand. Postfix now gives the panel's milters the
|
||||||
|
client address with every message, the limit no longer depends on it, and an
|
||||||
|
application with a client IP list is refused when no address is known.
|
||||||
|
- Reviews of the inbound antispam feature before its release (technical and
|
||||||
|
security, 2026-10-12; nothing Critical or High in the feature itself). Fixed:
|
||||||
|
a client's HELO name could supply the sender, recipient and reply of an
|
||||||
|
inbound log row; the log's row cap was applied only every six hours; rows of
|
||||||
|
a connection's later messages had no client address; two changes of the
|
||||||
|
filter lists at once could leave the filter's files behind the page; a held
|
||||||
|
message was logged twice. Recorded as accepted in
|
||||||
|
[docs/security.md](docs/security.md): the filter's verdict in the log is the
|
||||||
|
sender's own while the filter is down; an allow entry for a sender address
|
||||||
|
trusts an address anyone can claim; the list files are readable by anyone on
|
||||||
|
the host.
|
||||||
|
- `INBOUND_ANTISPAM_MILTER` and `INBOUND_JOURNAL_MILTER_SOCKET` are checked
|
||||||
|
character by character at start; the check looked at their first characters
|
||||||
|
only.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Port 25 logs as `postfix/inbound`.** Its lines in the mail log (Server ›
|
- **Port 25 logs as `postfix/inbound`.** Its lines in the mail log (Server ›
|
||||||
|
|||||||
@@ -230,6 +230,14 @@ postconf -e \
|
|||||||
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
"smtpd_milters={ unix:${OPENDKIM_SOCK}, default_action=tempfail }, { unix:${JOURNAL_SOCK}, default_action=accept }" \
|
||||||
"non_smtpd_milters="
|
"non_smtpd_milters="
|
||||||
|
|
||||||
|
# What Postfix tells a milter with MAIL FROM: its defaults plus the client's
|
||||||
|
# address. The panel's milters judge every message by that address (an
|
||||||
|
# application's client-address list) and record it (the inbound journal), and
|
||||||
|
# the address given once at connect is not there for a connection's later
|
||||||
|
# messages - see MailFrom in internal/milter/milter.go.
|
||||||
|
postconf -e \
|
||||||
|
"milter_mail_macros=i {auth_type} {auth_authen} {auth_author} {mail_addr} {mail_host} {mail_mailer} {client_addr}"
|
||||||
|
|
||||||
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
# Bounded milter timeouts: a *hung* milter (socket accepts but never replies)
|
||||||
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
# must fail open just like a crash, not stall mail acceptance until the Postfix
|
||||||
# defaults (300s content) elapse. With default_action per milter, a
|
# defaults (300s content) elapse. With default_action per milter, a
|
||||||
@@ -364,7 +372,14 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
# panel's inbound journal milter, or nothing at all.
|
# panel's inbound journal milter, or nothing at all.
|
||||||
INBOUND_MILTERS=""
|
INBOUND_MILTERS=""
|
||||||
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
if [ "${INBOUND_RELAY_ENABLE}" = "true" ] && [ -n "${INBOUND_ANTISPAM_MILTER}" ]; then
|
||||||
|
# A glob's "*" matches anything, so the shape alone would pass
|
||||||
|
# "inet:a:1},{unix:/elsewhere": characters outside the set are refused
|
||||||
|
# first, then the shape is checked.
|
||||||
case "${INBOUND_ANTISPAM_MILTER}" in
|
case "${INBOUND_ANTISPAM_MILTER}" in
|
||||||
|
*[!A-Za-z0-9._:/-]*)
|
||||||
|
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
inet:[A-Za-z0-9._-]*:[0-9]* | unix:/[A-Za-z0-9._/-]* ) ;;
|
||||||
*)
|
*)
|
||||||
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
echo "FATAL: INBOUND_ANTISPAM_MILTER must be inet:host:port or unix:/path, got: ${INBOUND_ANTISPAM_MILTER}" >&2
|
||||||
@@ -388,6 +403,10 @@ if [ "${INBOUND_RELAY_ENABLE}" = "true" ] || [ "${DMARC_REPORTS_ENABLE}" = "true
|
|||||||
# it is down. A comma, not a space, between the two - see
|
# it is down. A comma, not a space, between the two - see
|
||||||
# master_cf_set_smtp_inet_o.
|
# master_cf_set_smtp_inet_o.
|
||||||
case "${INBOUND_JOURNAL_SOCK}" in
|
case "${INBOUND_JOURNAL_SOCK}" in
|
||||||
|
*[!A-Za-z0-9._/-]*)
|
||||||
|
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
/[A-Za-z0-9._/-]*) ;;
|
/[A-Za-z0-9._/-]*) ;;
|
||||||
*)
|
*)
|
||||||
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
echo "FATAL: INBOUND_JOURNAL_MILTER_SOCKET must be an absolute path, got: ${INBOUND_JOURNAL_SOCK}" >&2
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
| `opendkim` | root → `opendkim` (`UserID` in opendkim.conf) | 100 | DKIM signing milter |
|
||||||
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
| `panel` | panel | 200 | HTTP UI + journal-milter + log-tailer + rate-limit recalc; with the inbound relay on also the inbound journal milter |
|
||||||
| `postfix` | root (wrapper) | 300 | MTA — started only after both milter sockets exist |
|
| `postfix` | root (wrapper) | 300 | MTA — started only after the milter sockets exist (OpenDKIM's, the journal milter's and, with the inbound relay on, the inbound journal milter's) |
|
||||||
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
| `postfix-reload` | root | — | On-demand `postfix reload` (autostart off) |
|
||||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||||
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
| `logrotate` | root | 400 | Periodic `mail.log` rotation |
|
||||||
@@ -98,7 +98,8 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
|||||||
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
|
([internal/logtail/inbound.go](../internal/logtail/inbound.go)). A line
|
||||||
names one recipient, so a message refused at end-of-message is one row
|
names one recipient, so a message refused at end-of-message is one row
|
||||||
whatever its recipient count, and a line read twice (the tailer saves its
|
whatever its recipient count, and a line read twice (the tailer saves its
|
||||||
place once a second; an unclean stop can lose that second) is two rows. The
|
place every five seconds; an unclean stop can lose those seconds) is two
|
||||||
|
rows. The
|
||||||
journal is pruned every six hours: rows
|
journal is pruned every six hours: rows
|
||||||
older than the `inbound_spam_log_retention_days` setting, then down to
|
older than the `inbound_spam_log_retention_days` setting, then down to
|
||||||
10 000.
|
10 000.
|
||||||
|
|||||||
@@ -135,7 +135,7 @@
|
|||||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
<div class="sp-help-topic" id="recipients"><h3>Valid recipients</h3><p>Choose whom this domain accepts. Listed addresses only: the relay accepts exactly the addresses you list, one per line or comma-separated, and refuses any other while the sender is still connected, so it never has to send a bounce for them. Any recipient: everything at the domain is accepted and passed on, and an address the upstream then rejects comes back as a bounce from this server to a sender who is often forged — backscatter. Prefer a list, and keep it in step with the mailboxes upstream.</p></div>
|
||||||
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
<div class="sp-help-topic" id="filter"><h3>Spam filter</h3><p>The spam filter belongs to the server, not to a domain. The server's administrator attaches it in the server's <code>.env</code>, and from then on every inbound domain is screened by it before mail is handed on; the box shows on or off and has nothing to switch. Off means mail goes to the upstream unfiltered. While the filter is down, mail is either accepted unfiltered (the default) or deferred, so the sending server tries again later, as the server's setting says; the box states which.</p></div>
|
||||||
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
<div class="sp-help-topic" id="inbound-log"><h3>Inbound log</h3><p>One row per recipient of a message offered on port 25 for an inbound domain, with what was decided: <span class="tag is-success is-light">accept</span> — handed on to the upstream; <span class="tag is-danger is-light">reject</span> — refused for good; <span class="tag is-warning is-light">tempfail</span> — the sender was asked to come back later; <span class="tag is-primary is-light">quarantine</span> — held by the filter. <em>Why</em> names who decided. <code>rspamd</code> is the spam filter, with its action, the score against the reject threshold and the heaviest symbols; <code>postfix</code> is the relay's own rule — an unknown recipient, a sender domain that does not exist — with its reply; <code>selfpost</code> means nobody judged the message: it was relayed with no filter attached. Accept says what this server did, not what the upstream did with the message afterwards.<br><br>Accepted mail is recorded as it passes; refused mail is read from <code>mail.log</code>, so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000.</p></div>
|
||||||
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
<div class="sp-help-topic" id="filter-lists"><h3>Filter lists</h3><p>Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, <strong>Deny wins</strong>.<br><br>SelfPost does not apply the lists itself: it writes them as files under <code>/data/antispam/</code>, and rspamd rereads a file within seconds of a change. With the spam filter off the lists are kept and can be edited, but nothing reads them. The sender matched is the envelope sender — the one in the log — not the From line a mail program shows. Matched counts the messages in the Inbound log that the entry decided, so it reaches back as far as the log does; never means not once in that time, and such an entry is one to consider removing.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
<div class="sp-box-head"><span class="sp-no">04</span><h2>Server</h2></div>
|
||||||
|
|||||||
+5
-1
@@ -870,7 +870,11 @@ seconds of a change - no restart. An entry is a sender address, a sender
|
|||||||
domain (that domain, not its subdomains) or a client address or network. A
|
domain (that domain, not its subdomains) or a client address or network. A
|
||||||
denied sender is refused outright. An allowed one is given a weight that no
|
denied sender is refused outright. An allowed one is given a weight that no
|
||||||
spam score outweighs, so it is accepted - unless a deny entry matches the same
|
spam score outweighs, so it is accepted - unless a deny entry matches the same
|
||||||
message: **deny wins**, whichever entries they are. The files are readable by
|
message: **deny wins**, whichever entries they are. **A sender address or
|
||||||
|
domain on the allow list is not proof of anything**: it is the envelope
|
||||||
|
sender, which the sending server states and nobody checks, so whoever knows an
|
||||||
|
allowed address can send as it. Allow a client address or network where you
|
||||||
|
can. The files are readable by
|
||||||
anyone on the host; they hold addresses, not secrets.
|
anyone on the host; they hold addresses, not secrets.
|
||||||
|
|
||||||
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
In the panel the lists are **Inbound › Filter lists** (global role), and what
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ antispam hook). Without an external filter:
|
|||||||
|
|
||||||
- **accept** rows — message relayed (engine `selfpost`, detail empty);
|
- **accept** rows — message relayed (engine `selfpost`, detail empty);
|
||||||
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
|
- **reject** / **tempfail** rows — from the mail.log tailer (Postfix policy,
|
||||||
size limit, unknown recipient, etc.).
|
unknown recipient, a sender domain that does not exist. As built, a refusal
|
||||||
|
whose log line names no recipient - the size limit answered at MAIL FROM -
|
||||||
|
and the per-client rate limit, which Postfix logs as a warning, make no row).
|
||||||
|
|
||||||
Panel copy explains that rspamd symbols appear only when the antispam milter is
|
Panel copy explains that rspamd symbols appear only when the antispam milter is
|
||||||
configured.
|
configured.
|
||||||
@@ -260,7 +262,9 @@ flowchart LR
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
- Same validation whitelists as inbound relay ([security.md](../security.md)).
|
- Same validation whitelists as inbound relay ([security.md](../security.md)).
|
||||||
- Journal rows contain PII — retention and global-admin-only access.
|
- Journal rows contain PII — retention, and access by reach: the global role
|
||||||
|
reads all of it, a `domain` user the rows of assigned inbound domains (as
|
||||||
|
built; see § RBAC).
|
||||||
- Inbound journal milter fail-open must not block mail (backup-MX role).
|
- Inbound journal milter fail-open must not block mail (backup-MX role).
|
||||||
- List map writes injection-safe; deny wins over allow.
|
- List map writes injection-safe; deny wins over allow.
|
||||||
|
|
||||||
@@ -312,5 +316,5 @@ Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
|
|||||||
- [x] Unit + handler tests — **Composer** (written with each step: store, milter, tailer, lists and their usage, view fixtures held to the outlines, handlers for both roles)
|
- [x] Unit + handler tests — **Composer** (written with each step: store, milter, tailer, lists and their usage, view fixtures held to the outlines, handlers for both roles)
|
||||||
- [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review)
|
- [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review)
|
||||||
- [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel)
|
- [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel)
|
||||||
- [ ] Technical review of inbound antispam changes — **Opus**
|
- [x] Technical review of inbound antispam changes — **Opus** (2026-10-12; findings fixed: client address of a connection's later messages, the lists' writer under a lock, the tailer's queue-id memory, hold rows written twice, the cap by time; and, outside the feature, the level-2 limit and client-address list skipped after a connection's first message)
|
||||||
- [ ] Security review inbound antispam path — **Fable**
|
- [x] Security review inbound antispam path — **Fable** (2026-10-12; no Critical or High; HELO-forged journal rows and the six-hourly row cap fixed, three accepted risks recorded in [security.md](../security.md))
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
| preflight | Installation check page (`/preflight`) | candidate | — | [plans/preflight.md](plans/preflight.md) |
|
||||||
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
| contributing | `CONTRIBUTING.md` | candidate | — | — |
|
||||||
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 10/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
| inbound-antispam-panel | Inbound antispam journal + allow/deny lists | in progress | 12/12 | [plans/inbound-antispam-panel.md](plans/inbound-antispam-panel.md) |
|
||||||
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
| inbound-quarantine | Inbound spam quarantine (hold / review / release) | candidate | — | [plans/inbound-quarantine.md](plans/inbound-quarantine.md) |
|
||||||
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
| csrf-tokens | Per-session CSRF tokens on state-changing forms | candidate | — | — |
|
||||||
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
| template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — |
|
||||||
|
|||||||
+83
-2
@@ -33,7 +33,21 @@ Critical or High finding; a report's size was bounded only as bytes (now 5 000
|
|||||||
records, 255 bytes a field), the retention cap was one for the whole server
|
records, 255 bytes a field), the retention cap was one for the whole server
|
||||||
(now per domain), the delivery command could run a hundred at once (now one),
|
(now per domain), the delivery command could run a hundred at once (now one),
|
||||||
and the reason logged for a dropped message carried the sender's bytes as they
|
and the reason logged for a dropped message carried the sender's bytes as they
|
||||||
came (now cut and reduced to printable ASCII).
|
came (now cut and reduced to printable ASCII). The 2026-10-12 reviews of
|
||||||
|
inbound-antispam-panel (`2.1.0`: the inbound journal milter, refusals read from
|
||||||
|
`mail.log`, the filter lists and their pages) were two, by different models
|
||||||
|
over the same diff. The technical one (Opus) found, outside the feature and
|
||||||
|
there since `1.9.0`, that **the level-2 rate limit and an application's
|
||||||
|
client-address list held only for the first message of a connection** - see
|
||||||
|
[Mail path](#mail-path-security-relevant) - which was reproduced on the test
|
||||||
|
stand and fixed. The security one (Fable) found no Critical or High: a client's
|
||||||
|
HELO name could supply the sender, recipient and reply of a journal row and so
|
||||||
|
make a refusal look like the deny list's (the reply now ends at Postfix's own
|
||||||
|
`; from=<`); the journal's row cap was applied only every six hours, between
|
||||||
|
which refusals were written at whatever rate a stranger sent recipients (the
|
||||||
|
tailer now cuts to the cap every 500 rows); and three behaviours are recorded
|
||||||
|
under Accepted risks (verdict headers while the filter is down, the allow list
|
||||||
|
keyed on an address anyone can claim, the list files readable by anyone).
|
||||||
(2) **Accepted risks** —
|
(2) **Accepted risks** —
|
||||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
lost.
|
lost.
|
||||||
@@ -120,6 +134,8 @@ assigned outbound domains and inbound domains **separately**, each list with
|
|||||||
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
|
| Outbound: domain page, applications, Domain settings (report address, rate limit, export), log | every domain | assigned outbound domains |
|
||||||
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
|
| Outbound › DMARC reports | every report, with the server's ingest statistics | reports of assigned domains, without the ingest box |
|
||||||
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
|
| Inbound: domain page, upstream, recipients | every inbound domain | assigned inbound domains |
|
||||||
|
| Inbound › Log | every row, including those of a domain deleted since | rows of assigned inbound domains (by name), without the links into the filter lists |
|
||||||
|
| Inbound › Filter lists; the inbound log's retention | yes | no — 404 |
|
||||||
| Add or delete a domain, either direction; import a domain | yes | no |
|
| Add or delete a domain, either direction; import a domain | yes | no |
|
||||||
| Account, Help | yes | yes |
|
| Account, Help | yes | yes |
|
||||||
|
|
||||||
@@ -178,6 +194,32 @@ operator can act on it.
|
|||||||
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
|
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
|
||||||
on inbound. An optional antispam milter is inbound-only; default action is
|
on inbound. An optional antispam milter is inbound-only; default action is
|
||||||
fail-open (`accept`).
|
fail-open (`accept`).
|
||||||
|
- **The panel's milters judge every message of a connection.** Postfix
|
||||||
|
introduces a client to a milter once per connection, and the milter library
|
||||||
|
replaces the milter's session after any reply that is not "continue". Until
|
||||||
|
`2.1.0` the journal milter knew the client's address only from that
|
||||||
|
introduction, and both of its checks passed a message that came without one:
|
||||||
|
a second `MAIL FROM` after a refused one, or any message after the first on
|
||||||
|
one connection, got past the level-2 limit and past an application's
|
||||||
|
client-address list. Now the address comes with every `MAIL FROM`
|
||||||
|
(`milter_mail_macros` carries `{client_addr}`), the level-2 limit does not
|
||||||
|
ask for it at all, an application with a client-address list is refused when
|
||||||
|
no address is known, and neither milter replies anything but "continue" to a
|
||||||
|
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
|
||||||
|
- **The inbound journal is written from what strangers send.** The inbound
|
||||||
|
journal milter is the last on port 25, read-only and attached fail-open; it
|
||||||
|
keeps of the filter's verdict only what its patterns recognise. Refusals are
|
||||||
|
parsed from `mail.log` from the head of the line, with the reply ending at
|
||||||
|
Postfix's own `; from=<` so that the HELO name after it - the client's text,
|
||||||
|
logged as written - cannot stand in for the sender, the recipient or the
|
||||||
|
reply; an address with a quote, a bracket or a space in it is not kept.
|
||||||
|
Only a recipient at an inbound domain makes a row. The journal is cut to
|
||||||
|
10 000 rows every 500 rows the tailer writes, and by age every six hours.
|
||||||
|
- **Filter lists** reach another container as files: values are whitelisted on
|
||||||
|
the way in (address, domain, canonical IP or network; no `/0`, no
|
||||||
|
IPv4-mapped network), the writer refuses a stored value with whitespace, a
|
||||||
|
newline or `#`, and a change of the table and the rewrite of the six files
|
||||||
|
are one step under a lock.
|
||||||
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
- TLS is mandatory before credentials are transmitted (465 wrapper / 587
|
||||||
`encrypt`).
|
`encrypt`).
|
||||||
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
- `TRUSTED_PROXY_CIDR` — only explicitly trusted proxies may supply
|
||||||
@@ -295,13 +337,52 @@ deferred item from the roadmap.
|
|||||||
- **The application auth-IP allow-list is enforced fail-open.** The check runs
|
- **The application auth-IP allow-list is enforced fail-open.** The check runs
|
||||||
in the journal milter (`internal/milter/authip.go`) with
|
in the journal milter (`internal/milter/authip.go`) with
|
||||||
`default_action=accept` and skips on store errors, so if the milter is down
|
`default_action=accept` and skips on store errors, so if the milter is down
|
||||||
the restriction is not applied; the SASL login itself always succeeds. This
|
the restriction is not applied; the SASL login itself always succeeds.
|
||||||
|
(While the milter is up the list holds for every message, and an unknown
|
||||||
|
client address is refused - see Mail path; what is accepted here is the
|
||||||
|
milter being down.) This
|
||||||
is documented in [guide.md](guide.md) as **not an authentication boundary** —
|
is documented in [guide.md](guide.md) as **not an authentication boundary** —
|
||||||
it is a blast-radius limiter for a leaked application password, on the same
|
it is a blast-radius limiter for a leaked application password, on the same
|
||||||
fail-open path as level-2 rate limiting, and a milter outage already stops
|
fail-open path as level-2 rate limiting, and a milter outage already stops
|
||||||
the journal from recording sends at all. The trigger to revisit is a
|
the journal from recording sends at all. The trigger to revisit is a
|
||||||
requirement for the allow-list to hold as a security boundary — at which
|
requirement for the allow-list to hold as a security boundary — at which
|
||||||
point it must move to the SASL/smtpd layer and fail closed.
|
point it must move to the SASL/smtpd layer and fail closed.
|
||||||
|
- **With the spam filter down, a sender can write his own verdict into the
|
||||||
|
inbound journal.** The filter is attached fail-open by default, and the
|
||||||
|
inbound journal milter reads its verdict from two headers. Up, rspamd removes
|
||||||
|
headers of those names that a sender wrote and adds its own (seen on the
|
||||||
|
test server: a forged pair was replaced). Down, the sender's pair is all
|
||||||
|
there is: the row shows his action and score, a forged `quarantine` shows a
|
||||||
|
hold that never happened, and a forged allow symbol counts as a match of an
|
||||||
|
allow entry that fits him in Filter lists. Mail is not affected - the
|
||||||
|
journal decides nothing - and the page that says the filter is down is
|
||||||
|
Server › Health. Accepted because the alternative is a live check of the
|
||||||
|
filter on every message for the sake of a monitoring page. Trigger to
|
||||||
|
revisit: the journal or the match counts being used to decide something
|
||||||
|
(alerts, automatic removal of entries), or `INBOUND_ANTISPAM_MILTER_ACTION`
|
||||||
|
staying `accept` on an installation whose filter is often down.
|
||||||
|
- **An allow entry for a sender address or domain trusts what the sending
|
||||||
|
server says.** The filter matches the envelope sender, which is not
|
||||||
|
authenticated: anyone who knows or guesses an allowed address can send as it
|
||||||
|
and is given the entry's weight, which no spam score outweighs. The page's
|
||||||
|
Help and the guide say so and recommend an entry for the client's address or
|
||||||
|
network instead. Deny entries have no such problem - claiming to be a denied
|
||||||
|
sender only gets mail refused - and deny wins over allow. Trigger to
|
||||||
|
revisit: allow entries being used as the main way to receive mail from a
|
||||||
|
sender, at which point the rule should also require the sender's SPF or
|
||||||
|
DKIM to pass.
|
||||||
|
- **The filter lists are files readable by anyone on the host.**
|
||||||
|
`/data/antispam` is 0755 and its six files 0644, unlike the rest of `/data`:
|
||||||
|
the filter runs under another user in another container. They hold sender
|
||||||
|
addresses, domains and networks - who the operator blocks and whom he lets
|
||||||
|
through - but no message data. The inbound journal itself is in the
|
||||||
|
database, 0640. Trigger to revisit: a shared host, where the sidecar should
|
||||||
|
instead be given the files through a group it shares with the panel.
|
||||||
|
- **A row of the inbound log belongs to a domain by name.** A `domain` user
|
||||||
|
sees the rows whose inbound domain has the name of one assigned to them, so
|
||||||
|
an inbound domain that is deleted and added again shows its earlier rows to
|
||||||
|
whoever it is assigned to next. Accepted: the journal is of a mail domain,
|
||||||
|
and the rows age out with the retention.
|
||||||
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
|
- **Level-2 in-flight reservations can outlive a dropped SMTP session until TTL.**
|
||||||
The journal milter cannot observe connection-close directly through
|
The journal milter cannot observe connection-close directly through
|
||||||
go-milter, so if a client disconnects without an explicit `Abort`, its
|
go-milter, so if a client disconnects without an explicit `Abort`, its
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ type ListStore interface {
|
|||||||
type Lists struct {
|
type Lists struct {
|
||||||
store ListStore
|
store ListStore
|
||||||
dir string
|
dir string
|
||||||
|
// mu makes a change of the table and the rewrite of the files one step.
|
||||||
|
// Two requests at once could otherwise write the files in the other order
|
||||||
|
// than they changed the table - the first to read the entries being the
|
||||||
|
// last to write them - and leave the filter with an entry the page no
|
||||||
|
// longer lists. One Lists serves the panel, so a mutex is enough.
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLists builds the filter lists over the store and the map directory
|
// NewLists builds the filter lists over the store and the map directory
|
||||||
@@ -76,31 +83,63 @@ func (l *Lists) Add(list, kind, rawValue, rawNote string) (store.SpamListEntry,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return store.SpamListEntry{}, err
|
return store.SpamListEntry{}, err
|
||||||
}
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
e, err := l.store.AddSpamListEntry(list, kind, value, note)
|
e, err := l.store.AddSpamListEntry(list, kind, value, note)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return store.SpamListEntry{}, err
|
return store.SpamListEntry{}, err
|
||||||
}
|
}
|
||||||
if err := l.Sync(); err != nil {
|
if err := l.sync(); err != nil {
|
||||||
// An entry the filter never got is worse than no entry: the page
|
// An entry the filter never got is worse than no entry: the page
|
||||||
// would show a rule that does nothing.
|
// would show a rule that does nothing. The files are written again
|
||||||
|
// after the row is taken back, since some of the six may already
|
||||||
|
// hold it; if that fails too, the next change or start puts it right.
|
||||||
_ = l.store.DeleteSpamListEntry(e.ID)
|
_ = l.store.DeleteSpamListEntry(e.ID)
|
||||||
|
_ = l.sync()
|
||||||
return store.SpamListEntry{}, err
|
return store.SpamListEntry{}, err
|
||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete removes an entry and rewrites the maps.
|
// Delete removes an entry and rewrites the maps. If the maps cannot be
|
||||||
|
// written the entry is put back: an entry that is gone from the page and
|
||||||
|
// still in the filter's file could not even be removed a second time.
|
||||||
func (l *Lists) Delete(id int64) error {
|
func (l *Lists) Delete(id int64) error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
entries, err := l.store.ListSpamListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var gone *store.SpamListEntry
|
||||||
|
for i := range entries {
|
||||||
|
if entries[i].ID == id {
|
||||||
|
gone = &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := l.store.DeleteSpamListEntry(id); err != nil {
|
if err := l.store.DeleteSpamListEntry(id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return l.Sync()
|
if err := l.sync(); err != nil {
|
||||||
|
if gone != nil {
|
||||||
|
_, _ = l.store.AddSpamListEntry(gone.List, gone.Kind, gone.Value, gone.Note)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync writes all six map files from SQLite, each atomically, so the filter
|
// Sync writes all six map files from SQLite, each atomically, so the filter
|
||||||
// reads a whole old file or a whole new one. Every file is written even when
|
// reads a whole old file or a whole new one. Every file is written even when
|
||||||
// empty: the filter's configuration names all six.
|
// empty: the filter's configuration names all six.
|
||||||
func (l *Lists) Sync() error {
|
func (l *Lists) Sync() error {
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
return l.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync is Sync for a caller that holds mu.
|
||||||
|
func (l *Lists) sync() error {
|
||||||
entries, err := l.store.ListSpamListEntries()
|
entries, err := l.store.ListSpamListEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+70
-16
@@ -34,6 +34,9 @@ type Inbound struct {
|
|||||||
// postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<a@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<mx.example.net>
|
// postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from mx.example.net[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<a@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<mx.example.net>
|
||||||
// postfix/cleanup[52]: 4A1B2C3D: milter-reject: END-OF-MESSAGE from mx.example.net[203.0.113.9]: 5.7.1 Spam message rejected; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>
|
// postfix/cleanup[52]: 4A1B2C3D: milter-reject: END-OF-MESSAGE from mx.example.net[203.0.113.9]: 5.7.1 Spam message rejected; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>
|
||||||
//
|
//
|
||||||
|
// A message the filter holds (milter-hold) is not read here: it goes on to the
|
||||||
|
// inbound journal milter, which writes its row with the subject.
|
||||||
|
//
|
||||||
// The first is Postfix's own policy, written by the port-25 smtpd, which
|
// The first is Postfix's own policy, written by the port-25 smtpd, which
|
||||||
// build/postfix-config.sh names postfix/inbound so that its lines are told
|
// build/postfix-config.sh names postfix/inbound so that its lines are told
|
||||||
// from those of the submission ports. The second is the filter's verdict at
|
// from those of the submission ports. The second is the filter's verdict at
|
||||||
@@ -45,9 +48,20 @@ type Inbound struct {
|
|||||||
// name: the tail of such a line is the sender's own words (HELO name,
|
// name: the tail of such a line is the sender's own words (HELO name,
|
||||||
// addresses), and a pattern that could start anywhere would let him write
|
// addresses), and a pattern that could start anywhere would let him write
|
||||||
// journal rows.
|
// journal rows.
|
||||||
|
//
|
||||||
|
// For the same reason the reply is the shortest text before "; from=<", and
|
||||||
|
// the HELO name - the last thing on the line, which Postfix logs as the client
|
||||||
|
// wrote it, spaces and brackets included - is whatever is left. Taking the
|
||||||
|
// longest reply instead would let a HELO name that itself spells
|
||||||
|
// "; from=<…> to=<…> proto=…" supply the sender, the recipient and the reply
|
||||||
|
// of the row (security review, 2026-10-12).
|
||||||
var (
|
var (
|
||||||
inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-hold|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<[^>]*>)?$`)
|
inboundRejectRe = regexp.MustCompile(`^\S+ postfix/(inbound/smtpd|cleanup)\[\d+\]: ([0-9A-Za-z]+): (reject|milter-reject|milter-discard): \S+ from [^\[\s]*\[([0-9A-Fa-f:.]+)\]: (.*?); from=<([^>]*)> to=<([^>]*)> proto=\S+(?: helo=<.*>)?$`)
|
||||||
inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`)
|
inboundClientRe = regexp.MustCompile(`^\S+ postfix/inbound/smtpd\[\d+\]: ([0-9A-Za-z]+): client=`)
|
||||||
|
// A queue id stops being port 25's when the message is queued (qmgr takes
|
||||||
|
// it: the filter let it through) or when another listener opens a message
|
||||||
|
// under the same id - Postfix reuses ids.
|
||||||
|
inboundForgetRe = regexp.MustCompile(`^\S+ postfix/(?:qmgr\[\d+\]: ([0-9A-Za-z]+): from=|(?:[a-z0-9-]+/)?smtpd\[\d+\]: ([0-9A-Za-z]+): client=)`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// A row holds one line of reason; Postfix's reply text carries the far end's
|
// A row holds one line of reason; Postfix's reply text carries the far end's
|
||||||
@@ -55,21 +69,40 @@ var (
|
|||||||
const inboundDetailMax = 255
|
const inboundDetailMax = 255
|
||||||
|
|
||||||
// inboundQueueIDs is how many port-25 queue ids are remembered while waiting
|
// inboundQueueIDs is how many port-25 queue ids are remembered while waiting
|
||||||
// for cleanup's verdict line on them. A message is in that state for the
|
// for cleanup's verdict line on them. An id is forgotten when the verdict
|
||||||
// seconds its DATA takes; the cap only bounds a flood.
|
// comes, when the message is queued, or when another listener reuses it; the
|
||||||
|
// cap bounds what is left - messages the client abandoned - and a flood.
|
||||||
const inboundQueueIDs = 4096
|
const inboundQueueIDs = 4096
|
||||||
|
|
||||||
|
// inboundPruneEvery is how many rows the tailer writes before it has the
|
||||||
|
// journal cut back to its row cap, without waiting for the six-hourly prune.
|
||||||
|
// Refusals are written at the rate strangers send RCPT TO, which no limit of
|
||||||
|
// Postfix's counts, so between two prunes the journal would otherwise grow as
|
||||||
|
// fast as someone cared to make it.
|
||||||
|
const inboundPruneEvery = 500
|
||||||
|
|
||||||
// inboundJournal is the per-run state of the inbound half.
|
// inboundJournal is the per-run state of the inbound half.
|
||||||
type inboundJournal struct {
|
type inboundJournal struct {
|
||||||
st InboundStore
|
st InboundStore
|
||||||
// port25 holds the queue ids the port-25 smtpd opened and cleanup has not
|
// port25 holds the queue ids the port-25 smtpd opened and nobody has
|
||||||
// reported on yet; order is their arrival, for dropping the oldest.
|
// accounted for yet, each with the number of its arrival; order is those
|
||||||
port25 map[string]struct{}
|
// arrivals, for dropping the oldest. An id that was forgotten and opened
|
||||||
order []string
|
// again is in order twice: the number tells the stale entry from the live
|
||||||
|
// one when the stale one comes up for dropping.
|
||||||
|
port25 map[string]uint64
|
||||||
|
order []queued
|
||||||
|
seq uint64
|
||||||
|
// written counts the rows since the journal was last cut to its cap.
|
||||||
|
written int
|
||||||
|
}
|
||||||
|
|
||||||
|
type queued struct {
|
||||||
|
id string
|
||||||
|
seq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInboundJournal(st InboundStore) *inboundJournal {
|
func newInboundJournal(st InboundStore) *inboundJournal {
|
||||||
return &inboundJournal{st: st, port25: make(map[string]struct{})}
|
return &inboundJournal{st: st, port25: make(map[string]uint64)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle looks at one mail.log line and writes a journal row when it is a
|
// handle looks at one mail.log line and writes a journal row when it is a
|
||||||
@@ -80,11 +113,23 @@ func (j *inboundJournal) handle(line string, now time.Time) {
|
|||||||
j.remember(m[1])
|
j.remember(m[1])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if m := inboundForgetRe.FindStringSubmatch(rest); m != nil {
|
||||||
|
delete(j.port25, m[1]+m[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
m := inboundRejectRe.FindStringSubmatch(rest)
|
m := inboundRejectRe.FindStringSubmatch(rest)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
program, queueID, kind, clientIP, reason, from, to := m[1], m[2], m[3], m[4], m[5], m[6], m[7]
|
program, queueID, kind, clientIP, reason, from, to := m[1], m[2], m[3], m[4], m[5], m[6], m[7]
|
||||||
|
// The sender's address is his own text too, and with a quoted local part it
|
||||||
|
// may hold "> to=<…> proto=…" - which would end the sender early and supply
|
||||||
|
// the recipient. Such a line shows it: the sender is left with an unclosed
|
||||||
|
// quote. An address with a quote, a bracket or a space in it is not one the
|
||||||
|
// journal keeps.
|
||||||
|
if strings.ContainsAny(from, "\"<> ") || strings.ContainsAny(to, "\"<> ") {
|
||||||
|
return
|
||||||
|
}
|
||||||
if program == "cleanup" {
|
if program == "cleanup" {
|
||||||
if _, ok := j.port25[queueID]; !ok {
|
if _, ok := j.port25[queueID]; !ok {
|
||||||
return // a submission-port message, or one opened before this run
|
return // a submission-port message, or one opened before this run
|
||||||
@@ -121,11 +166,8 @@ func (j *inboundJournal) handle(line string, now time.Time) {
|
|||||||
Detail: printable(reason, inboundDetailMax),
|
Detail: printable(reason, inboundDetailMax),
|
||||||
At: at,
|
At: at,
|
||||||
}
|
}
|
||||||
switch {
|
if kind == "reject" {
|
||||||
case kind == "reject":
|
|
||||||
e.Engine = store.SpamEnginePostfix
|
e.Engine = store.SpamEnginePostfix
|
||||||
case kind == "milter-hold":
|
|
||||||
e.Decision = store.SpamDecisionQuarantine
|
|
||||||
}
|
}
|
||||||
// "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply
|
// "450 4.7.1 …" from Postfix, "4.7.1 …" from a milter: either way a reply
|
||||||
// that starts with 4 asks the sender to come back.
|
// that starts with 4 asks the sender to come back.
|
||||||
@@ -134,6 +176,14 @@ func (j *inboundJournal) handle(line string, now time.Time) {
|
|||||||
}
|
}
|
||||||
if err := j.st.InsertInboundSpam(e); err != nil {
|
if err := j.st.InsertInboundSpam(e); err != nil {
|
||||||
log.Printf("log-tailer: inbound journal: record %s -> %s: %v", from, to, err)
|
log.Printf("log-tailer: inbound journal: record %s -> %s: %v", from, to, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.written++; j.written >= inboundPruneEvery {
|
||||||
|
j.written = 0
|
||||||
|
// The zero time removes nothing by age: this is the cap alone.
|
||||||
|
if _, err := j.st.PruneInboundSpam(time.Time{}, inboundSpamLogMaxRows); err != nil {
|
||||||
|
log.Printf("log-tailer: inbound journal: cut to %d rows: %v", inboundSpamLogMaxRows, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +191,16 @@ func (j *inboundJournal) remember(queueID string) {
|
|||||||
if _, ok := j.port25[queueID]; ok {
|
if _, ok := j.port25[queueID]; ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(j.order) >= inboundQueueIDs {
|
for len(j.order) >= inboundQueueIDs {
|
||||||
delete(j.port25, j.order[0])
|
oldest := j.order[0]
|
||||||
j.order = j.order[1:]
|
j.order = j.order[1:]
|
||||||
|
if j.port25[oldest.id] == oldest.seq {
|
||||||
|
delete(j.port25, oldest.id)
|
||||||
}
|
}
|
||||||
j.port25[queueID] = struct{}{}
|
}
|
||||||
j.order = append(j.order, queueID)
|
j.seq++
|
||||||
|
j.port25[queueID] = j.seq
|
||||||
|
j.order = append(j.order, queued{queueID, j.seq})
|
||||||
}
|
}
|
||||||
|
|
||||||
// printable cuts s to max bytes and replaces everything outside printable
|
// printable cuts s to max bytes and replaces everything outside printable
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ func TestInboundMilterVerdictNeedsAPort25QueueID(t *testing.T) {
|
|||||||
func TestInboundMilterDecisions(t *testing.T) {
|
func TestInboundMilterDecisions(t *testing.T) {
|
||||||
for kind, c := range map[string]struct{ reply, decision string }{
|
for kind, c := range map[string]struct{ reply, decision string }{
|
||||||
"milter-reject": {"4.7.1 Try again later", store.SpamDecisionTempfail},
|
"milter-reject": {"4.7.1 Try again later", store.SpamDecisionTempfail},
|
||||||
"milter-hold": {"milter triggers HOLD action", store.SpamDecisionQuarantine},
|
|
||||||
"milter-discard": {"milter triggers DISCARD action", store.SpamDecisionReject},
|
"milter-discard": {"milter triggers DISCARD action", store.SpamDecisionReject},
|
||||||
} {
|
} {
|
||||||
j, st := inboundFixture()
|
j, st := inboundFixture()
|
||||||
@@ -128,7 +127,7 @@ func TestInboundIgnores(t *testing.T) {
|
|||||||
"a connect line": head + "postfix/inbound/smtpd[41]: connect from mx.example.net[203.0.113.9]",
|
"a connect line": head + "postfix/inbound/smtpd[41]: connect from mx.example.net[203.0.113.9]",
|
||||||
// The HELO name is the sender's; a line cannot be started from inside one.
|
// The HELO name is the sender's; a line cannot be started from inside one.
|
||||||
"a refusal spelled inside another line": head + "postfix/inbound/smtpd[41]: warning: hostname x does not resolve: mail postfix/inbound/smtpd[1]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 no; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<x>",
|
"a refusal spelled inside another line": head + "postfix/inbound/smtpd[41]: warning: hostname x does not resolve: mail postfix/inbound/smtpd[1]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 no; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<x>",
|
||||||
"an address with a bracket in it": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 no; from=<a> to=<v@in.example.com> proto=X helo=<y>@example.net> to=<b@elsewhere.example> proto=ESMTP helo=<x>",
|
"an address with a bracket in it": head + "postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 554 5.7.1 <b@elsewhere.example>: Relay access denied; from=<\"a> to=<v@in.example.com> proto=X helo=<y\"@example.net> to=<b@elsewhere.example> proto=ESMTP helo=<x>",
|
||||||
} {
|
} {
|
||||||
j, st := inboundFixture()
|
j, st := inboundFixture()
|
||||||
j.handle(line, tailNow)
|
j.handle(line, tailNow)
|
||||||
@@ -159,6 +158,95 @@ func TestInboundLookupFailureLeavesNoRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A message the filter holds goes on to the inbound journal milter, which
|
||||||
|
// writes its row; a second one from the log would be the same message twice.
|
||||||
|
func TestInboundHoldIsTheMiltersRow(t *testing.T) {
|
||||||
|
j, st := inboundFixture()
|
||||||
|
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow)
|
||||||
|
j.handle(head+"postfix/cleanup[52]: Q1: milter-hold: END-OF-MESSAGE from mx.example.net[203.0.113.9]: milter triggers HOLD action; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<mx.example.net>", tailNow)
|
||||||
|
if len(st.rows) != 0 {
|
||||||
|
t.Errorf("rows = %+v", st.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HELO name is the last thing on the line and is logged as the client
|
||||||
|
// wrote it. It cannot supply the row: the reply ends at the first "; from=<",
|
||||||
|
// which is Postfix's own.
|
||||||
|
func TestInboundHeloCannotWriteTheRow(t *testing.T) {
|
||||||
|
j, st := inboundFixture()
|
||||||
|
j.handle(head+"postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<x 5.7.1 Refused by this server's deny list; from=<victim@bank.example> to=<ceo@in.example.com> proto=ESMTP helo=<y>", tailNow)
|
||||||
|
if len(st.rows) != 1 {
|
||||||
|
t.Fatalf("rows = %+v", st.rows)
|
||||||
|
}
|
||||||
|
got := st.rows[0]
|
||||||
|
if got.From != "real@example.net" || got.To != "nobody@in.example.com" ||
|
||||||
|
got.Detail != "550 5.1.1 <nobody@in.example.com>: Recipient address rejected: User unknown in relay recipient table" {
|
||||||
|
t.Errorf("row = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A HELO name with a closing bracket in it does not lose the line either.
|
||||||
|
j, st = inboundFixture()
|
||||||
|
j.handle(head+"postfix/inbound/smtpd[41]: NOQUEUE: reject: RCPT from x[203.0.113.9]: 550 5.1.1 <nobody@in.example.com>: no; from=<real@example.net> to=<nobody@in.example.com> proto=ESMTP helo=<a>b>", tailNow)
|
||||||
|
if len(st.rows) != 1 || st.rows[0].From != "real@example.net" {
|
||||||
|
t.Errorf("rows = %+v", st.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same on a verdict line: the filter's reply is not extended by the
|
||||||
|
// HELO name, so a stranger cannot make a refusal look like the deny list's.
|
||||||
|
j, st = inboundFixture()
|
||||||
|
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=x[203.0.113.9]", tailNow)
|
||||||
|
j.handle(head+"postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from x[203.0.113.9]: 5.7.1 Spam message rejected; from=<real@example.net> to=<b@in.example.com> proto=ESMTP helo=<x this server's deny list; from=<spoofed@example.org> to=<b@in.example.com> proto=ESMTP helo=<y>", tailNow)
|
||||||
|
if len(st.rows) != 1 || st.rows[0].Detail != "5.7.1 Spam message rejected" || st.rows[0].From != "real@example.net" {
|
||||||
|
t.Errorf("rows = %+v", st.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A queue id is port 25's only until the message is queued or the id is
|
||||||
|
// opened again by another listener - Postfix reuses ids.
|
||||||
|
func TestInboundQueueIDIsForgotten(t *testing.T) {
|
||||||
|
const verdict = "postfix/cleanup[52]: Q1: milter-reject: END-OF-MESSAGE from app[203.0.113.4]: 4.7.1 Service unavailable - try again later; from=<a@example.net> to=<b@in.example.com> proto=ESMTP helo=<app>"
|
||||||
|
for name, forget := range map[string]string{
|
||||||
|
"queued": "postfix/qmgr[58]: Q1: from=<a@example.net>, size=600, nrcpt=1 (queue active)",
|
||||||
|
"reused by the submission port": "postfix/smtpd[20]: Q1: client=app.example.com[203.0.113.4], sasl_method=PLAIN, sasl_username=app",
|
||||||
|
} {
|
||||||
|
j, st := inboundFixture()
|
||||||
|
j.handle(head+"postfix/inbound/smtpd[41]: Q1: client=mx.example.net[203.0.113.9]", tailNow)
|
||||||
|
j.handle(head+forget, tailNow)
|
||||||
|
j.handle(head+verdict, tailNow)
|
||||||
|
if len(st.rows) != 0 || len(j.port25) != 0 {
|
||||||
|
t.Errorf("%s: rows %+v, remembered %v", name, st.rows, j.port25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An id that was forgotten and opened again is not dropped by its own stale
|
||||||
|
// place in the queue of arrivals.
|
||||||
|
func TestInboundStaleArrivalDoesNotDropALiveID(t *testing.T) {
|
||||||
|
j, _ := inboundFixture()
|
||||||
|
j.remember("Q1")
|
||||||
|
delete(j.port25, "Q1") // its verdict came
|
||||||
|
for i := 0; i < inboundQueueIDs-2; i++ {
|
||||||
|
j.remember("F" + time.Duration(i).String())
|
||||||
|
}
|
||||||
|
j.remember("Q1") // reused; order is now full, with the stale Q1 first
|
||||||
|
j.remember("LAST")
|
||||||
|
if _, ok := j.port25["Q1"]; !ok {
|
||||||
|
t.Error("the live Q1 was dropped by its stale arrival")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Between the six-hourly prunes the tailer has the journal cut to its cap
|
||||||
|
// itself, every so many rows: refusals come at the rate strangers send them.
|
||||||
|
func TestInboundCutsTheJournalAsItWrites(t *testing.T) {
|
||||||
|
j, st := inboundFixture()
|
||||||
|
for i := 0; i < inboundPruneEvery*2+10; i++ {
|
||||||
|
j.handle(unknownRcp, tailNow)
|
||||||
|
}
|
||||||
|
if len(st.pruned) != 2 || st.pruned[0] != inboundSpamLogMaxRows {
|
||||||
|
t.Errorf("cuts = %v, want two, to %d rows", st.pruned, inboundSpamLogMaxRows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The queue ids waiting for a verdict are bounded.
|
// The queue ids waiting for a verdict are bounded.
|
||||||
func TestInboundQueueIDsAreBounded(t *testing.T) {
|
func TestInboundQueueIDsAreBounded(t *testing.T) {
|
||||||
j, _ := inboundFixture()
|
j, _ := inboundFixture()
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ const (
|
|||||||
// which os.SameFile cannot answer across a restart.
|
// which os.SameFile cannot answer across a restart.
|
||||||
fingerprintSize = 512
|
fingerprintSize = 512
|
||||||
// persistInterval throttles the offset write. Losing up to this much
|
// persistInterval throttles the offset write. Losing up to this much
|
||||||
// progress on a crash only means re-parsing a few lines (UpdateStatus is
|
// progress on a crash means re-parsing a few lines, which is much cheaper
|
||||||
// idempotent), which is much cheaper than a database write per poll tick.
|
// than a database write per poll tick. For a delivery status that changes
|
||||||
|
// nothing (UpdateStatus is idempotent); a refusal in those lines gets a
|
||||||
|
// second row in the inbound journal.
|
||||||
persistInterval = 5 * time.Second
|
persistInterval = 5 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,8 +48,9 @@ type tracker struct {
|
|||||||
// state whose fingerprint still matches means continue from it, parsing the
|
// state whose fingerprint still matches means continue from it, parsing the
|
||||||
// tail written while the panel was down; anything else means the file is not
|
// tail written while the panel was down; anything else means the file is not
|
||||||
// the one the offset referred to (rotated, recreated or truncated in the
|
// the one the offset referred to (rotated, recreated or truncated in the
|
||||||
// meantime), so read it from the start. Re-parsing lines already seen is
|
// meantime), so read it from the start. Re-parsing lines already seen writes
|
||||||
// harmless: UpdateStatus writes the same status onto the same row.
|
// the same status onto the same send-log row, and a second inbound journal
|
||||||
|
// row for a refusal among them.
|
||||||
func (t *tracker) resume(f *os.File) int64 {
|
func (t *tracker) resume(f *os.File) int64 {
|
||||||
size, err := fileSize(f)
|
size, err := fileSize(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// authIPAllowed reports whether the authenticated application may submit from
|
// authIPAllowed reports whether the authenticated application may submit from
|
||||||
// the connecting client IP. When the application has no IP restriction, or the
|
// the connecting client IP. When the application has no IP restriction the
|
||||||
// client IP is not known, the check passes. Store errors are fail-open (see
|
// check passes. When it has one and the client's address is not known, the
|
||||||
// overLimit).
|
// check fails: an address list that an unknown address satisfies is no list.
|
||||||
|
// Store errors are fail-open (see overLimit).
|
||||||
func (s *session) authIPAllowed() bool {
|
func (s *session) authIPAllowed() bool {
|
||||||
if s.login == "" || s.clientIP == "" {
|
if s.login == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
a, err := s.rec.ApplicationByLogin(s.login)
|
a, err := s.rec.ApplicationByLogin(s.login)
|
||||||
@@ -26,6 +27,10 @@ func (s *session) authIPAllowed() bool {
|
|||||||
if a.AllowsAuthFromIP(s.clientIP) {
|
if a.AllowsAuthFromIP(s.clientIP) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if s.clientIP == "" {
|
||||||
|
log.Printf("journal-milter: application %q refused — its client IPs are restricted and Postfix gave no client address", s.login)
|
||||||
|
return false
|
||||||
|
}
|
||||||
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
|
log.Printf("journal-milter: application %q refused from %s — client IP not allowed", s.login, s.clientIP)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ type InboundStore interface {
|
|||||||
// of a message that reached end-of-message - that is, one the filter did not
|
// of a message that reached end-of-message - that is, one the filter did not
|
||||||
// refuse; refusals never get this far and are read from mail.log instead.
|
// refuse; refusals never get this far and are read from mail.log instead.
|
||||||
//
|
//
|
||||||
// Like the send-log milter it only watches: every callback continues or
|
// Like the send-log milter it only watches: every callback continues, and
|
||||||
// accepts, and Postfix attaches the socket with default_action=accept, so
|
// Postfix attaches the socket with default_action=accept, so
|
||||||
// neither an error here nor the panel being down holds up inbound mail.
|
// neither an error here nor the panel being down holds up inbound mail.
|
||||||
type inboundSession struct {
|
type inboundSession struct {
|
||||||
milter.NoOpMilter
|
milter.NoOpMilter
|
||||||
@@ -38,7 +38,10 @@ type inboundSession struct {
|
|||||||
// by the sender, and are not read.
|
// by the sender, and are not read.
|
||||||
filter bool
|
filter bool
|
||||||
|
|
||||||
clientIP string // once per connection
|
// clientIP is taken at Connect and again from {client_addr} with every
|
||||||
|
// MAIL FROM: a session the library made after a non-continue reply never
|
||||||
|
// sees Connect (see session.MailFrom in milter.go).
|
||||||
|
clientIP string
|
||||||
|
|
||||||
from string
|
from string
|
||||||
rcpts []string
|
rcpts []string
|
||||||
@@ -58,6 +61,9 @@ func (s *inboundSession) Connect(host, family string, port uint16, addr net.IP,
|
|||||||
// MailFrom starts a message: a connection may carry several.
|
// MailFrom starts a message: a connection may carry several.
|
||||||
func (s *inboundSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
func (s *inboundSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.reset()
|
s.reset()
|
||||||
|
if ip := clientAddr(m); ip != "" {
|
||||||
|
s.clientIP = ip
|
||||||
|
}
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
return milter.RespContinue, nil
|
return milter.RespContinue, nil
|
||||||
}
|
}
|
||||||
@@ -90,11 +96,12 @@ func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Body fires at end-of-message: the message is about to be queued for its
|
// Body fires at end-of-message: the message is about to be queued for its
|
||||||
// upstream.
|
// upstream. "Continue" lets it through as "accept" would, and keeps this
|
||||||
|
// session for the connection's next message.
|
||||||
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
|
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
s.record()
|
s.record()
|
||||||
s.reset()
|
s.reset()
|
||||||
return milter.RespAccept, nil
|
return milter.RespContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *inboundSession) Abort(m *milter.Modifier) error {
|
func (s *inboundSession) Abort(m *milter.Modifier) error {
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ func TestInboundRecordsAcceptedMessage(t *testing.T) {
|
|||||||
resp := message(t, s, "<sender@example.net> SIZE=1200",
|
resp := message(t, s, "<sender@example.net> SIZE=1200",
|
||||||
[]string{"<a@in.example.com>", "b@IN.example.com"},
|
[]string{"<a@in.example.com>", "b@IN.example.com"},
|
||||||
[][2]string{{"Subject", "=?utf-8?Q?Sch=C3=B6n?="}})
|
[][2]string{{"Subject", "=?utf-8?Q?Sch=C3=B6n?="}})
|
||||||
if resp != milter.RespAccept {
|
if resp != milter.RespContinue {
|
||||||
t.Errorf("end-of-message response = %v, want accept", resp)
|
t.Errorf("end-of-message response = %v, want continue", resp)
|
||||||
}
|
}
|
||||||
if len(rec.rows) != 2 {
|
if len(rec.rows) != 2 {
|
||||||
t.Fatalf("rows = %+v, want one per recipient", rec.rows)
|
t.Fatalf("rows = %+v, want one per recipient", rec.rows)
|
||||||
@@ -217,8 +217,8 @@ func TestInboundAcceptsWhenTheStoreFails(t *testing.T) {
|
|||||||
"lookup": {lookupErr: errors.New("boom")},
|
"lookup": {lookupErr: errors.New("boom")},
|
||||||
} {
|
} {
|
||||||
s := inboundSessionFor(t, rec, false)
|
s := inboundSessionFor(t, rec, false)
|
||||||
if resp := message(t, s, "sender@example.net", []string{"a@in.example.com"}, nil); resp != milter.RespAccept {
|
if resp := message(t, s, "sender@example.net", []string{"a@in.example.com"}, nil); resp != milter.RespContinue {
|
||||||
t.Errorf("%s failure: response = %v, want accept", name, resp)
|
t.Errorf("%s failure: response = %v, want continue", name, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,3 +234,46 @@ func TestRspamdVerdictKeepsTheListSymbols(t *testing.T) {
|
|||||||
t.Errorf("detail = %q\nwant %q", detail, want)
|
t.Errorf("detail = %q\nwant %q", detail, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The client address of a connection's later messages comes from the MAIL
|
||||||
|
// macro: a session the library made after a non-continue reply saw no Connect.
|
||||||
|
func TestInboundClientAddressFromTheMailMacro(t *testing.T) {
|
||||||
|
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
|
||||||
|
s := &inboundSession{rec: rec} // no Connect
|
||||||
|
if _, err := s.MailFrom("sender@example.net", mods(map[string]string{"{client_addr}": "IPv6:2001:db8::7"})); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.RcptTo("a@in.example.com", mods(nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.Body(mods(nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(rec.rows) != 1 || rec.rows[0].ClientIP != "2001:db8::7" {
|
||||||
|
t.Errorf("rows = %+v", rec.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No callback of the inbound milter answers anything but "continue": it must
|
||||||
|
// never be the reason a message is held up, and a non-continue reply would
|
||||||
|
// cost it its session.
|
||||||
|
func TestInboundOnlyEverContinues(t *testing.T) {
|
||||||
|
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
|
||||||
|
s := &inboundSession{rec: rec, filter: true}
|
||||||
|
check := func(stage string, resp milter.Response, err error) {
|
||||||
|
t.Helper()
|
||||||
|
if err != nil || resp != milter.RespContinue {
|
||||||
|
t.Errorf("%s: %v, %v", stage, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := s.Connect("mx", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil))
|
||||||
|
check("connect", resp, err)
|
||||||
|
resp, err = s.MailFrom("a@example.net", mods(nil))
|
||||||
|
check("mail", resp, err)
|
||||||
|
resp, err = s.RcptTo("a@in.example.com", mods(nil))
|
||||||
|
check("rcpt", resp, err)
|
||||||
|
resp, err = s.Header("X-Rspamd-Action", "reject", mods(nil))
|
||||||
|
check("header", resp, err)
|
||||||
|
resp, err = s.Body(mods(nil))
|
||||||
|
check("end of message", resp, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ type session struct {
|
|||||||
// valid zero value (no in-flight accounting).
|
// valid zero value (no in-flight accounting).
|
||||||
flight *inflight
|
flight *inflight
|
||||||
|
|
||||||
clientIP string // captured once per connection
|
// clientIP is the client's address. Postfix gives it at Connect, once per
|
||||||
|
// connection, and again with every MAIL FROM as the {client_addr} macro
|
||||||
|
// (milter_mail_macros in build/postfix-config.sh). The second is the one
|
||||||
|
// that can be relied on: see MailFrom.
|
||||||
|
clientIP string
|
||||||
|
|
||||||
login string
|
login string
|
||||||
from string
|
from string
|
||||||
@@ -62,9 +66,8 @@ type session struct {
|
|||||||
reserved []*reservation // level-2 slots held by the current message
|
reserved []*reservation // level-2 slots held by the current message
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect captures the client IP, which comes from the addr parameter rather
|
// Connect captures the client IP of a connection's first message. It is not
|
||||||
// than a macro (the {client_addr} macro was empty in testing). It is the
|
// enough on its own - see MailFrom.
|
||||||
// rate-limit key; here it is recorded for completeness.
|
|
||||||
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
|
||||||
if addr != nil {
|
if addr != nil {
|
||||||
s.clientIP = addr.String()
|
s.clientIP = addr.String()
|
||||||
@@ -79,8 +82,21 @@ func (s *session) Connect(host, family string, port uint16, addr net.IP, m *milt
|
|||||||
// limit is enforced here: over the limit, the message is refused with a 4xx
|
// limit is enforced here: over the limit, the message is refused with a 4xx
|
||||||
// tempfail before recipients are even offered (guide § Rate limiting).
|
// tempfail before recipients are even offered (guide § Rate limiting).
|
||||||
// Enforcement is fail-open — see overLimit.
|
// Enforcement is fail-open — see overLimit.
|
||||||
|
//
|
||||||
|
// The client address is read again here, from the {client_addr} macro. The
|
||||||
|
// milter library replaces the session with a new one after every reply that is
|
||||||
|
// not "continue" - a refusal, or the accept that used to end a message - and
|
||||||
|
// Postfix does not send Connect again on the same connection. A session made
|
||||||
|
// that way never saw the client, so every message of a connection after the
|
||||||
|
// first, and every MAIL FROM after a refused one, used to be judged with no
|
||||||
|
// client address: the level-2 limit and an application's client-address list
|
||||||
|
// both let it through. (Found on 2026-10-12; test/e2e sends two MAIL FROM on
|
||||||
|
// one connection.)
|
||||||
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
func (s *session) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
s.releaseReservations() // a previous transaction that ended without EOM/ABORT
|
||||||
|
if ip := clientAddr(m); ip != "" {
|
||||||
|
s.clientIP = ip
|
||||||
|
}
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
s.login = macro(m, "auth_authen")
|
s.login = macro(m, "auth_authen")
|
||||||
s.rcpts = nil
|
s.rcpts = nil
|
||||||
@@ -113,13 +129,15 @@ func (s *session) Header(name, value string, m *milter.Modifier) (milter.Respons
|
|||||||
|
|
||||||
// Body fires at end-of-message, when the queue-id macro {i} is set and the
|
// Body fires at end-of-message, when the queue-id macro {i} is set and the
|
||||||
// message is about to be committed to the queue. This is where the "queued"
|
// message is about to be committed to the queue. This is where the "queued"
|
||||||
// rows are written. We accept (this milter is done) without ever rejecting.
|
// rows are written. The reply is "continue", which at end-of-message lets the
|
||||||
|
// message through just as "accept" does - and, unlike it, leaves this session
|
||||||
|
// in place for the connection's next message (see MailFrom).
|
||||||
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
func (s *session) Body(m *milter.Modifier) (milter.Response, error) {
|
||||||
s.record(macro(m, "i"))
|
s.record(macro(m, "i"))
|
||||||
// The rows are in the send log now, so the stored count sees this message
|
// The rows are in the send log now, so the stored count sees this message
|
||||||
// and its level-2 slots are no longer needed.
|
// and its level-2 slots are no longer needed.
|
||||||
s.releaseReservations()
|
s.releaseReservations()
|
||||||
return milter.RespAccept, nil
|
return milter.RespContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort ends the current transaction without an end-of-message (client RSET, or
|
// Abort ends the current transaction without an end-of-message (client RSET, or
|
||||||
@@ -143,6 +161,21 @@ func macro(m *milter.Modifier, name string) string {
|
|||||||
return m.Macros["{"+name+"}"]
|
return m.Macros["{"+name+"}"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientAddr is the client's address from the {client_addr} macro, or "" when
|
||||||
|
// Postfix did not send one that reads as an address. An IPv6 address may come
|
||||||
|
// in Sendmail's form, with "IPv6:" in front.
|
||||||
|
func clientAddr(m *milter.Modifier) string {
|
||||||
|
raw := strings.TrimSpace(macro(m, "client_addr"))
|
||||||
|
if len(raw) > 5 && strings.EqualFold(raw[:5], "IPv6:") {
|
||||||
|
raw = raw[5:]
|
||||||
|
}
|
||||||
|
ip := net.ParseIP(raw)
|
||||||
|
if ip == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
// record writes one send-log row per recipient. Failures are logged, never
|
// record writes one send-log row per recipient. Failures are logged, never
|
||||||
// propagated: journalling must not affect mail acceptance (architecture.md §
|
// propagated: journalling must not affect mail acceptance (architecture.md §
|
||||||
// Mail path).
|
// Mail path).
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ func TestBodyAcceptsEvenWhenRecorderFails(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Body returned error, must fail open: %v", err)
|
t.Fatalf("Body returned error, must fail open: %v", err)
|
||||||
}
|
}
|
||||||
if resp != milter.RespAccept {
|
// "Continue" at end-of-message lets the message through, and keeps the
|
||||||
t.Fatalf("Body response = %v, want Accept", resp)
|
// session for the connection's next message.
|
||||||
|
if resp != milter.RespContinue {
|
||||||
|
t.Fatalf("Body response = %v, want Continue", resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,21 +391,77 @@ func TestRateLimitFailsOpenOnCountError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRateLimitNoIPKeyDoesNotApply(t *testing.T) {
|
// A ceiling is on a domain or an application, not on an address: a message that
|
||||||
|
// arrives with no client address is counted like any other. It used to be let
|
||||||
|
// through, which is how the second MAIL FROM of a connection got past a limit.
|
||||||
|
func TestRateLimitAppliesWithoutAClientIP(t *testing.T) {
|
||||||
rec := &fakeRecorder{
|
rec := &fakeRecorder{
|
||||||
limits: map[string]store.RateLimit{
|
limits: map[string]store.RateLimit{
|
||||||
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
store.RateLimitScopeDomain + "|example.com": domainLimit(),
|
||||||
},
|
},
|
||||||
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
counts: map[string]int64{store.RateLimitScopeDomain + "|example.com": 999},
|
||||||
}
|
}
|
||||||
// A session with no client IP (e.g. local submission) cannot be keyed.
|
|
||||||
s := &session{rec: rec}
|
s := &session{rec: rec}
|
||||||
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
resp, err := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"}))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("MailFrom: %v", err)
|
t.Fatalf("MailFrom: %v", err)
|
||||||
}
|
}
|
||||||
if resp != milter.RespContinue {
|
if resp != milter.RespTempFail {
|
||||||
t.Fatalf("no-IP session = %v, want Continue", resp)
|
t.Fatalf("over the limit with no client IP = %v, want TempFail", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The milter library hands the milter a fresh session after every reply that
|
||||||
|
// is not "continue", and Postfix sends Connect once per connection. Such a
|
||||||
|
// session knows the client only from the {client_addr} macro of MAIL FROM, and
|
||||||
|
// that has to be enough for an application's client-address list.
|
||||||
|
func TestAuthIPRestrictHoldsForASessionThatNeverSawConnect(t *testing.T) {
|
||||||
|
rec := &fakeRecorder{apps: map[string]store.Application{
|
||||||
|
"app1": {Login: "app1", AuthIPRestrict: true, AuthAllowedIPs: []string{"198.51.100.1"}},
|
||||||
|
}}
|
||||||
|
for name, c := range map[string]struct {
|
||||||
|
macros map[string]string
|
||||||
|
want milter.Response
|
||||||
|
}{
|
||||||
|
"an unlisted address in the macro": {map[string]string{"auth_authen": "app1", "{client_addr}": limitIP}, milter.RespTempFail},
|
||||||
|
"the listed address in the macro": {map[string]string{"auth_authen": "app1", "{client_addr}": "198.51.100.1"}, milter.RespContinue},
|
||||||
|
// No address at all is not a pass: a list an unknown address satisfies is no list.
|
||||||
|
"no client address": {map[string]string{"auth_authen": "app1"}, milter.RespTempFail},
|
||||||
|
"not an address": {map[string]string{"auth_authen": "app1", "{client_addr}": "unknown"}, milter.RespTempFail},
|
||||||
|
} {
|
||||||
|
s := &session{rec: rec} // no Connect
|
||||||
|
resp, err := s.MailFrom("a@example.com", mods(c.macros))
|
||||||
|
if err != nil || resp != c.want {
|
||||||
|
t.Errorf("%s: %v, %v; want %v", name, resp, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An application with no list is not held up by a missing address.
|
||||||
|
open := &fakeRecorder{apps: map[string]store.Application{"app1": {Login: "app1"}}}
|
||||||
|
s := &session{rec: open}
|
||||||
|
if resp, _ := s.MailFrom("a@example.com", mods(map[string]string{"auth_authen": "app1"})); resp != milter.RespContinue {
|
||||||
|
t.Errorf("no list, no address: %v, want Continue", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The macro wins over what Connect said, and reads both spellings of IPv6.
|
||||||
|
func TestClientAddrFromTheMailMacro(t *testing.T) {
|
||||||
|
for raw, want := range map[string]string{
|
||||||
|
"203.0.113.7": "203.0.113.7", "2001:db8::1": "2001:db8::1", "IPv6:2001:DB8::1": "2001:db8::1",
|
||||||
|
"ipv6:::1": "::1", "": "", "unknown": "", "IPv6:": "",
|
||||||
|
} {
|
||||||
|
if got := clientAddr(mods(map[string]string{"{client_addr}": raw})); got != want {
|
||||||
|
t.Errorf("clientAddr(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := &session{rec: &fakeRecorder{}}
|
||||||
|
_, _ = s.Connect("h", "tcp4", 0, net.ParseIP("192.0.2.1"), mods(nil))
|
||||||
|
_, _ = s.MailFrom("a@example.com", mods(map[string]string{"client_addr": "192.0.2.2"}))
|
||||||
|
if s.clientIP != "192.0.2.2" {
|
||||||
|
t.Errorf("clientIP = %q, want the macro's", s.clientIP)
|
||||||
|
}
|
||||||
|
_, _ = s.MailFrom("a@example.com", mods(nil))
|
||||||
|
if s.clientIP != "192.0.2.2" {
|
||||||
|
t.Errorf("clientIP = %q after a MAIL FROM with no macro, want it kept", s.clientIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,9 @@ import (
|
|||||||
// reaches the send log (or is abandoned) — see inflight for why the stored
|
// reaches the send log (or is abandoned) — see inflight for why the stored
|
||||||
// count alone is not enough.
|
// count alone is not enough.
|
||||||
func (s *session) overLimit() bool {
|
func (s *session) overLimit() bool {
|
||||||
if s.clientIP == "" {
|
// A ceiling is on a domain or an application, whatever address the mail
|
||||||
return false // no client IP to key on; level-2 does not apply
|
// comes from, so the client address is not asked for here. It used to be,
|
||||||
}
|
// and a message that arrived without one was let through uncounted.
|
||||||
|
|
||||||
if s.login != "" {
|
if s.login != "" {
|
||||||
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
rl, ok, err := s.rec.RateLimit(store.RateLimitScopeApp, s.login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
// CHECK constraint of inbound_spam_log.
|
// CHECK constraint of inbound_spam_log.
|
||||||
const (
|
const (
|
||||||
SpamEngineRspamd = "rspamd" // the external filter
|
SpamEngineRspamd = "rspamd" // the external filter
|
||||||
SpamEnginePostfix = "postfix" // Postfix's own policy: unknown recipient, size, rate
|
SpamEnginePostfix = "postfix" // Postfix's own policy: an unknown recipient, a sender domain that does not exist
|
||||||
SpamEngineSelfPost = "selfpost" // nobody: relayed with no filter attached
|
SpamEngineSelfPost = "selfpost" // nobody: relayed with no filter attached
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,9 +71,11 @@ func (s *Store) PruneInboundSpam(cutoff time.Time, keep int) (int64, error) {
|
|||||||
return 0, fmt.Errorf("prune inbound_spam_log by age: %w", err)
|
return 0, fmt.Errorf("prune inbound_spam_log by age: %w", err)
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
n, _ := res.RowsAffected()
|
||||||
|
// Oldest by when it happened, not by id: the tailer dates a refusal by its
|
||||||
|
// log line, so after the panel was down the newest ids are old rows.
|
||||||
res, err = s.db.Exec(
|
res, err = s.db.Exec(
|
||||||
`DELETE FROM inbound_spam_log WHERE id <=
|
`DELETE FROM inbound_spam_log WHERE id IN
|
||||||
(SELECT id FROM inbound_spam_log ORDER BY id DESC LIMIT 1 OFFSET ?)`, keep)
|
(SELECT id FROM inbound_spam_log ORDER BY created_at DESC, id DESC LIMIT -1 OFFSET ?)`, keep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, fmt.Errorf("prune inbound_spam_log by count: %w", err)
|
return n, fmt.Errorf("prune inbound_spam_log by count: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,3 +284,29 @@ func TestListSpamListHits(t *testing.T) {
|
|||||||
t.Errorf("no marks: %v, %v", hits, err)
|
t.Errorf("no marks: %v, %v", hits, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The cap removes the oldest by when it happened. After the panel was down the
|
||||||
|
// tailer writes old refusals with the newest ids, and they are not what stays.
|
||||||
|
func TestPruneInboundSpamCapIsByTime(t *testing.T) {
|
||||||
|
st := openTestStore(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, r := range []struct {
|
||||||
|
to string
|
||||||
|
age time.Duration
|
||||||
|
}{{"new-1", time.Minute}, {"new-2", 2 * time.Minute}, {"backdated-old", 5 * time.Hour}} {
|
||||||
|
err := st.InsertInboundSpam(InboundSpamEntry{
|
||||||
|
InboundDomain: "in.example.com", ClientIP: "203.0.113.7", To: r.to,
|
||||||
|
Decision: SpamDecisionReject, Engine: SpamEnginePostfix, At: now.Add(-r.age),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, err := st.PruneInboundSpam(time.Time{}, 2); err != nil || n != 1 {
|
||||||
|
t.Fatalf("prune = %d, %v", n, err)
|
||||||
|
}
|
||||||
|
rows, err := st.QueryInboundSpam(InboundSpamFilter{}, 10, 0)
|
||||||
|
if err != nil || len(rows) != 2 || rows[0].To != "new-1" || rows[1].To != "new-2" {
|
||||||
|
t.Errorf("left = %+v, %v; want the two newest by time", rows, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ var inboundTopics = []HelpTopic{
|
|||||||
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
|
", so its row has no subject, and a message refused after its text was sent is one row however many recipients it had. A refusal to relay for a domain that is not an inbound one is every scanner on the internet and is not recorded. Rows are kept for the number of days set in Settings, and never more than 10 000."),
|
||||||
}},
|
}},
|
||||||
{ID: HelpFilterLists, Title: "Filter lists", Body: []Text{
|
{ID: HelpFilterLists, Title: "Filter lists", Body: []Text{
|
||||||
Rich("Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. When entries of both lists match one message, ",
|
Rich("Two lists for the whole server that correct the spam filter by hand. An entry is a sender address, a sender domain — that domain, not its subdomains — or a client address or network in CIDR form. A sender on the Deny list is refused outright. A sender on the Allow list is given a weight no spam score outweighs, so the filter lets it through. The sender's address is whatever the sending server says it is - anyone can claim to be a sender you allow - so allow a client address or network where you can, and a sender address only for mail you would rather receive forged than lose. When entries of both lists match one message, ",
|
||||||
Strong("Deny wins"), ".",
|
Strong("Deny wins"), ".",
|
||||||
Br(), Br(),
|
Br(), Br(),
|
||||||
"SelfPost does not apply the lists itself: it writes them as files under ", Code("/data/antispam/"),
|
"SelfPost does not apply the lists itself: it writes them as files under ", Code("/data/antispam/"),
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (p *InLog) NewRow(in InLogRowInput) InLogRow {
|
|||||||
Time: FormatLogTime(in.At), From: orDash(in.From), Client: in.Client, To: in.To, Subject: in.Subject,
|
Time: FormatLogTime(in.At), From: orDash(in.From), Client: in.Client, To: in.To, Subject: in.Subject,
|
||||||
Why: Sheet{
|
Why: Sheet{
|
||||||
ID: "why-" + strconv.FormatInt(in.ID, 10), Tag: Tag{Status: in.Decision}, Title: "Why",
|
ID: "why-" + strconv.FormatInt(in.ID, 10), Tag: Tag{Status: in.Decision}, Title: "Why",
|
||||||
Rows: whyRows(in.Engine, in.Detail, in.Subject),
|
Rows: whyRows(in.Decision, in.Engine, in.Detail, in.Subject),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if p.IsGlobal {
|
if p.IsGlobal {
|
||||||
@@ -157,15 +157,15 @@ func (p *InLog) NewRow(in InLogRowInput) InLogRow {
|
|||||||
|
|
||||||
// whyRows is the content of a row's sheet: who decided, what they said, and
|
// whyRows is the content of a row's sheet: who decided, what they said, and
|
||||||
// the subject whole, which the table clips. What was said has one of two
|
// the subject whole, which the table clips. What was said has one of two
|
||||||
// shapes, told apart by how it starts. The inbound journal milter writes the
|
// shapes, and the decision says which. Mail that passed was written by the
|
||||||
// filter's verdict on mail that passed - an action and a score, then the
|
// inbound journal milter with the filter's verdict - an action and a score,
|
||||||
// symbols after a semicolon. The log tailer writes the reply a refusal was
|
// then the symbols after a semicolon. Mail that was refused or put off was
|
||||||
// given, which starts with its code.
|
// read from mail.log by the tailer, with the reply it was given.
|
||||||
func whyRows(engine, detail, subject string) []SheetRow {
|
func whyRows(decision, engine, detail, subject string) []SheetRow {
|
||||||
rows := []SheetRow{{Label: "Decided by", Lines: []string{engine}}}
|
rows := []SheetRow{{Label: "Decided by", Lines: []string{engine}}}
|
||||||
switch {
|
switch {
|
||||||
case detail == "":
|
case detail == "":
|
||||||
case detail[0] >= '0' && detail[0] <= '9':
|
case decision == "reject" || decision == "tempfail":
|
||||||
rows = append(rows, SheetRow{Label: "Reply", Lines: []string{detail}, Mono: true})
|
rows = append(rows, SheetRow{Label: "Reply", Lines: []string{detail}, Mono: true})
|
||||||
default:
|
default:
|
||||||
verdict, symbols, _ := strings.Cut(detail, "; ")
|
verdict, symbols, _ := strings.Cut(detail, "; ")
|
||||||
|
|||||||
@@ -284,18 +284,20 @@ func TestWhyRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, "; ")
|
return strings.Join(out, "; ")
|
||||||
}
|
}
|
||||||
for _, c := range []struct{ engine, detail, subject, want string }{
|
for _, c := range []struct{ decision, engine, detail, subject, want string }{
|
||||||
{"selfpost", "", "", "Decided by=selfpost"},
|
{"accept", "selfpost", "", "", "Decided by=selfpost"},
|
||||||
{"selfpost", "", "Hello", "Decided by=selfpost; Subject=Hello"},
|
{"accept", "selfpost", "", "Hello", "Decided by=selfpost; Subject=Hello"},
|
||||||
{"rspamd", "no action, score 0.40 / 15.00", "Hi", "Decided by=rspamd; Verdict=no action, score 0.40 / 15.00; Subject=Hi"},
|
{"accept", "rspamd", "no action, score 0.40 / 15.00", "Hi", "Decided by=rspamd; Verdict=no action, score 0.40 / 15.00; Subject=Hi"},
|
||||||
{"rspamd", "add header, score 9.80 / 15.00; A(5.00) B(-0.10)", "", "Decided by=rspamd; Verdict=add header, score 9.80 / 15.00; Symbols=A(5.00)|B(-0.10)"},
|
{"accept", "rspamd", "add header, score 9.80 / 15.00; A(5.00) B(-0.10)", "", "Decided by=rspamd; Verdict=add header, score 9.80 / 15.00; Symbols=A(5.00)|B(-0.10)"},
|
||||||
// a verdict with symbols and no action or score
|
// a verdict with symbols and no action or score
|
||||||
{"rspamd", "A(5.00)", "", "Decided by=rspamd; Verdict=A(5.00)"},
|
{"quarantine", "rspamd", "A(5.00)", "", "Decided by=rspamd; Verdict=A(5.00)"},
|
||||||
{"rspamd", "5.7.1 Spam message rejected", "", "Decided by=rspamd; Reply=5.7.1 Spam message rejected"},
|
{"reject", "rspamd", "5.7.1 Spam message rejected", "", "Decided by=rspamd; Reply=5.7.1 Spam message rejected"},
|
||||||
{"postfix", "450 4.1.8 <a@nx.example>: Sender address rejected; see x", "", "Decided by=postfix; Reply=450 4.1.8 <a@nx.example>: Sender address rejected; see x"},
|
{"tempfail", "postfix", "450 4.1.8 <a@nx.example>: Sender address rejected; see x", "", "Decided by=postfix; Reply=450 4.1.8 <a@nx.example>: Sender address rejected; see x"},
|
||||||
|
// a refusal's text is a reply whatever it starts with
|
||||||
|
{"reject", "rspamd", "milter triggers DISCARD action", "", "Decided by=rspamd; Reply=milter triggers DISCARD action"},
|
||||||
} {
|
} {
|
||||||
if got := flat(whyRows(c.engine, c.detail, c.subject)); got != c.want {
|
if got := flat(whyRows(c.decision, c.engine, c.detail, c.subject)); got != c.want {
|
||||||
t.Errorf("whyRows(%q, %q, %q)\n got: %s\nwant: %s", c.engine, c.detail, c.subject, got, c.want)
|
t.Errorf("whyRows(%q, %q, %q, %q)\n got: %s\nwant: %s", c.decision, c.engine, c.detail, c.subject, got, c.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ func testLevel2RateLimit(t *testing.T, sc *scenario) {
|
|||||||
second.dialErr, second.authErr, second.rcptErr, second.dataErr)
|
second.dialErr, second.authErr, second.rcptErr, second.dataErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The limit holds for every message of a connection, not for its first
|
||||||
|
// only. The milter library hands the milter a fresh session after any reply
|
||||||
|
// that is not "continue" - the refusal above is one - and Postfix does not
|
||||||
|
// introduce the client again, so a second MAIL FROM on the same connection
|
||||||
|
// used to arrive with no client address and was let through (found by the
|
||||||
|
// technical review of 2026-10-12; the same hole let an application past its
|
||||||
|
// client-address list).
|
||||||
|
if errs := mailTwiceOnOneConnection(login, password, from); errs[0] == nil || errs[1] == nil {
|
||||||
|
t.Fatalf("over the level-2 limit, two MAIL FROM on one connection were answered %v and %v, want both refused", errs[0], errs[1])
|
||||||
|
}
|
||||||
|
|
||||||
if err := waitFor("a rejected row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
if err := waitFor("a rejected row for l2app in the send log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||||
rows, err := sc.panel.sendLogRows(senderDomain, login)
|
rows, err := sc.panel.sendLogRows(senderDomain, login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -140,3 +140,35 @@ func buildMessage(a sendAttempt) string {
|
|||||||
a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body,
|
a.from, a.to, a.subject, time.Now().UTC().Format(time.RFC1123Z), a.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mailTwiceOnOneConnection authenticates once and sends MAIL FROM twice on the
|
||||||
|
// same connection, with a RSET between, and returns how each was answered. No
|
||||||
|
// message is sent: this is about what the server says to the second
|
||||||
|
// transaction of a connection whose first was refused.
|
||||||
|
func mailTwiceOnOneConnection(login, password, from string) [2]error {
|
||||||
|
var out [2]error
|
||||||
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", smtpsAddr, &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
ServerName: selfpostHostname,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return [2]error{nil, nil}
|
||||||
|
}
|
||||||
|
c, err := smtp.NewClient(conn, selfpostHostname)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return [2]error{nil, nil}
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Hello("e2e-client.e2e.test"); err != nil {
|
||||||
|
return [2]error{nil, nil}
|
||||||
|
}
|
||||||
|
if err := c.Auth(smtp.PlainAuth("", login, password, selfpostHostname)); err != nil {
|
||||||
|
return [2]error{nil, nil}
|
||||||
|
}
|
||||||
|
out[0] = c.Mail(from)
|
||||||
|
_ = c.Reset()
|
||||||
|
out[1] = c.Mail(from)
|
||||||
|
_ = c.Quit()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user