from-header-binding: agreed, taken into work
The design questions were closed with the owner on 2026-10-12, one at a time; the plan records what he chose and what I had proposed instead. - The rule: From: is held to what the envelope is held to - any address of the application's domain, or the addresses of its list. Anything else is refused, a domain this server knows nothing of included (I had proposed to refuse only names the server signs for). - The check is the panel's and strict: while the panel does not answer, Postfix defers mail. It gets a socket of its own so that the journal stays fail-open, and reads Postfix's own file of sender bindings. - Server > Health counts the panel's downtime under a running Postfix and the mail deferred for it, to decide later whether the check needs a process of its own. - A refusal is 550 5.7.1, and its reason is the row's status tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Plan: from-header-binding
|
||||
|
||||
**Status:** agreed 2026-10-12 - the design questions were closed with the owner one at a time and he took it into work
|
||||
**Date:** 2026-10-12
|
||||
**Version:** `2.4.0` MINOR (one migration; it refuses mail that was accepted before).
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
An application sends only as itself - in the `From:` line a mail program
|
||||
shows and OpenDKIM chooses the key by, not only in the envelope.
|
||||
|
||||
Until now Postfix held the envelope sender against the SASL login and nothing
|
||||
held the header. An application of one sending domain could write another
|
||||
registered domain, or the server's own name, into `From:` and have the message
|
||||
signed with that name's key (security review of panel-notifications,
|
||||
2026-10-12).
|
||||
|
||||
## Decisions (owner, 2026-10-12)
|
||||
|
||||
### The rule
|
||||
|
||||
**`From:` is held to what the envelope is held to.** An application set to
|
||||
any address of its domain may write any address of that domain; one with a
|
||||
list of addresses, only those. Anything else is refused: another registered
|
||||
domain, the server's name, an address at a domain this server knows nothing
|
||||
of, no `From:` at all.
|
||||
|
||||
I proposed the narrower rule - refuse only a `From:` that names a domain this
|
||||
server signs for, and let a stranger's address through unsigned as before -
|
||||
and, for an application with a list, to check the domain alone. The owner
|
||||
chose the wider one both times. What it stops working: a form that puts its
|
||||
visitor's address into `From:`, and an application with a list whose envelope
|
||||
address and header address differ, until the second is added to the list.
|
||||
|
||||
When the hostname is itself a sending domain its applications write
|
||||
`…@<hostname>` by right, as the guide already says.
|
||||
|
||||
### Where it is checked, and what happens when it cannot be
|
||||
|
||||
**The panel checks, and the check is strict:** while the panel does not
|
||||
answer, Postfix replies with a temporary error and accepts nothing unchecked.
|
||||
|
||||
I first proposed the opposite (fail-open, as the journal and the level-2 limit
|
||||
are) and then a small process of its own. The owner's objection to the first:
|
||||
a check that is absent while the panel is down does not close the hole, and
|
||||
the panel only has to be slow - 15 seconds - for Postfix to go on without it.
|
||||
His answer to the second: not yet; measure first.
|
||||
|
||||
So the check is a role of the panel process on a socket of its own, attached
|
||||
with `default_action=tempfail`. The journal milter keeps its socket and stays
|
||||
fail-open: a slow database still does not defer mail. The check reads no
|
||||
database - it reads the file of sender bindings Postfix itself reads for the
|
||||
envelope, so the two cannot disagree - and moving it into a process of its own
|
||||
later is a change of where it is started.
|
||||
|
||||
### Statistics, to decide whether the check needs its own process
|
||||
|
||||
Per day, kept a year:
|
||||
|
||||
- **for how long the panel did not answer while Postfix was running.** The
|
||||
panel marks itself alive every few seconds; at start the gap since the last
|
||||
mark is its downtime, counted when Postfix was not restarted within it (a
|
||||
rollout or a reboot stops mail for another reason);
|
||||
- **how many messages were deferred because the check did not answer**, read
|
||||
from `mail.log`. This one also sees a panel that is running and not
|
||||
answering, which the mark does not.
|
||||
|
||||
Shown on **Server › Health** in a card of its own, *Sender check*: today, the
|
||||
last 30 days, the last 12 months. The card warns when a message was deferred
|
||||
in the last 24 hours; downtime that deferred nothing warns of nothing.
|
||||
|
||||
### What a refusal looks like
|
||||
|
||||
- **To the application:** `550 5.7.1`, permanent, naming the address. A
|
||||
level-2 limit answers 4xx because the message will pass in an hour; a wrong
|
||||
`From:` will not, and a 4xx would have the application send it again for
|
||||
days.
|
||||
- **In the Outbound log:** a row with the reason in the status tag -
|
||||
`wrong From`, beside `rate-limited` and the tag of a refused client address.
|
||||
The built panel wrote `rejected` for all of them; it follows the mockup now.
|
||||
The status filter keeps one entry, `rejected`.
|
||||
- **On the message's page:** a fact *From: line* with the address written in
|
||||
the header, and a History step that says which address was refused, for
|
||||
which application, what that application may write, and that it was told
|
||||
`550 5.7.1`.
|
||||
|
||||
### Health, re-laid
|
||||
|
||||
The owner on the first picture of the new card: too many holes on the screen,
|
||||
the numbers should follow importance, and the boxes should read left to right
|
||||
and top to bottom - with stacked columns the third box was below the fold and
|
||||
the fourth was not.
|
||||
|
||||
- Order: 01 Processes, 02 Milter sockets, 03 Sender check, 04 TLS
|
||||
certificate, 05 Hostname and reverse DNS, 06 Notification mail, 07 Machine,
|
||||
08 Configuration.
|
||||
- Layout `cards` (new, on the components page): two stacks, odd numbers on the
|
||||
left and even on the right, each closed up, so a short box leaves no gap
|
||||
beside a tall one and the eye still goes 01, 02, 03. On a phone the boxes
|
||||
come in the order of their numbers.
|
||||
|
||||
## To settle while building
|
||||
|
||||
- How the header is read so that the check and OpenDKIM cannot disagree: one
|
||||
`From:` field, one address, and a cruder second look at the raw value.
|
||||
- What Postfix writes in `mail.log` when a strict milter does not answer, and
|
||||
how a line is told to be this milter's and not OpenDKIM's.
|
||||
- How the panel learns when Postfix started.
|
||||
- Schema: the reason of a refusal and the header address on a send-log row;
|
||||
the table of days.
|
||||
- That a refusal for `From:` is not taken for a refusal under a limit by the
|
||||
notifier.
|
||||
|
||||
## Implementation checklist
|
||||
|
||||
- [x] Mockups: Health card and layout, the log row, help topic; accepted by the owner 2026-10-12 (`design:` commit)
|
||||
- [ ] Migration and store: refusal reason and header address, days of the sender check
|
||||
- [ ] The check: a role of the panel on its own socket, bindings read from Postfix's file; Postfix attaches it strictly; start order
|
||||
- [ ] Statistics: the alive mark and downtime, deferred mail from `mail.log`
|
||||
- [ ] Panel: Health (card, sockets row, layout), Outbound log tags, the message's page, help
|
||||
- [ ] guide.md, architecture.md, security.md (the accepted risk goes), schema-migrations.md
|
||||
- [ ] `go vet`, `go test`, e2e: refused for another domain's `From:`, for the hostname's and for an address off the list; accepted for its own
|
||||
- [ ] Technical review — **Opus**
|
||||
- [ ] Security review — **Fable**
|
||||
+13
-18
@@ -39,7 +39,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md).
|
||||
| delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) |
|
||||
| password-reset | Password reset by e-mail | candidate | — | — |
|
||||
| phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — |
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | candidate | — | — |
|
||||
| from-header-binding | Hold a message's `From:` header against the application that sends it | in progress, for `2.4.0` | 1/9 | [plans/from-header-binding.md](plans/from-header-binding.md) |
|
||||
| panel-redesign | Panel redesign (accepted mockups, design contract) | shipped `2.0.0` | 27/27 | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
| schema-squash | Squash SQLite migrations into a 2.x baseline | built in panel-redesign (stage 1) | — | [plans/panel-redesign.md](plans/panel-redesign.md) |
|
||||
|
||||
@@ -440,26 +440,21 @@ since 2.3.0, the server's own name - into `From:` and have the message signed
|
||||
with that name's key: mail that passes DMARC as a domain the application has
|
||||
no right to, or that looks like a notification of the panel.
|
||||
|
||||
**Goal:** a message whose `From:` names a domain this server signs for is
|
||||
accepted only from an application of that domain.
|
||||
|
||||
**Boundary:** the journal milter reads `From:` (and `Sender:`) and refuses at
|
||||
end-of-headers when the header's domain is a signing name other than the
|
||||
login's own. Mail whose `From:` is a domain this server does not sign for is
|
||||
not touched - it goes out unsigned today and would after. The journal milter
|
||||
is fail-open, so with it down the hole is as it is now; whether that is
|
||||
enough or the check belongs somewhere stricter is part of the item.
|
||||
**Agreed with the owner on 2026-10-12** and taken into work
|
||||
([plans/from-header-binding.md](plans/from-header-binding.md)). The rule he
|
||||
chose is wider than the one first written here: `From:` is held to exactly
|
||||
what the envelope is held to - any address of the application's domain, or
|
||||
the addresses of its list - and anything else is refused, a stranger's domain
|
||||
included. The check is a role of the panel on a strict socket of its own:
|
||||
while the panel does not answer, mail is deferred, not accepted unchecked.
|
||||
Server › Health counts what that costs, to decide later whether the check
|
||||
needs a process of its own.
|
||||
|
||||
**Done when:** the e2e stand shows an application refused for another
|
||||
tenant's `From:` and for the hostname's, and accepted for its own.
|
||||
tenant's `From:`, for the hostname's and for an address off its list, and
|
||||
accepted for its own.
|
||||
|
||||
**Dependencies / risks:** it refuses mail - an application that today sends
|
||||
with a `From:` of another of the operator's domains stops working until it
|
||||
is given an application of that domain; parsing `From:` differently from
|
||||
OpenDKIM would leave a gap, so the check has to be the cruder one (does the
|
||||
header mention `@<signing name>`).
|
||||
|
||||
**Version:** `2.x` MINOR; `candidate`. Until then:
|
||||
**Version:** `2.4.0` MINOR; in progress. Until it ships:
|
||||
[security.md](security.md) § Accepted risks.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user