Your own email: once POSTULO_EMAIL_HOST is set, every person's outbox may dial private and loopback addresses #358
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#358
Loading…
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?
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 runningOwnMail.test()andOwnMail.send()against127.0.0.1with and without the variable set.Checked again on 2026-10-01 against
mainat0a5cad587: still present. The code is now atcore/mail.py:152-165(host_policy),core/mail.py:180(_open),core/mail.py:240(GuardedBackend.open),core/mail.py:94-97(thecheck_connectiondocstring),core/mail.py:118-120(the "port scanner" comment),core/site.py:94-107(overridden_by),plugins/policy.py:51(GOVERNED_KINDS) andcore/server_views.py:833(the Email page's test); the own-mail plugin, the SMTP plugin,plugins/forms.py:125and both tests have not moved.The exemption that lets the operator's own relay live on
localhostor 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 asPOSTULO_EMAIL_HOSTis 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 althoughPOSTULO_CONNECTIONS_ALLOW_PRIVATEis 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.
core/mail.py:162-175(host_policy), which ends inreturn 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 inos.environ; nothing compares the host being dialled with it.core/mail.py:190(_open, behindcheck_connection) andcore/mail.py:250(GuardedBackend.open), which both pass that one answer asallow_privatetodestinations.approve.plugins/own_mail/__init__.py:165-186(OwnMail.test, throughmail.check_connection) andplugins/own_mail/__init__.py:188-204(OwnMail.send, throughGuardedBackend).core/mail.py:104-107: thecheck_connectiondocstring 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 ofOwnMail.sendcontainsGuardedBackendand that ofGuardedBackend.opencontainsapprove, so it cannot fail on this.tests/test_mail_destinations.py:216-224asserts the instance-wide answer (mail.host_policy() is True) as intended behaviour.POSTULO_EMAIL_HOST=smtp.example.organd leavesPOSTULO_CONNECTIONS_ALLOW_PRIVATEoff. 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 answersConnectionRefusedError: [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 throughGuardedBackend.open: dialled with the variable, refused without it.POSTULO_EMAIL_HOSTand did not allow private destinations. Tests are rate-limited per account (POSTULO_CONNECTION_TEST_RATE,10/hby default), not refused, and an administrator cannot switch the plugin off for anybody, becauseoutboxis 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 (theTypeErroratplugins/forms.py:125, reported separately), and has done since the plugin arrived in9d34da0eb, 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.core/mail.py:128-130says the guard prevents. Sending makes the server speak SMTP to an internal host on the account's behalf. The wiki (Hardening.md, "POSTULO_EMAIL_HOSTis exempt") promises only that the environment's own host is exempt.check_connection,_openandGuardedBackendan explicitallow_privateargument with no default. The instance's callers passmail.host_policy():plugins/smtp/__init__.py:49and:69, and the Email page's test atcore/server_views.py:832.OwnMail.testandOwnMail.sendpassdestinations.private_allowed()only. Comparing the typed host withsettings.POSTULO_EMAIL_HOSTinside a sharedhost_policyis not enough on its own, since a person can type the same name and the default islocalhost. Correct the docstring atcore/mail.py:104-107.Done when
tests/test_outbox.pyhas a test that, withPOSTULO_EMAIL_HOSTset andPOSTULO_CONNECTIONS_ALLOW_PRIVATEoff, showsOwnMail().test({"host": "127.0.0.1", ...})andOwnMail().send(...)both refused before any socket is opened; it replaces the source-text test attests/test_outbox.py:191-202.tests/test_mail_destinations.pyhas a test that the instance transport, in the same environment, still reaches an environment-pinnedlocalhost, and the existing tests there keep passing.check_connectionsays what the code does.