A mail server a person typed is a host the server dials #148
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Blocks
#149 Mail has two halves: the instance's and the person's
Postulo/postulo
Reference
Postulo/postulo#148
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
Prerequisite. The moment a person can type the mail server, the server dials somewhere a
person chose — and the guard that exists for that was written for a different protocol.
What exists
Today the SMTP host is the operator's, and that is why nothing checks it.
Server settings → Email is an administrator's page, overridable by
POSTULO_EMAIL_*in theenvironment. An administrator pointing the instance at
localhost:25is configuring theirown machine, which is not an attack.
The guard for user-chosen destinations exists and is thoughtful, and it is HTTP-only.
plugins/http.pyandplugins/fetching.pybetween them:stranger's page";
the approved one — because a name that answers with one public and one private address
"would otherwise be a way through", and because a name can answer publicly for the check
and privately a moment later;
a Paperless on the LAN is the whole point of a connection.
None of that is on the path
send_mailtakes. A Django mail backend opens a socket to ahost and a port; it does not pass through
check_destination, and it has no equivalent.What this asks for
The same care for a mail server somebody typed that already exists for a URL somebody typed.
Worth being careful about
The three things the HTTP guard does that a naive SMTP connection would not. Refuse
private ranges unless the operator allowed them; check every address the name resolves to,
not the first; and connect to the address that was checked rather than resolving again. The
third is the one people forget and it is the one that matters — the comment in
fetching.pyexplains the window exactly.A mail server is a probe. Connect, read the banner, and the reply tells you whether
something is listening on that host and port. That is a port scanner with a form around it,
available to any account, and the answer is visible because the Test button reports what
happened. Whatever refusal is written has to say the same thing whether the host is
unreachable or forbidden.
The operator's escape hatch already exists and should be reused, not reinvented.
private_destinations_allowed()is the switch that lets connections reach a LAN. A personwhose mail server is on their own network is the same case as their Paperless, and the same
setting should govern both — one decision, made once, by the person who owns the machine.
Credentials get stored, and the machinery for that is already there.
plugins/secrets.pyencrypts connection secrets with a key derived from the instance's, and#111 made a weak key a start-up refusal precisely because of what that key protects. A user's
mail password joins their Paperless password under the same rule; nothing new is needed, and
the threat model should say it is now holding one more class of credential.
Failure has to be legible to the person, not to the server log. An operator debugging
their own SMTP reads the log; a person whose own mail server refused them needs the reason
on the page — and the reason must not leak whether an arbitrary host answered.
postulo.core.destinationsgives SMTP the three propertiesplugins/http.pyalreadygives a URL, and both mail paths go through it — the connection test and the send.
All three, including the third. Private and loopback refused under
POSTULO_CONNECTIONS_ALLOW_PRIVATE, reused rather than reinvented, exactly as the issue asked.Every resolved address checked, not the first. And the connection made to the address that was
approved — which needed the name carried past
smtplib's own idea of what it connected to, orthe certificate would be proved against a number and never match.
plugins/http.pysolves thesame problem with
sni_hostname; this is the same shape.The probe concern is handled by construction. The refusal is decided from the resolved
address before anything is dialled, so a private address gets the same answer whether or not
something is listening — nothing ever finds out. A test holds four different private addresses
to one sentence between them, because a different sentence for any of them would be a map of
the network.
One decision worth stating:
POSTULO_EMAIL_HOSTis exempt. That is the project's ordinaryenvironment-wins rule rather than a hole in this one — it is a line in a file only the operator
can edit, and the default it carries is
localhost. Checking it would refuse the defaultconfiguration of every instance that has never opened the Email page. A host stored from the
page is checked, which is the path #149 opens up.
Upgrade note. An instance whose administrator typed a LAN or loopback mail host on Server
settings → Email — rather than setting
POSTULO_EMAIL_HOST— now needsPOSTULO_CONNECTIONS_ALLOW_PRIVATE=true. The refusal names the variable.docs/THREAT-MODEL.mdgains the rule as rule 5's second half, and rule 6 now records that theencrypted-secret set includes a mail password typed into the interface — which is what #111's
start-up refusal is protecting.
21 tests in
tests/test_mail_destinations.py, a wiki section under Hardening, and twostrings in all 39 European catalogues.
Shipped in
328b1f9on0.3.0, withmainkept level.