| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A member hands its hub access token to every node it connects to (the MNP
handshake), so a node operator holds a live bearer token for that member.
PATCH /v1/users/me {email} needed only that token, and the confirmation code
goes to the new address — so an operator could point the account's e-mail at
their own inbox, confirm it, and then use the passphrase-reset path to take the
account over. This is the immediate mitigation of that chain; the full fix
(a node-audience token distinct from the API session token) follows.
Changing the address now requires the passphrase-derived auth_key, verified
through the same throttle as a passphrase change or an account deletion — the
hub still never sees the passphrase. A PATCH that does not change the address is
unaffected. The profile page prompts for the passphrase and derives auth_key
with the existing MeshBayKeys.deriveAuthKey, as the delete and change-password
flows already do.
test_email_change_requires_passphrase.py: refused without / with a wrong
passphrase, proceeds with the right one, and a no-email PATCH still works; red
before, green after. test_mail_is_not_a_relay.py updated to pass the auth_key.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
| |
The per-recipient cooldown was shared by every purpose, so the code a
person asked for by registering within two minutes of an invitation
link was refused, silently. The cooldown is now per family (invitations
vs the account's own steps); the daily cap still counts everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
`ruff check .` had gone unrun long enough to report 568 errors, which is the
same as having no linter: the next real finding would have been invisible in the
noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the
98 it cannot fix are the next commit.
What actually changed: import sorting (225), imports nobody used (87, none of
them a re-export — no `__init__.py` is touched, which was the one way this could
have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69)
and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12
this project requires, `Optional[X]` to `X | None` (24), and f-strings with
nothing to interpolate (19).
Checked rather than assumed: every module in the three packages still imports,
and the suite is 2893 passed — the same count, test for test, as the merge
before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
| |
|
|
|
|
|
| |
Existing shorter accounts keep signing in. Test usernames padded to match.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The figure was a line beside the settings form, which is where it is changed
and not where it is watched. It sits with the other live figures under
Statistics now — four cards and, above them, a banner saying which of the two
ceilings has fallen. The two states are not the same to whoever is reading:
one means newcomers are turned away, the other means somebody locked out of
their account cannot get back in. The settings block keeps a line pointing at
it.
And an operator no longer has to be looking. When a global ceiling is reached
the administrators are notified — in `mail.py`, in its own session, never
raising, because this runs while a request is being refused and an alert that
fails must not turn a refusal into a 500. Once per hour, keyed on a row
rather than a flag in memory: a flood is what spends the budget, so one alert
per refusal would bury the message under its own cause, and a hub that is
refusing mail is a hub somebody is about to restart.
`/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than
leaving the panel to compare two numbers.
Labels in all ten catalogues; `.warn-msg` for the middle state, on the
`--warn` token both themes already define.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two dicts in `mail.py` held the budget, so every deploy handed out a fresh
one — and this hub is deployed several times a day. A bound a restart forgets
is not a bound, for the reason the denylist is persisted rather than held in
memory (S3). It is a `mail_quota` table now, one row per counter, the
recipient hashed so the table does not become a list of plaintext addresses.
The counting moves with it, into an async `reserve` that has a session, and
`send_off_loop` is the one door it stands in. `_send` keeps the purpose
allow-list: that half needs no state, and it is what stops anything which
puts a message on the wire from naming a reason this hub does not send for.
The caller owns the commit, so a request that fails afterwards is not charged
for mail nobody received.
`hourly_reserved_for_recovery` is new. A flood of sign-ups used to be able to
spend the whole hour and lock out the person waiting on a passphrase reset;
registration and address changes may now spend only the unreserved share.
Values changed as agreed: 10 messages a day to one recipient, 300 s between
two reset codes. The address-change ceiling and its cooldown were two bounds
on one thing — 3 a day and 60 s apart — and collapse into one 48-hour delay.
Asking again for the address already pending is exempt: it reaches no new
recipient, that recipient is bounded anyway, and without the exemption a typo
locked the account out of correcting it for two days.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|
|
|
The previous commit metered the paths that send mail. It was not enough, and
saying it was would have been wrong: a 60-second cooldown per account still
allows one stranger a minute — 1440 a day — and registration is open, so
"per account" is a bound an attacker buys more of. And there was a third door
nobody had counted.
POST /v1/users/register an address nobody has verified
PATCH /v1/users/me an address nobody has verified, signed in
POST /v1/users/password/reset only the address already on file
POST /v1/groups/{id}/invite-notify only a registered member's address
The widest was the register *resend* branch: no token, no captcha, and the
username and address are the caller's own from a moment ago — registering a
victim's address once bought the right to mail them at the endpoint's rate
limit for as long as the account stayed pending.
So the bound moves into `mail.py`, where every message passes one function.
`purpose` is keyword-required and checked against a closed list, so a helper
that names anything else does not send and one that names nothing is a
TypeError rather than an unrestricted send. Under it:
- a bound per **recipient**, across every purpose, account and endpoint —
what a person being mail-bombed actually experiences, and the only bound
that describes it. Keyed on a hash, because this would otherwise be the
one place in the hub holding plaintext addresses in memory (S2)
- an instance-wide hourly ceiling, which cannot be bought with more accounts
- a cooldown on the resend branch, a cooldown and a daily ceiling on the
address change, and the IP-log entry that endpoint never wrote — alone
among the ones that mail
The ceiling on address changes counts IP-log rows, not EmailVerification: the
handler deletes this account's unverified rows before writing a new one, so
counting those counts one, always. Which is what the first version of it did.
Refusals never carry the address: that line goes to the journal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
|