Commit Graph
3 Commits
Author SHA1 Message Date
mixandClaude Opus 5.5 5f86457848 milter: the level-2 limit and the client IP list hold for every message
Security fix, outside the inbound antispam feature. Found by its
technical review (Opus, 2026-10-12), reproduced on the test stand
before the fix: over an application's level-2 limit, two MAIL FROM on
one connection were answered 451 and then accepted.

Cause. Postfix introduces a client to a milter once per connection.
The milter library (go-milter 0.4.1, session.go) replaces the milter's
session after any reply that is not "continue" - a refusal, or the
accept that used to end a message. The new session never saw Connect,
so it had no client address, and both checks passed a message without
one: overLimit returned early ("no client IP to key on") and
authIPAllowed did the same. So every message after the first on a
connection, and every MAIL FROM after a refused one, skipped the
level-2 limit and an application's client IP list. In the code since
1.9.0 for the list and longer for the limit.

Fix.
  - build/postfix-config.sh: milter_mail_macros carries {client_addr},
    so the address comes with every MAIL FROM; MailFrom reads it (both
    spellings of IPv6) in the journal milter and the inbound journal
    milter.
  - overLimit no longer asks for a client address: a ceiling is on a
    domain or an application.
  - authIPAllowed refuses an application that has a client IP list when
    no address is known.
  - Body replies "continue" in both milters, which lets the message
    through as "accept" did and keeps the session.

The inbound journal milter had the same cause with a smaller effect:
rows of a connection's later messages had no client address.

In the same file, the checks of INBOUND_ANTISPAM_MILTER and
INBOUND_JOURNAL_MILTER_SOCKET refuse any character outside their set
first: a glob's "*" matched anything after the first characters.

Tests: a session that never saw Connect is judged by the macro, a
listed and an unlisted address, no address, not an address; the limit
with no client address; the inbound milter answers only "continue".
test/e2e sends two MAIL FROM on one connection over the limit and
wants both refused.

Checked on the test server: e2e red before the fix on exactly that
check, green after; go vet ./... ok, go test ./... ok (22 packages).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:51:50 +03:00
mixandClaude Opus 5.5 7192c3861e inbound-antispam-panel: Filter lists, kind column, matches, note on a sheet
Follows the design commit before it (owner's words quoted there).

The lists' tables are Value, Kind, Matched and Remove. The kind tag
opens the entry's sheet: note, day added, how often it decided a
message in the days the log keeps, and the last match.

Matches are read off the journal on each view, nothing is stored
(inbound.Lists.Usage, store.ListSpamListHits). A row counts for an
entry when the list decided the message - a rejected row whose reply is
the deny rule's message, an accepted row whose verdict carries the
allow rule's symbol for that kind - and the entry fits the row's sender
or client address the way rspamd matches it: an address whole, a domain
as the sender's domain, a network containing the client (an
IPv4-mapped address as IPv4). A failure to count shows the lists with
"never" and logs why.

For that the journal milter keeps the lists' own symbols ahead of its
six-symbol cap: rspamd writes symbols heaviest first, so an allow
entry's -100 came last and was cut behind any six others.

The plan gains "Filter list usage": what counting from the journal
implies, and - the owner asked for it in writing - the conditions under
which a counter kept with each entry becomes worth a migration (the
journal cut by its row cap and not by age; a short retention; counts
that must outlive the journal; a slow page). multimap.conf says which
of its texts SelfPost reads back.

Tests: Usage over every kind on both lists and the rows that are
nobody's; the milter's symbol order; the store query; the page and its
sheets; a handler test from journal rows to the numbers on the page.

Seen: the fixture page with a sheet open, headless Edge at 1360 px -
the same as the accepted drawing.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.

Not seen in a real run: a count above zero. It needs an entry added in
the panel, which I cannot sign in to, and then mail that the list
decides; in particular the deny rule's message arriving in mail.log as
the reply of a milter-reject line is what the trial with rspamc showed,
not yet what Postfix logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 01:08:26 +03:00
mixandClaude Opus 5.5 28ec784ae2 inbound-antispam-panel: inbound journal milter on port 25
Step 3/12 of docs/plans/inbound-antispam-panel.md.

The panel serves a second milter while the inbound relay is on
(internal/milter/inbound.go, socket INBOUND_JOURNAL_MILTER_SOCKET,
default /run/selfpost/inbound-journal.sock). build/postfix-config.sh
puts it last on port 25, after the optional antispam milter, with
default_action=accept; postfix-wrapper.sh waits for its socket. At
end-of-message it writes one inbound_spam_log row per recipient at an
inbound domain - other port-25 recipients (DMARC report addresses) get
none - and always accepts. With a filter attached it reads the verdict
from X-Rspamd-Action and X-Spamd-Result (action, score, up to six
weighted symbols, only what the patterns recognise, 255 bytes); without
one those headers are the sender's and are not read. The plan named
X-Rspamd-Score, which rspamd does not have.

The antispam compose fragment mounts rspamd/milter_headers.conf into
the sidecar so that it adds the two headers (checked in rspamd 3.11:
both routines exist and remove a sender's own headers of those names).

Store: InsertInboundSpam, IsInboundDomain.

Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok - the relayed message of
inbound_relay_delivers_and_refuses has its journal row (accept by
selfpost) and the DMARC report sent through the same listener has none.

Not yet seen in a real run: a verdict written by rspamd reaching this
milter. The e2e stand has no filter, and the test server's only inbound
domain has no upstream, so it accepts no mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:18:14 +03:00