An account can be got back into without email, or SMTP can never be switched off #103

Closed
opened 2026-09-07 15:44:33 +00:00 by tiagoagueda · 2 comments
Owner

Why this exists

#100 settled that SMTP may be switched off only when it is not the last way anybody could
get back into their account
-- the same rule Postulo already applies to the last
administrator, one level deeper.

Checking what that interlock would actually find:

Route Exists today?
Email password reset yes
SSO / OpenID Connect yes, when the operator has configured one
An administrator resetting a password no
SMS no
Apprise or any other notifier no

server_urls.py gives an administrator four powers over an account -- make administrator,
deactivate, rename, delete -- and none of them is help this person get back in. So on an
instance without SSO, email is the only route that exists, and the interlock will refuse
every attempt to switch SMTP off. Correctly, and permanently, until something else exists.

What would count as a recovery route

Not "a way to reach somebody". A recovery route has to deliver a single-use, expiring,
account-scoped token to a channel already proven to reach the right person, and it has to
be at least as hard to intercept as the email it replaces.

That last clause matters. A Telegram chat configured six months ago and never tested since is
not a recovery route; it is a guess. The Connection model already records last_ok_at and
last_error and has a Test button, so "proven" is available rather than hypothetical -- and
should be required, with an age limit.

Three candidates, in increasing order of how much they need thinking about

1. An administrator issues a reset link. Smallest, and probably first. An administrator
generates a single-use link and hands it over by whatever means they already trust -- in
person, on the phone. No new channel, no new dependency, and it is the answer for a family
or small-team instance where the administrator is in the same room. Needs care: the link is a
full account takeover in a URL, so it wants a short life, one use, an audit record, and it
must never be shown to anybody but the administrator who asked for it.

2. A notifier a person has already proven. Apprise, ntfy, Telegram, Signal. The plugin
machinery is already there and the connection is already owned, tested and dated. The
question is not whether it can deliver but whether it should carry credentials: a notifier is
configured for convenience and may point at a shared channel, a family group, a desktop
alert on an unlocked machine. Somebody choosing a notifier for reminders did not choose it
for account recovery. So this needs an explicit opt-in per connection -- "use this to get
back in" -- rather than treating every notifier as a recovery route by default.

3. SMS. Named in the request, and the weakest of the three despite being the most
familiar: it needs an outbound gateway (a cost and a dependency Postulo does not have), and
SIM-swap attacks make it the channel security guidance has been moving away from for a
decade. If it is built, it should be an ordinary notifier plugin like any other rather than
something Postulo speaks natively -- which is candidate 2 again, with a different transport.

What has to be true whichever is built

  • The route is per person: the interlock in #100 asks about every account, not the
    instance.
  • It is proven and recent, not merely configured.
  • Rate-limited and audited, like the email reset already is.
  • It does not solve sign-up. ACCOUNT_EMAIL_VERIFICATION = "mandatory" means a new
    account still needs a verification email, so an instance with SMTP off can recover its
    existing people and cannot admit new ones. #100 has to say which of those it covers.

Classification

Enhancement, security. Prerequisite for the SMTP half of #100 ever answering yes; nothing
else waits on it.

