All checks were successful
check / check (push) Successful in 2m52s
With TRUSTED_PROXIES empty behind the reverse proxy production is required to run behind, every login POST keyed on the proxy's address and shared one 5/minute bucket. A stranger sending five POSTs a minute -- 0.08 requests per second, from anywhere -- kept that bucket permanently full, and the operator's own correct password was answered 429 indefinitely with no second administrative path. The login POST no longer has a pre-emptive limiter. The handler verifies credentials first and spends budget only on a FAILED attempt, so a correct password is never throttled whatever the counters hold. Three things follow, and are implemented together because the first is unsafe without the other two: - Failures are counted per (client bucket, submitted username), five per minute, after which further failures get 429 with a Retry-After. A successful login clears the counter, so mistyping and then succeeding does not leave the operator throttled. - Both key sets are capped at 1024 entries. The submitted username is attacker-controlled, so past the first cap failures fall back to a counter keyed on the client alone, and past both caps a failure is answered as throttled without being recorded. Tracked state stays under half a megabyte and does not grow with invented usernames. - Concurrent Argon2id verifications are capped at two, a 128 MB ceiling at 64 MB per hash, and the queue for those slots is capped at 64 waiters. Every password-hashing endpoint takes a slot, including the password-change endpoint, which holds one across both its hashes. A request that waits five seconds without a slot is answered 503, and one that arrives with the queue already full is shed with 503 immediately rather than joining it. Bounding the wait alone would not bound memory: a waiter reaches the guard with its form parsed, so it holds up to the 1 MB body cap for the whole wait, and at flood rates an unbounded queue is worth gigabytes against a 128 MB hashing budget. 64 waiters is 64 MB of committed queue memory, shallow enough that two slots drain a full queue inside the five-second deadline; peak commitment is 128 MB of hashing plus about 66 MB of parsed bodies. An unknown username is verified against a dummy hash instead of returning early, so a nonexistent account costs the same time as a real one and the response cannot be used to enumerate usernames. The password-change limiter is unchanged: RequireAuth runs ahead of it, so only a request already carrying a valid session reaches its bucket. Two consequences are documented rather than fixed, because they follow from the shape the issue asks for. Online guessing throughput rises from 5 a minute to roughly 27 a second, about 2.3 million a day: the credential check always precedes the counter, so the 429 is a label on the response rather than a gate in front of the hash, and what bounds brute force is the semaphore. And under a sustained flood the residual exposure is a loss of login availability, not merely of latency -- above about 27 requests a second most attempts are shed with 503, so a determined flood still denies login for as long as it runs. It costs roughly 400x more to run, nothing accumulates, and the first attempt after it stops succeeds. Restarting the service does not help: the counters a restart clears are not what is saturated. Also adds the missing test for the third bucketKey call site, where the peer is a trusted proxy but the forwarded chain names no client. Every existing test of that fallback uses an IPv4 proxy, where bucketKey is the identity function, so dropping the /64 masking there left the suite green. README and the TRUSTED_PROXIES startup warning updated: a shared bucket now costs precision, not the availability of the admin path.
228 lines
5.7 KiB
Go
228 lines
5.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// HandleProfile returns a handler for the user profile page
|
|
func (h *Handlers) HandleProfile() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sessionUserID, sessionUsername, ok :=
|
|
h.profileOwnerOrDeny(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
h.renderProfile(w, r, sessionUserID, sessionUsername, "", "")
|
|
}
|
|
}
|
|
|
|
// HandlePasswordChange returns a handler that lets an authenticated
|
|
// user change their own password. It is served by the CSRF- and
|
|
// auth-protected POST /password route under /user/{username}.
|
|
func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sessionUserID, sessionUsername, ok :=
|
|
h.profileOwnerOrDeny(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// The body size cap is enforced by the MaxBodySize
|
|
// middleware, which runs before CSRF parses the form.
|
|
err := r.ParseForm()
|
|
if err != nil {
|
|
h.log.Error("failed to parse form", "error", err)
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
successMessage, errorMessage, handled := h.applyPasswordChange(
|
|
r.Context(),
|
|
w,
|
|
sessionUsername,
|
|
r.FormValue("current_password"),
|
|
r.FormValue("new_password"),
|
|
r.FormValue("confirm_password"),
|
|
)
|
|
if !handled {
|
|
return
|
|
}
|
|
|
|
h.renderProfile(
|
|
w, r, sessionUserID, sessionUsername,
|
|
successMessage, errorMessage,
|
|
)
|
|
}
|
|
}
|
|
|
|
// applyPasswordChange verifies the current password and, on success,
|
|
// persists a fresh hash for the user, reusing the same helpers that
|
|
// bootstrap the admin user. It returns the success and error messages
|
|
// to display on the profile page. On an internal failure it writes a
|
|
// 500 response itself and returns handled=false, signalling the caller
|
|
// to stop without re-rendering the page.
|
|
func (h *Handlers) applyPasswordChange(
|
|
ctx context.Context,
|
|
w http.ResponseWriter,
|
|
username, currentPassword, newPassword, confirmPassword string,
|
|
) (string, string, bool) {
|
|
// This endpoint verifies one password and hashes another, at
|
|
// 64 MB each, so it takes a slot from the same bound the login
|
|
// endpoint uses. The bound is per hash, not per endpoint: leaving
|
|
// this path outside it would leave a hole in it. The slot is held
|
|
// across both hashes.
|
|
release, ok := h.mw.BeginPasswordVerification(ctx)
|
|
if !ok {
|
|
h.log.Warn("password verification capacity exhausted")
|
|
http.Error(
|
|
w,
|
|
"The server is busy verifying credentials. "+
|
|
"Please try again.",
|
|
http.StatusServiceUnavailable,
|
|
)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
defer release()
|
|
|
|
// Load the user row so we can verify the current password and
|
|
// persist the new hash.
|
|
var user database.User
|
|
|
|
err := h.db.DB().Where(
|
|
"username = ?", username,
|
|
).First(&user).Error
|
|
if err != nil {
|
|
h.serverError(
|
|
w, "failed to load user for password change", err,
|
|
)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
valid, err := database.VerifyPassword(
|
|
currentPassword, user.Password,
|
|
)
|
|
if err != nil {
|
|
h.serverError(w, "failed to verify password", err)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
if !valid {
|
|
return "", "Current password is incorrect.", true
|
|
}
|
|
|
|
if newPassword == "" {
|
|
return "", "New password must not be empty.", true
|
|
}
|
|
|
|
if newPassword != confirmPassword {
|
|
return "", "New password and confirmation do not match.", true
|
|
}
|
|
|
|
hashedPassword, err := database.HashPassword(newPassword)
|
|
if err != nil {
|
|
h.serverError(w, "failed to hash new password", err)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
err = h.db.DB().Model(&user).Update(
|
|
"password", hashedPassword,
|
|
).Error
|
|
if err != nil {
|
|
h.serverError(w, "failed to update password", err)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
h.log.Info("user changed password", "username", username)
|
|
|
|
return "Password changed successfully.", "", true
|
|
}
|
|
|
|
// profileOwnerOrDeny resolves the session identity and enforces that a
|
|
// user may only act on their own profile (the requested username in the
|
|
// URL must equal the session username). On any failure it writes the
|
|
// appropriate HTTP response and returns ok=false; callers must stop
|
|
// when ok is false.
|
|
func (h *Handlers) profileOwnerOrDeny(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
) (string, string, bool) {
|
|
requestedUsername := chi.URLParam(r, "username")
|
|
if requestedUsername == "" {
|
|
http.NotFound(w, r)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
// RequireAuth middleware guarantees an authenticated session
|
|
// before this handler runs, so we only need to guard against an
|
|
// unexpected retrieval error.
|
|
sess, err := h.session.Get(r)
|
|
if err != nil {
|
|
h.serverError(w, "failed to get session", err)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
sessionUsername, ok := h.session.GetUsername(sess)
|
|
if !ok {
|
|
h.log.Error("authenticated session missing username")
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
sessionUserID, ok := h.session.GetUserID(sess)
|
|
if !ok {
|
|
h.log.Error("authenticated session missing user ID")
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
// Only allow users to act on their own profile.
|
|
if requestedUsername != sessionUsername {
|
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
|
|
|
return "", "", false
|
|
}
|
|
|
|
return sessionUserID, sessionUsername, true
|
|
}
|
|
|
|
// renderProfile renders the profile page for the given user,
|
|
// optionally including a success or error message.
|
|
func (h *Handlers) renderProfile(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
userID, username, successMessage, errorMessage string,
|
|
) {
|
|
data := map[string]any{
|
|
"User": &UserInfo{
|
|
ID: userID,
|
|
Username: username,
|
|
},
|
|
"SuccessMessage": successMessage,
|
|
"ErrorMessage": errorMessage,
|
|
}
|
|
|
|
h.renderTemplate(w, r, "profile.html", data)
|
|
}
|