A mail server a person typed is a host the server dials #148

Closed
opened 2026-09-09 11:02:03 +00:00 by tiagoagueda · 1 comment
Owner

Observation

a second part, user side, that can be enable/disable by the user / admin with smtp settings
by user

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 the
environment. An administrator pointing the instance at localhost:25 is configuring their
own machine, which is not an attack.

The guard for user-chosen destinations exists and is thoughtful, and it is HTTP-only.
plugins/http.py and plugins/fetching.py between them:

  • refuse private and loopback addresses outright for capture, "because the URL came from a
    stranger's page"
    ;
  • resolve the hostname, approve every address it answers with, and pin the request to
    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;
  • allow private destinations for connections only when the operator has said so, since
    a Paperless on the LAN is the whole point of a connection.

None of that is on the path send_mail takes. A Django mail backend opens a socket to a
host 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.py explains 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 person
whose 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.py encrypts 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.

## Observation > a second part, user side, that can be enable/disable by the user / admin with smtp settings > by user 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 the environment. An administrator pointing the instance at `localhost:25` is configuring their own machine, which is not an attack. **The guard for user-chosen destinations exists and is thoughtful, and it is HTTP-only.** `plugins/http.py` and `plugins/fetching.py` between them: - refuse private and loopback addresses outright for capture, *"because the URL came from a stranger's page"*; - resolve the hostname, approve **every** address it answers with, and pin the request to 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; - allow private destinations for **connections** only when the operator has said so, since a Paperless on the LAN is the whole point of a connection. None of that is on the path `send_mail` takes. A Django mail backend opens a socket to a host 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.py` explains 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 person whose 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.py` encrypts 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.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:02:03 +00:00
Author
Owner

postulo.core.destinations gives SMTP the three properties plugins/http.py already
gives 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, or
the certificate would be proved against a number and never match. plugins/http.py solves the
same 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_HOST is exempt. That is the project's ordinary
environment-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 default
configuration 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 needs
POSTULO_CONNECTIONS_ALLOW_PRIVATE=true. The refusal names the variable.

docs/THREAT-MODEL.md gains the rule as rule 5's second half, and rule 6 now records that the
encrypted-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 two
strings in all 39 European catalogues.

Shipped in 328b1f9 on 0.3.0, with main kept level.

`postulo.core.destinations` gives SMTP the three properties `plugins/http.py` already gives 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, or the certificate would be proved against a number and never match. `plugins/http.py` solves the same 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_HOST` is exempt.** That is the project's ordinary environment-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 default configuration 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 needs `POSTULO_CONNECTIONS_ALLOW_PRIVATE=true`. The refusal names the variable. `docs/THREAT-MODEL.md` gains the rule as rule 5's second half, and rule 6 now records that the encrypted-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 two strings in all 39 European catalogues. Shipped in `328b1f9` on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#148
No description provided.