Security audit 2026-10-17, findings A1 (medium), A8, A9 (low), stage 3.
Under the shipped Apache-on-host deployment with TRUSTED_PROXY_CIDR empty,
every visitor has the Docker gateway's address, and every public limit was
keyed on the address alone: ten POSTs to /login per 15 minutes (any body,
successes counted) locked out every administrator; likewise /login/reset,
the link pages and /setup/. IPv6 was keyed per /128.
Now (internal/web/auth/limitkeys.go):
- sign-in counts per address and username (10 / 15 min) and per address
alone at 100; a right password refunds both (rateLimiter.Refund);
- a reset request counts per address and username (5) and per address
alone (50); a link's page per link, by token hash (30), and per address
alone (300); setup per address as before;
- an IPv6 address is keyed by its /64 (limitAddr);
- proxyWatch: when the first 20 requests after a start all come from one
private or loopback peer with X-Forwarded-For and no proxy is trusted,
the log says so once with the TRUSTED_PROXY_CIDR value to set;
- a refused sign-in or a lockout is logged, five lines per address in 15
minutes, the typed name printable and cut (refusalLog, logName).
The shipped compose cannot know the gateway, so the guide's install step
prints it (docker network inspect) and says to set it.
Tests: TestLoginBehindOneUntrustedProxyKeepsOthersIn (the reader's case),
TestLoginSuccessesDoNotCount, TestIPv6IsLimitedPerSlash64,
TestProxyWatchAndRefusalsAreLogged; the existing lockout tests pass.
Docs: security.md § First-run setup, § Authentication; guide § Full
deployment; CHANGELOG; plan. Also the I1 plan line's Done note.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of 2c27275..2c8e981 (read-only, a separate agent) found no high
or medium; three small ones, fixed here:
- reauth took a limiter attempt before the compare, right password
included: with A2 asking on every user save, ten saves in fifteen
minutes locked the administrator out of Account's password change, the
backup and the export. A right password now gives its attempt back
(rateLimiter.Refund, Module.PasswordCheckPassed; a bucket already at its
limit stays blocked). TestRightAccountPasswordsDoNotUseUpTheLimit.
- LoggedPath hid the tokens of /login/reset/ and /confirm/ but not of the
first administrator's /setup/<token>; now it does (test case added).
- The entrypoint's own `[ -e ] || : >` lines (sender_login_maps, the four
inbound maps, mail.log) get the `[ -L ]` guard postfix-config.sh got for
I2. Safe before too - drop_links runs first on those paths and the panel
is not running yet - this is the belt and braces.
The review also confirmed, with a probe of the regex against Postfix's
formats, that a port-25 client can place only its own envelope in an
Inbound log row, and that every path to a password, e-mail or role asks
for the signed-in password.
go test ./internal/web/handlers ./internal/web/auth; sh -n entrypoint.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security audit 2026-10-17, finding A4 (low), stage 0. The line logged when
a request carries two unprefixed session cookies printed r.URL.Path raw,
so GET /confirm/<token> wrote the token to the log (reachable only with
PANEL_COOKIE_SECURE=false, where a sibling host can plant the bare name).
loggedPath moves from internal/web to internal/web/auth as LoggedPath
(web imports auth, not the other way) and both lines - the origin check's
refusal and the duplicate-cookie one - go through it.
Tests: TestDuplicateCookieLineHidesALinksToken; TestLoggedPathHidesTokens
moved with the function. go test ./internal/web ./internal/web/auth.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agreed by the owner on 2026-10-13 ("the e-mail must be confirmed");
notifications to an unconfirmed address settled on 2026-10-17: "Без
подтверждения слать". Mockups 2f5f5b2, route guard b7bbb2c, panel guard
0774da2.
Migration 0013: users.email_confirmed_at ('' for every address saved
before it) and user_links - the SHA-256 of a link's token, its user,
purpose (confirm, reset), address, created and expiry. A changed address
loses its mark (keepConfirmed, in UpdateUser and SaveUser).
internal/web/auth/links.go. A link is 256 bits from crypto/rand, mailed as
https://SELFPOST_HOSTNAME/... - never the request's Host. Confirmation:
24 h, sent when an address is saved (Account, or the global role's user
form) and by Account's Send the link again; its page /confirm/<token> has
a button (a GET changes nothing) and confirms only for a session of its
own account. Reset: /login/reset asks for a username and answers the same
for every name (the sign-in page, "If that account has a confirmed e-mail,
the link is on its way"), mailing after the answer; only a confirmed
address gets one, none within ten minutes of the last, five links an hour
per account, five requests in 15 minutes per client address;
/login/reset/<token> (30 min) sets the password with the link taken in
the same transaction that checks the address again
(store.TakeResetLink), then ends every session. Changing the e-mail on
Account asks for the current password. A password set on Account or the
user form removes the reset links; a restore removes every link; the stop
waits for links being mailed (Server.Drain); a rejected request's log line
hides a link's token (loggedPath).
Reviewed for security before the commit (2026-10-17): no high; two
medium and five low, all fixed here - confirming needs the account's
session and the mail does not name the account (a mistyped address was a
takeover); a reset request neither replaces the links already sent nor
spends the account's quota inside ten minutes; take-and-set atomic; the
confirmation mail sent after the answer; the stop waits for mail; a
restore deletes the links; the token kept out of the log.
Tests: store TestUserLinks, TestUserLinkTakenOnce, TestEmailConfirmation,
TestTakeResetLink; auth TestResetSendsALinkOnlyToAConfirmedAddress,
TestResetLinkSetsThePasswordOnce, TestResetLinkDiesWithTheAddress,
TestResetRequestsAreLimited, TestResetRequestsAreSpaced, TestConfirmLink,
TestLinkPathsWithoutAToken; handlers TestAccountEmailIsConfirmedByItsLink,
TestUserFormSendsANewAddressItsLink; web TestLoggedPathHidesTokens; the
view's pages and fixtures; e2e password_reset_by_e_mail runs it through
the sink (and checks a link from before the restore is dead). go vet +
go test (26 packages) and make e2e on the test server for this tree.
Docs: guide § Account, security.md (the bullet and what ends sessions),
architecture (routes), schema-migrations (row 13, users, user_links),
help #account (word for word with the mockup), roadmap row done,
CHANGELOG [Unreleased].
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>