## Why this exists #100 settled that **SMTP may be switched off only when it is not the last way anybody could get back into their account** -- the same rule Postulo already applies to the last administrator, one level deeper. Checking what that interlock would actually find: | Route | Exists today? | | --- | --- | | Email password reset | yes | | SSO / OpenID Connect | yes, when the operator has configured one | | An administrator resetting a password | **no** | | SMS | **no** | | Apprise or any other notifier | **no** | `server_urls.py` gives an administrator four powers over an account -- make administrator, deactivate, rename, delete -- and none of them is *help this person get back in*. So on an instance without SSO, **email is the only route that exists**, and the interlock will refuse every attempt to switch SMTP off. Correctly, and permanently, until something else exists. ## What would count as a recovery route Not "a way to reach somebody". A recovery route has to deliver a single-use, expiring, account-scoped token to a channel **already proven to reach the right person**, and it has to be at least as hard to intercept as the email it replaces. That last clause matters. A Telegram chat configured six months ago and never tested since is not a recovery route; it is a guess. The `Connection` model already records `last_ok_at` and `last_error` and has a Test button, so "proven" is available rather than hypothetical -- and should be required, with an age limit. ## Three candidates, in increasing order of how much they need thinking about **1. An administrator issues a reset link.** Smallest, and probably first. An administrator generates a single-use link and hands it over by whatever means they already trust -- in person, on the phone. No new channel, no new dependency, and it is the answer for a family or small-team instance where the administrator is in the same room. Needs care: the link is a full account takeover in a URL, so it wants a short life, one use, an audit record, and it must never be shown to anybody but the administrator who asked for it. **2. A notifier a person has already proven.** Apprise, ntfy, Telegram, Signal. The plugin machinery is already there and the connection is already owned, tested and dated. The question is not whether it can deliver but whether it should carry credentials: a notifier is configured for convenience and may point at a shared channel, a family group, a desktop alert on an unlocked machine. Somebody choosing a notifier for reminders did not choose it for account recovery. So this needs an explicit opt-in per connection -- "use this to get back in" -- rather than treating every notifier as a recovery route by default. **3. SMS.** Named in the request, and the weakest of the three despite being the most familiar: it needs an outbound gateway (a cost and a dependency Postulo does not have), and SIM-swap attacks make it the channel security guidance has been moving away from for a decade. If it is built, it should be an ordinary notifier plugin like any other rather than something Postulo speaks natively -- which is candidate 2 again, with a different transport. ## What has to be true whichever is built - The route is **per person**: the interlock in #100 asks about every account, not the instance. - It is **proven and recent**, not merely configured. - Rate-limited and audited, like the email reset already is. - **It does not solve sign-up.** `ACCOUNT_EMAIL_VERIFICATION = "mandatory"` means a new account still needs a verification email, so an instance with SMTP off can recover its existing people and cannot admit new ones. #100 has to say which of those it covers. ## Classification Enhancement, security. Prerequisite for the SMTP half of #100 ever answering yes; nothing else waits on it.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 15:44:33 +00:00
Author
Owner

Two of the three missing routes in the table above now have issues of their own: #143 for a channel the instance can reach somebody on, and #144 for the telephone number at the end of it. #142 is the one that has to come first — the numbers stored since #90 are unverified by design, and a recovery route on an unverified identifier is takeover rather than recovery.

Worth noting for this issue: an administrator-issued recovery link, which the table lists as the other gap, needs no third party at all. On a self-hosted instance with one administrator it answers the same question as SMS without sending a telephone number to an aggregator, and it does not wait on any of the three.

Two of the three missing routes in the table above now have issues of their own: #143 for a channel the instance can reach somebody on, and #144 for the telephone number at the end of it. #142 is the one that has to come first — the numbers stored since #90 are unverified by design, and a recovery route on an unverified identifier is takeover rather than recovery. Worth noting for this issue: an administrator-issued recovery link, which the table lists as the other gap, needs no third party at all. On a self-hosted instance with one administrator it answers the same question as SMS without sending a telephone number to an aggregator, and it does not wait on any of the three.
Author
Owner

Candidate 1, built. Server settings → People → ⋯ → Recovery link: an
administrator makes a single-use link and hands it over themselves. No third party, no
gateway, no cost — the reason it comes before SMS rather than after it.

The four cautions in the issue are each a property of the thing:

  • Short life — an hour. A URL that still works next week has been in a chat log for a
    week.
  • One use — and opening it does not spend it, only choosing a password does. A
    link-preview bot in the chat an administrator sent it through would otherwise burn
    somebody's only way back in by fetching it.
  • Shown to nobody but the administrator who asked — rendered on the response that made
    it and never again. Nothing stores the token; the row keeps a SHA-256 fingerprint, so the
    record is a record rather than a second copy of the secret. It is never mailed, which would
    be absurd for the route that exists to replace mail.
  • An audit record — the row outlives the link: who issued it, for whom, when, and whether
    it was used, revoked or left to expire. There is still no general audit trail in Server
    settings
    , and a full account takeover leaving no trace at all was not a thing to wait for
    one with.

