Skip to content

Three hardenings from the security audit - #66

Merged
klausbreyer merged 6 commits into
mainfrom
feature/audit-hardening
Aug 30, 2026
Merged

Three hardenings from the security audit#66
klausbreyer merged 6 commits into
mainfrom
feature/audit-hardening

Conversation

@klausbreyer

Copy link
Copy Markdown
Collaborator

A security audit of the codebase found no critical and no high findings, and three low ones. This closes those three. Nothing changes for a normal reader or writer; the screens stay as they are.

The forgot form now knocks at the same door as the sign-in. POST /forgot was the one password door without a per-caller brake. The mail was capped at one per account per minute, but the route itself took unlimited knocks, each one a database lookup. It now shares the door bucket that the sign-in and the site password use.

A flood of view counts costs the database nothing. The counter ran its repeat query before the flood limiter, so a caller past the limit still bought one database read per knock. A new non-counting RateLimiter.over?/2 turns a flood away at the door. The slot is still spent only by allow?/2 when a row is really written, so a reader who reloads never loses a slot to the reload, as before.

An empty lock process ends itself. A lock process lived on after its text was free, so the supervisor carried one process per entry ever opened and grew without bound on an installation that runs for years. The process now stops once the text is free and nobody waits for a flush; a takeover still in flight keeps it alive until the transfer is done. Reading the state of a lock that ended a breath ago hit the registry's lagging entry, so Lock.state/1 now answers :free when the pid it finds is already gone.

Tests (all written first, watched failing): the shared-door test hammers /forgot and expects the next sign-in to read Too many tries. The flood tests expect no database query from a caller past the limit, measured over the repo's own telemetry, and expect a fresh page to still count after a minute of reloads. The lock tests watch the process die on release, on idle, and stay alive while a takeover resolves. Full suite: 1428 tests, 0 failures. mix format, compile --warnings-as-errors green. No UI changed, so no screenshots; no reader-visible sentence changed, so no gettext run. (mix gettext.extract --check-up-to-date is already red on main, from newsletter.html.heex line drift in #65. This PR touches no template and stays out of it.)

The fourth audit finding, media URLs living outside the site gate, is a design decision and stays open for a separate discussion.

Worked with opencode, model z-ai/glm-5.3-flash.

POST /forgot was the one password door without the per-caller brake:
the mail was capped per account, but a stranger could knock at the
route itself as often as the wire allowed, each knock a database
lookup. It now spends the same door bucket the sign-in and the site
password share, and the answer reads the same either way.
The counter asked its repeat question (one query) before it asked the
flood limiter, so a caller past the limit still bought a database read
on every knock. The limiter now answers first through the new
non-counting over?/2, and the slot is spent only by allow? when a row
is really written, so a reader who reloads still never loses a slot to
the reload.
A lock process lived on after its text was free, so the supervisor
carried one process per entry ever opened and grew without bound on an
installation that runs for years. Once the text is free and nobody
waits for a flush, the process stops; the next editor starts a fresh
one on demand. A takeover still in flight keeps it alive until the
transfer is done.

Reading the state of a lock that ended a breath ago hit the registry's
lagging entry, so state/1 answers :free when the pid it finds is gone.
The lock process ends itself once its text is free. A caller can reach
it a breath too late: the registry still names the pid, or the pid is
alive but already on its way out. The call then exits with :noproc or
with :normal, and only the first was caught, and only in state/1.

Every call now goes through one helper that catches both and knocks
once more on a fresh process. state/1 answers :free instead.
A watcher closing its tab releases a lock it never held. Going through
ensure/1 started a process only to end it at once. release/2 now looks
the process up and answers :ok when there is none.
@klausbreyer

Copy link
Copy Markdown
Collaborator Author

Review of the three hardenings. The forgot door and the flood turn-away are correct and small. The self-ending lock process had one gap, fixed on the branch.

Fixed

  • A call can reach an ending lock process (e55ce2b). The process announces the change and then stops. A watcher reading `Lock.state/1` on that announcement, or a tab calling `acquire`, `takeover` or `release` through `ensure/1`, could hit a pid that the registry still names or that is alive but on its way out. The call then exits with `:noproc` or `:normal`; only the first was caught, and only in `state/1`. Every call now goes through one helper that catches both and knocks once more on a fresh process. Two tests, one of which failed before the fix.
  • Releasing a text without a process started one (latest commit). A watcher closing its tab went through `ensure/1` and started a process only to end it. `release/2` now looks the process up and answers `:ok` when there is none.

Left as is

  • `Stats.count/1` now makes two limiter calls per counted view (`over?` then `allow?`). Both are needed so a reload never spends a slot, and the cost is one message each. Not worth folding.

Full suite after the fixes: 1431 tests, 0 failures. `mix compile --warnings-as-errors` clean.

Reviewed with Claude Code, model claude-fable-5.

@klausbreyer
klausbreyer merged commit 5854d1f into main Aug 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant