findings of the reviews of audit stage 2b
Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.
- Bug: two refusals of one second swapped their NOQUEUE lines (each line
went to the newest lineless row). AttachRefusalLine now matches the
client address too (send_log.client_ip, written by the journal milter -
Postfix's line names the client and the sender, never the login: the
sasl_username part of the pattern never matched and could be fed by HELO,
security F1/F2), takes the nearest row in time and the oldest of equals,
and a line already on a row goes on no other (security F6).
- Waiting lines and recoveries lived only in memory while the saved offset
moved past them: a stop within 30 s lost them. The tracker now never saves
an offset past the oldest waiting item in its file (tracker.hold,
refusalLines.oldest) - for a recovery, the client= line that names its
login - so they are read again; the "once" guards make that harmless.
- A recovered row whose login the tailer did not see was nobody's: its
domain_id falls back to the envelope domain's.
- The fingerprint's file identity is the inode alone: the device number can
change across a host reboot and make the same log look rotated.
- A refused row carries its queue id; UpdateStatus's "newest row" now skips
refused rows, so a later message with that id does not flip it to sent.
- The alive mark: its age is read first and kept from then on, so asking
supervisord for 30 s cannot make it stale; the entrypoint removes it with
the sockets - a container restart is not the check's downtime, and a mark
from before would have counted the whole outage as "≈".
- Security F3: the sender check prints no ";" of the client's in the From:
text of its reply, and the tailer reads the envelope after the first ";"
- Postfix's own text.
- A refused row with a queue id and no stored lines says so (it was told the
mail.log text of 2.2.0); the fact's em dash is the house hyphen.
- Docs: architecture (client address, no login), guide § Health (warns on
either count), help topics log and sender-check (design 1ca87f2).
- e2e: refusals_show_postfixs_line wants the refusal's own sender in the
line; refusal_recovered_from_the_log waits past the tailer's 30 s and
checks that a refusal the panel wrote got no recovered twin.
Not changed, written down: review 9 (mail.log.1 read to its end at that
moment; postlogd may still write to it until the reload), 10 (a log under
512 bytes has no fingerprint, so places fall back to time briefly after a
rotation), 6 (a 451 at MAIL FROM of another milter reads the same - the
client and sender must match too now), security F5/F12 (info). Review 12:
the refused and closed-by-the-panel variants of out-message were shown to
the owner as pictures of the real templates, not drawn in out-message.html:
the guard ties every drawn screen to a page of the engine, and a variant
has the page's outline.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (refusal_recovered_from_the_log 70 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -349,10 +349,18 @@ func testRefusalsShowPostfixsLine(t *testing.T, sc *scenario) {
|
||||
rowRe := regexp.MustCompile(`(?s)<tr>.*?</tr>`)
|
||||
tagRe := regexp.MustCompile(`<span class="tag[^"]*">(wrong From|rate-limited|wrong client)</span>`)
|
||||
linkRe := regexp.MustCompile(`href="(/outbound/log/\d+)[^"]*"`)
|
||||
// Each with the refusal's own sender in Postfix's line: the line of one
|
||||
// application's refusal must not land on another's row (review of
|
||||
// stage 2b; the rate-limited refusals are l2app's, from billing@).
|
||||
want := map[string]string{
|
||||
"wrong From": "milter-reject: END-OF-MESSAGE",
|
||||
"rate-limited": "NOQUEUE: milter-reject: MAIL from",
|
||||
"wrong client": "NOQUEUE: milter-reject: MAIL from",
|
||||
"rate-limited": "NOQUEUE: milter-reject: MAIL from unknown[",
|
||||
"wrong client": "NOQUEUE: milter-reject: MAIL from unknown[",
|
||||
}
|
||||
sender := map[string]string{
|
||||
"wrong From": "",
|
||||
"rate-limited": "from=<billing@" + senderDomain + ">",
|
||||
"wrong client": "from=<alerts@" + senderDomain + ">",
|
||||
}
|
||||
for tag, line := range want {
|
||||
if err := waitFor("the page of a "+tag+" refusal to show Postfix's line", 30*time.Second, time.Second, func() (bool, error) {
|
||||
@@ -370,7 +378,7 @@ func testRefusalsShowPostfixsLine(t *testing.T, sc *scenario) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if strings.Contains(page, line) {
|
||||
if strings.Contains(page, line) && strings.Contains(page, sender[tag]) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
@@ -397,6 +405,14 @@ func asPanel(code string, args ...string) (string, error) {
|
||||
// saved place moved back before the message's first line. Started again, the
|
||||
// panel writes the row from mail.log, marked, and once.
|
||||
func testRefusalRecoveredFromTheLog(t *testing.T, sc *scenario) {
|
||||
// A refusal whose row the panel writes as usual: it must not get a second
|
||||
// row from the log once the wait is over.
|
||||
written := uniqueToken("written")
|
||||
if res := attemptSend(sendAttempt{authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||
from: "alerts@" + senderDomain, headerFrom: "director@" + otherDomain, to: recipient,
|
||||
subject: "e2e " + written, body: "refused, its row written"}); res.refusedAt("DATA", 550, "5.7.1 From:") != nil {
|
||||
t.Fatalf("the written refusal: %v", res.firstErr())
|
||||
}
|
||||
token := uniqueToken("recover")
|
||||
res := attemptSend(sendAttempt{authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||
from: "alerts@" + senderDomain, headerFrom: "director@" + otherDomain, to: recipient,
|
||||
@@ -454,9 +470,16 @@ print(sqlite3.connect("/data/selfpost.db").execute("select count(*), coalesce(su
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Once: the next passes over the log write it no more.
|
||||
time.Sleep(3 * time.Second)
|
||||
// Once: past another wait of the tailer's (30 s), the passes over the log
|
||||
// write it no more, and the refusal the panel wrote itself is still one
|
||||
// row, not recovered.
|
||||
time.Sleep(35 * time.Second)
|
||||
if out, _ := count(); out != "(1, 1)" {
|
||||
t.Fatalf("after more passes the rows are %s, want (1, 1)", out)
|
||||
}
|
||||
out, err := asPanel(`import sqlite3,sys
|
||||
print(sqlite3.connect("/data/selfpost.db").execute("select count(*), coalesce(sum(recovered),0) from send_log where subject like ?",("%"+sys.argv[1]+"%",)).fetchone())`, written)
|
||||
if err != nil || out != "(1, 0)" {
|
||||
t.Fatalf("the refusal the panel wrote: rows (count, recovered) = %s, %v; want (1, 0)", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user