Your own email: once POSTULO_EMAIL_HOST is set, every person's outbox may dial private and loopback addresses #358

Closed
opened 2026-10-01 10:46:07 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, following the host a person types into their own-mail connection to the socket that dials it, and running OwnMail.test() and OwnMail.send() against 127.0.0.1 with and without the variable set.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/mail.py:152-165 (host_policy), core/mail.py:180 (_open), core/mail.py:240 (GuardedBackend.open), core/mail.py:94-97 (the check_connection docstring), core/mail.py:118-120 (the "port scanner" comment), core/site.py:94-107 (overridden_by), plugins/policy.py:51 (GOVERNED_KINDS) and core/server_views.py:833 (the Email page's test); the own-mail plugin, the SMTP plugin, plugins/forms.py:125 and both tests have not moved.

The exemption that lets the operator's own relay live on localhost or the LAN is decided for the whole instance, not for the operator's host. mail.host_policy() takes no host and answers "private allowed" as soon as POSTULO_EMAIL_HOST is in the environment, and it is the policy for every SMTP connection the server opens, including the person-owned Your own email outbox (#149), whose host a person types. So on any instance that configures mail through .env, which is the documented way (.env.example:158), every account's outbox may reach loopback, the Compose network, the LAN and link-local addresses although POSTULO_CONNECTIONS_ALLOW_PRIVATE is off, and the Test button reports what the far end said. This is server-side request forgery (CWE-918) on the path #148 was written for.

Related to #148 (closed), which put the destination guard on every mail server "somebody typed" and made the Test button answer the same whether or not something listens, and to #149 (closed), which made the person's outbox share that guard. This issue adds that the environment exemption #148 introduced for the operator's own host switches the guard off for the person's half as well.

  • Where:
    • core/mail.py:162-175 (host_policy), which ends in return destinations.private_allowed() or site.overridden_by("email_host") is not None. site.overridden_by (core/site.py:90-102) only asks whether the variable is in os.environ; nothing compares the host being dialled with it.
    • core/mail.py:190 (_open, behind check_connection) and core/mail.py:250 (GuardedBackend.open), which both pass that one answer as allow_private to destinations.approve.
    • plugins/own_mail/__init__.py:165-186 (OwnMail.test, through mail.check_connection) and plugins/own_mail/__init__.py:188-204 (OwnMail.send, through GuardedBackend).
    • core/mail.py:104-107: the check_connection docstring still says the address "is not checked against the private-address rule" and "Do not "fix" this by reusing the capture check", which the code below it and #148 contradict.
    • tests/test_outbox.py:191-202 (test_it_dials_only_where_the_instance_is_allowed_to), the only test of the own-mail path: it asserts that the source text of OwnMail.send contains GuardedBackend and that of GuardedBackend.open contains approve, so it cannot fail on this. tests/test_mail_destinations.py:216-224 asserts the instance-wide answer (mail.host_policy() is True) as intended behaviour.
  • What happens: the operator sets POSTULO_EMAIL_HOST=smtp.example.org and leaves POSTULO_CONNECTIONS_ALLOW_PRIVATE off. Checked on the snapshot: without the variable, OwnMail().test({"host": "127.0.0.1", "port": 1, "security": "none"}) answers "127.0.0.1 is on a private or local network. Postulo only connects to one when the operator sets POSTULO_CONNECTIONS_ALLOW_PRIVATE=true." and nothing is dialled; with it, the same call answers ConnectionRefusedError: [WinError 10061] …, which means the socket was dialled. With a local listener standing in for an internal service, the result carries that service's own banner (SMTPConnectError: (-1, b'2.0-internal-service')). OwnMail().send(...) behaves the same way through GuardedBackend.open: dialled with the variable, refused without it.
  • Who can trigger it: any signed-in account with a Your own email connection, on an instance whose operator set POSTULO_EMAIL_HOST and did not allow private destinations. Tests are rate-limited per account (POSTULO_CONNECTION_TEST_RATE, 10/h by default), not refused, and an administrator cannot switch the plugin off for anybody, because outbox is not a governed kind (plugins/policy.py:46; reported separately). One thing stands in the way today: the own-mail connection form answers 500 for everybody (the TypeError at plugins/forms.py:125, reported separately), and has done since the plugin arrived in 9d34da0eb, so no such connection can be saved through the page. The path opens the moment that crash is fixed, so this fix has to land before it or with it.
  • Why it matters: the public-only rule for hosts a person types is the boundary #148 drew. The Test result tells a refused connection, a time-out and a server's banner apart, so an ordinary account can learn which hosts and ports answer on the server's own network: the "port scanner with a form around it" that core/mail.py:128-130 says the guard prevents. Sending makes the server speak SMTP to an internal host on the account's behalf. The wiki (Hardening.md, "POSTULO_EMAIL_HOST is exempt") promises only that the environment's own host is exempt.
  • Fix: make the exemption belong to the instance's transport, not to every SMTP connection. Give check_connection, _open and GuardedBackend an explicit allow_private argument with no default. The instance's callers pass mail.host_policy(): plugins/smtp/__init__.py:49 and :69, and the Email page's test at core/server_views.py:832. OwnMail.test and OwnMail.send pass destinations.private_allowed() only. Comparing the typed host with settings.POSTULO_EMAIL_HOST inside a shared host_policy is not enough on its own, since a person can type the same name and the default is localhost. Correct the docstring at core/mail.py:104-107.

Done when

  • tests/test_outbox.py has a test that, with POSTULO_EMAIL_HOST set and POSTULO_CONNECTIONS_ALLOW_PRIVATE off, shows OwnMail().test({"host": "127.0.0.1", ...}) and OwnMail().send(...) both refused before any socket is opened; it replaces the source-text test at tests/test_outbox.py:191-202.
  • tests/test_mail_destinations.py has a test that the instance transport, in the same environment, still reaches an environment-pinned localhost, and the existing tests there keep passing.
  • The docstring of check_connection says what the code does.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, following the host a person types into their own-mail connection to the socket that dials it, and running `OwnMail.test()` and `OwnMail.send()` against `127.0.0.1` with and without the variable set. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/mail.py:152-165` (`host_policy`), `core/mail.py:180` (`_open`), `core/mail.py:240` (`GuardedBackend.open`), `core/mail.py:94-97` (the `check_connection` docstring), `core/mail.py:118-120` (the "port scanner" comment), `core/site.py:94-107` (`overridden_by`), `plugins/policy.py:51` (`GOVERNED_KINDS`) and `core/server_views.py:833` (the Email page's test); the own-mail plugin, the SMTP plugin, `plugins/forms.py:125` and both tests have not moved. The exemption that lets the operator's own relay live on `localhost` or the LAN is decided for the whole instance, not for the operator's host. `mail.host_policy()` takes no host and answers "private allowed" as soon as `POSTULO_EMAIL_HOST` is in the environment, and it is the policy for every SMTP connection the server opens, including the person-owned *Your own email* outbox (#149), whose host a person types. So on any instance that configures mail through `.env`, which is the documented way (`.env.example:158`), every account's outbox may reach loopback, the Compose network, the LAN and link-local addresses although `POSTULO_CONNECTIONS_ALLOW_PRIVATE` is off, and the *Test* button reports what the far end said. This is server-side request forgery (CWE-918) on the path #148 was written for. Related to #148 (closed), which put the destination guard on every mail server "somebody typed" and made the *Test* button answer the same whether or not something listens, and to #149 (closed), which made the person's outbox share that guard. This issue adds that the environment exemption #148 introduced for the operator's own host switches the guard off for the person's half as well. - **Where:** - `core/mail.py:162-175` (`host_policy`), which ends in `return destinations.private_allowed() or site.overridden_by("email_host") is not None`. `site.overridden_by` (`core/site.py:90-102`) only asks whether the variable is in `os.environ`; nothing compares the host being dialled with it. - `core/mail.py:190` (`_open`, behind `check_connection`) and `core/mail.py:250` (`GuardedBackend.open`), which both pass that one answer as `allow_private` to `destinations.approve`. - `plugins/own_mail/__init__.py:165-186` (`OwnMail.test`, through `mail.check_connection`) and `plugins/own_mail/__init__.py:188-204` (`OwnMail.send`, through `GuardedBackend`). - `core/mail.py:104-107`: the `check_connection` docstring still says the address "is not checked against the private-address rule" and "Do not \"fix\" this by reusing the capture check", which the code below it and #148 contradict. - `tests/test_outbox.py:191-202` (`test_it_dials_only_where_the_instance_is_allowed_to`), the only test of the own-mail path: it asserts that the source text of `OwnMail.send` contains `GuardedBackend` and that of `GuardedBackend.open` contains `approve`, so it cannot fail on this. `tests/test_mail_destinations.py:216-224` asserts the instance-wide answer (`mail.host_policy() is True`) as intended behaviour. - **What happens:** the operator sets `POSTULO_EMAIL_HOST=smtp.example.org` and leaves `POSTULO_CONNECTIONS_ALLOW_PRIVATE` off. Checked on the snapshot: without the variable, `OwnMail().test({"host": "127.0.0.1", "port": 1, "security": "none"})` answers "127.0.0.1 is on a private or local network. Postulo only connects to one when the operator sets POSTULO_CONNECTIONS_ALLOW_PRIVATE=true." and nothing is dialled; with it, the same call answers `ConnectionRefusedError: [WinError 10061] …`, which means the socket was dialled. With a local listener standing in for an internal service, the result carries that service's own banner (`SMTPConnectError: (-1, b'2.0-internal-service')`). `OwnMail().send(...)` behaves the same way through `GuardedBackend.open`: dialled with the variable, refused without it. - **Who can trigger it:** any signed-in account with a *Your own email* connection, on an instance whose operator set `POSTULO_EMAIL_HOST` and did not allow private destinations. Tests are rate-limited per account (`POSTULO_CONNECTION_TEST_RATE`, `10/h` by default), not refused, and an administrator cannot switch the plugin off for anybody, because `outbox` is not a governed kind (`plugins/policy.py:46`; reported separately). One thing stands in the way today: the own-mail connection form answers 500 for everybody (the `TypeError` at `plugins/forms.py:125`, reported separately), and has done since the plugin arrived in 9d34da0eb, so no such connection can be saved through the page. The path opens the moment that crash is fixed, so this fix has to land before it or with it. - **Why it matters:** the public-only rule for hosts a person types is the boundary #148 drew. The *Test* result tells a refused connection, a time-out and a server's banner apart, so an ordinary account can learn which hosts and ports answer on the server's own network: the "port scanner with a form around it" that `core/mail.py:128-130` says the guard prevents. Sending makes the server speak SMTP to an internal host on the account's behalf. The wiki (`Hardening.md`, "`POSTULO_EMAIL_HOST` is exempt") promises only that the environment's own host is exempt. - **Fix:** make the exemption belong to the instance's transport, not to every SMTP connection. Give `check_connection`, `_open` and `GuardedBackend` an explicit `allow_private` argument with no default. The instance's callers pass `mail.host_policy()`: `plugins/smtp/__init__.py:49` and `:69`, and the Email page's test at `core/server_views.py:832`. `OwnMail.test` and `OwnMail.send` pass `destinations.private_allowed()` only. Comparing the typed host with `settings.POSTULO_EMAIL_HOST` inside a shared `host_policy` is not enough on its own, since a person can type the same name and the default is `localhost`. Correct the docstring at `core/mail.py:104-107`. ## Done when - `tests/test_outbox.py` has a test that, with `POSTULO_EMAIL_HOST` set and `POSTULO_CONNECTIONS_ALLOW_PRIVATE` off, shows `OwnMail().test({"host": "127.0.0.1", ...})` and `OwnMail().send(...)` both refused before any socket is opened; it replaces the source-text test at `tests/test_outbox.py:191-202`. - `tests/test_mail_destinations.py` has a test that the instance transport, in the same environment, still reaches an environment-pinned `localhost`, and the existing tests there keep passing. - The docstring of `check_connection` says what the code does.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:07 +00:00
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.

Dependencies

No dependencies set

Reference
Postulo/postulo#358
No description provided.