inbound-antispam-panel: findings of the technical and security reviews
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.
Log tailer (internal/logtail/inbound.go):
- the reply of a refusal ends at the first "; from=<", Postfix's own,
and the HELO name after it is whatever is left. Taking the longest
reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
supply the row's sender, recipient and reply - and so make a
refusal read as the deny list's;
- an address with a quote, a bracket or a space is not kept: a quoted
sender could otherwise supply the recipient;
- the journal is cut to its row cap every 500 rows written, not only
every six hours;
- a queue id is forgotten when the message is queued or the id is
opened by another listener, and a stale place in the queue of
arrivals no longer drops a live id;
- milter-hold lines are not read: the held message reaches the
journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.
Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).
docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.
Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ Managed programs ([build/supervisord.conf](../build/supervisord.conf)):
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `cert-reload` | root | 400 | Daily `postfix reload` for renewed TLS certs |
|
||||
| `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
|
||||
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
|
||||
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
|
||||
older than the `inbound_spam_log_retention_days` setting, then down to
|
||||
10 000.
|
||||
|
||||
+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
|
||||
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
|
||||
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.
|
||||
|
||||
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);
|
||||
- **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
|
||||
configured.
|
||||
@@ -260,7 +262,9 @@ flowchart LR
|
||||
## Security
|
||||
|
||||
- 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).
|
||||
- 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] [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)
|
||||
- [ ] Technical review of inbound antispam changes — **Opus**
|
||||
- [ ] Security review inbound antispam path — **Fable**
|
||||
- [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)
|
||||
- [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) |
|
||||
| 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) |
|
||||
| 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 | — | — |
|
||||
|
||||
+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
|
||||
(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
|
||||
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** —
|
||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||
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 › 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 › 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 |
|
||||
| Account, Help | yes | yes |
|
||||
|
||||
@@ -178,6 +194,32 @@ operator can act on it.
|
||||
refuse unknown addresses at RCPT (no backscatter). OpenDKIM is not attached
|
||||
on inbound. An optional antispam milter is inbound-only; default action is
|
||||
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
|
||||
`encrypt`).
|
||||
- `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
|
||||
in the journal milter (`internal/milter/authip.go`) with
|
||||
`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** —
|
||||
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
|
||||
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
|
||||
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.**
|
||||
The journal milter cannot observe connection-close directly through
|
||||
go-milter, so if a client disconnects without an explicit `Abort`, its
|
||||
|
||||
Reference in New Issue
Block a user