Two decisions beyond what was asked, both towards a smaller blast radius. It sets a
password and does not sign anybody in
, so a link that goes astray does not bypass a second
factor. And the token is not in the address of the form it leads to — the ticket moves to
the session on the first hop — so it never travels in a Referer header or a synced history.

Who the route reaches, from "the route is per person". Issuing needs signing in, and the
person who forgot their password cannot: so it reaches everybody but the issuer. Two
administrators cover each other; one covers everybody else; a lone administrator without a
passkey is the single account it does not reach, and the page says so. Counted rather than
assumed, so it changes the moment somebody is made an administrator.

On "it does not solve sign-up" — said where it matters. The Email page now warns, when
the lock is open and registration is on, that mail may be switched off but nobody could create
a new account, because a new account has to verify an address before it exists. That is the
half #100 was told to be explicit about, and it is now on the page somebody acts from rather
than discovered when the first sign-up fails.

POSTULO_RECOVERY_RATE (10/h) bounds issuing: each link is a whole account in a URL, so a
compromised administrator session cannot mint fifty.

Candidates 2 (a notifier the person has proven, with per-connection opt-in) and 3 (SMS) are
untouched and still worth having; #143 built the channel the third would need and recorded
why it is not the first answer.

30 tests in tests/test_recovery_links.py, all three new pages walked by the browser suite
under axe-core, a wiki section under Accounts and invitations, and thirty strings in all 39
European catalogues.

Shipped in f8bc6e7 on 0.3.0, with main kept level.

**Candidate 1, built.** *Server settings → People → ⋯ → Recovery link*: an administrator makes a single-use link and hands it over themselves. No third party, no gateway, no cost — the reason it comes before SMS rather than after it. The four cautions in the issue are each a property of the thing: - **Short life** — an hour. A URL that still works next week has been in a chat log for a week. - **One use** — and *opening* it does not spend it, only choosing a password does. A link-preview bot in the chat an administrator sent it through would otherwise burn somebody's only way back in by fetching it. - **Shown to nobody but the administrator who asked** — rendered on the response that made it and never again. Nothing stores the token; the row keeps a SHA-256 fingerprint, so the record is a record rather than a second copy of the secret. It is never mailed, which would be absurd for the route that exists to replace mail. - **An audit record** — the row outlives the link: who issued it, for whom, when, and whether it was used, revoked or left to expire. There is still no general audit trail in *Server settings*, and a full account takeover leaving no trace at all was not a thing to wait for one with. Two decisions beyond what was asked, both towards a smaller blast radius. It **sets a password and does not sign anybody in**, so a link that goes astray does not bypass a second factor. And the token is **not in the address of the form it leads to** — the ticket moves to the session on the first hop — so it never travels in a `Referer` header or a synced history. **Who the route reaches, from "the route is per person".** Issuing needs signing in, and the person who forgot their password cannot: so it reaches everybody but the issuer. Two administrators cover each other; one covers everybody else; a lone administrator without a passkey is the single account it does not reach, and the page says so. Counted rather than assumed, so it changes the moment somebody is made an administrator. **On "it does not solve sign-up" — said where it matters.** The Email page now warns, when the lock is open and registration is on, that mail may be switched off but nobody could create a new account, because a new account has to verify an address before it exists. That is the half #100 was told to be explicit about, and it is now on the page somebody acts from rather than discovered when the first sign-up fails. `POSTULO_RECOVERY_RATE` (10/h) bounds issuing: each link is a whole account in a URL, so a compromised administrator session cannot mint fifty. Candidates 2 (a notifier the person has proven, with per-connection opt-in) and 3 (SMS) are untouched and still worth having; #143 built the channel the third would need and recorded why it is not the first answer. 30 tests in `tests/test_recovery_links.py`, all three new pages walked by the browser suite under axe-core, a wiki section under *Accounts and invitations*, and thirty strings in all 39 European catalogues. Shipped in `f8bc6e7` 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#103
No description provided.