Your own email: Encryption starts on None, so a password or XOAUTH2 token can cross the network in the clear #360

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

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the own-mail plugin, the connection form builder and the SMTP sign-in, and building the form's fields from the snapshot.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/mail_choices.py:25 (NONE, still the first member of MailSecurity, which moved there with #248 and is re-exported by core/mail.py), core/mail_auth.py:133-148 (authenticate) and core/mail.py:113 (its call in check_connection); the plugin and the form builder have not moved.

The Encryption field on the Your own email connection has no blank option and no default, and its first choice is None. A form left as it is therefore saves security="none", which _port turns into port 25, and the sign-in then sends the password or the provider's bearer token without TLS. The field's own help says the opposite of its default: "Nearly every provider wants STARTTLS on 587."

  • Where: plugins/own_mail/__init__.py:86-92 (the security field: choices=tuple(mail.MailSecurity.choices), required=False, no default); plugins/forms.py:133-134 (form_field_for builds a plain ChoiceField over those choices, with no empty choice); core/mail.py:34 (NONE is the first member of MailSecurity); plugins/own_mail/__init__.py:230-242 (_port picks 25 for none); core/mail_auth.py:158-173 (authenticate), called from core/mail.py:123 (check_connection) and from GuardedBackend.open.
  • What happens: built from the snapshot, the field renders as <select name="plugin_security"> with none, starttls, ssl in that order and nothing selected, so the browser submits none. check_connection and send then open a plain SMTP session on port 25 and call authenticate. With a provider chosen, server.auth("XOAUTH2", ..., initial_response_ok=True) puts the base64 bearer token on the first AUTH line; smtplib.SMTP.auth does not check that the server advertised AUTH, let alone that the session is encrypted, so the token is on the wire before the server can answer "530 Must issue a STARTTLS command first". With a password, server.login sends it in the clear to any server that advertises AUTH before TLS. Nothing in authenticate, check_connection or GuardedBackend.open checks that TLS is active before a credential is sent.
  • Who can trigger it: nobody chooses it; it is the default for every person who sets up their own mail and does not open the Encryption list. The exposure is to anybody on the network path between the instance and the mail server (the hosting provider, a shared network, a transit network).
  • Why it matters: the credential is the person's mail account: an app password, or a token that can send mail as them (for Microsoft 365 an SMTP.Send token). Today the connection form for this plugin answers 500 before it can be saved (the password field's help text, reported separately), so the path is latent; it becomes live as soon as that is fixed, and applies to any own-mail connection stored before.
  • Fix: give the security field default="starttls" and order its choices STARTTLS, TLS from the first byte, None, so an untouched form means what the help text says. In the own-mail path (OwnMail.test and OwnMail.send), refuse to sign in on a session that is not encrypted, with a ConnectionFailed sentence that names the fix; a small helper such as mail_auth.require_tls(server) that checks isinstance(server.sock, ssl.SSLSocket) before authenticate does it for both. Keep the instance's own transport free to use a loopback relay without TLS, which is the ordinary case for POSTULO_EMAIL_HOST=localhost.

Done when

  • tests/test_mail_security.py builds a ConnectionForm for own-mail and asserts the security field's initial value is starttls.
  • A test in tests/test_mail_xoauth2.py runs OwnMail.test with security="none", a username and a token against a stub SMTP server that offers neither STARTTLS nor AUTH, and asserts that no AUTH command reached it and that the result names the missing encryption.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the own-mail plugin, the connection form builder and the SMTP sign-in, and building the form's fields from the snapshot. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/mail_choices.py:25` (`NONE`, still the first member of `MailSecurity`, which moved there with #248 and is re-exported by `core/mail.py`), `core/mail_auth.py:133-148` (`authenticate`) and `core/mail.py:113` (its call in `check_connection`); the plugin and the form builder have not moved. The *Encryption* field on the *Your own email* connection has no blank option and no default, and its first choice is *None*. A form left as it is therefore saves `security="none"`, which `_port` turns into port 25, and the sign-in then sends the password or the provider's bearer token without TLS. The field's own help says the opposite of its default: "Nearly every provider wants STARTTLS on 587." - **Where:** `plugins/own_mail/__init__.py:86-92` (the `security` field: `choices=tuple(mail.MailSecurity.choices)`, `required=False`, no `default`); `plugins/forms.py:133-134` (`form_field_for` builds a plain `ChoiceField` over those choices, with no empty choice); `core/mail.py:34` (`NONE` is the first member of `MailSecurity`); `plugins/own_mail/__init__.py:230-242` (`_port` picks 25 for `none`); `core/mail_auth.py:158-173` (`authenticate`), called from `core/mail.py:123` (`check_connection`) and from `GuardedBackend.open`. - **What happens:** built from the snapshot, the field renders as `<select name="plugin_security">` with `none`, `starttls`, `ssl` in that order and nothing selected, so the browser submits `none`. `check_connection` and `send` then open a plain SMTP session on port 25 and call `authenticate`. With a provider chosen, `server.auth("XOAUTH2", ..., initial_response_ok=True)` puts the base64 bearer token on the first `AUTH` line; `smtplib.SMTP.auth` does not check that the server advertised `AUTH`, let alone that the session is encrypted, so the token is on the wire before the server can answer "530 Must issue a STARTTLS command first". With a password, `server.login` sends it in the clear to any server that advertises `AUTH` before TLS. Nothing in `authenticate`, `check_connection` or `GuardedBackend.open` checks that TLS is active before a credential is sent. - **Who can trigger it:** nobody chooses it; it is the default for every person who sets up their own mail and does not open the *Encryption* list. The exposure is to anybody on the network path between the instance and the mail server (the hosting provider, a shared network, a transit network). - **Why it matters:** the credential is the person's mail account: an app password, or a token that can send mail as them (for Microsoft 365 an `SMTP.Send` token). Today the connection form for this plugin answers 500 before it can be saved (the password field's help text, reported separately), so the path is latent; it becomes live as soon as that is fixed, and applies to any own-mail connection stored before. - **Fix:** give the `security` field `default="starttls"` and order its choices STARTTLS, TLS from the first byte, None, so an untouched form means what the help text says. In the own-mail path (`OwnMail.test` and `OwnMail.send`), refuse to sign in on a session that is not encrypted, with a `ConnectionFailed` sentence that names the fix; a small helper such as `mail_auth.require_tls(server)` that checks `isinstance(server.sock, ssl.SSLSocket)` before `authenticate` does it for both. Keep the instance's own transport free to use a loopback relay without TLS, which is the ordinary case for `POSTULO_EMAIL_HOST=localhost`. ## Done when - `tests/test_mail_security.py` builds a `ConnectionForm` for `own-mail` and asserts the security field's initial value is `starttls`. - A test in `tests/test_mail_xoauth2.py` runs `OwnMail.test` with `security="none"`, a username and a token against a stub SMTP server that offers neither STARTTLS nor AUTH, and asserts that no `AUTH` command reached it and that the result names the missing encryption.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:46:08 +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#360
No description provided.