All checks were successful
check / check (push) Successful in 2m54s
MaxBodySize logged r.URL.Path untruncated at WARN, and routes.go registers it ahead of RequireAuth, so an unauthenticated POST /source/<8 KB>/edit with an oversize declared Content-Length wrote attacker-chosen text of attacker-chosen length into the operator's log, for the cost of a request with no body. The 2,560-byte per-line budget from #146 did not reach it: that budget lives in the access log's field capping and this is a separate slog call. The capping mechanism moves out of internal/middleware into internal/logfield so there is one budget and one implementation rather than a second ad-hoc truncation. Truncate and EncodedBytes are unchanged; the access log now spends logfield.MaxBytes where it spent maxLogFieldBytes. The sweep the issue asked for found five more call sites of the same shape, all reachable unauthenticated, all now capped: the CSRF 403 (also registered ahead of RequireAuth), the rate limiters' 429 (the per-entrypoint receiver limiter is unauthenticated), RequireAuth's own DEBUG line, the unknown-entrypoint DEBUG line on the receiver, and the failed-login DEBUG lines. DEBUG being off by default is not a bound: an operator turning it on to diagnose a flood must not thereby hand the flood an unbounded write. Every other slog call in the tree was read and judged; the PR body lists all of them, including the ones left alone and why. Two further sites arrived in next with #171 after the first sweep was written and are capped here as well: "login failure limit exceeded" in loginguard.go and "password verification capacity exhausted" in handlers/auth.go, both WARN on the unauthenticated login POST. Neither was ever wide: chi routes that POST on a static pattern, so r.URL.Path is the 12-byte constant /pages/login and each line lands near 120 bytes. They are capped because RecordLoginFailure is exported and takes any *http.Request, so the bound rests on a routing invariant nobody wrote down, and because the same message at handlers/profile.go logs no path at all. No request through the mux can widen either line, so their tests call those two entry points directly with the path a caller on a parameterised route would supply; that is what the caps defend against, and an unasserted cap is one a later edit removes for free. MaxBodySize stays ahead of RequireAuth. An oversize body should be refused before the request buys a cookie decrypt and a session load, and rejecting first is what keeps an unauthenticated flood from choosing how much session work the process does. The ordering and what it costs are now written at the registration, on maxFormBodySize. MaxAccessLogLineBytes is restated as the ceiling on every slog line carrying text an UNAUTHENTICATED client supplies, not just the access log's: each of these lines carries strictly fewer client-supplied fields than the access log does, so none can be wider. That is asserted per line under both handlers rather than argued. The claim is qualified rather than universal because three kinds of writer are outside it, and the README and the constant now name all three: lines carrying an authenticated operator's own input, which are not truncated at all (the webhook name on "webhook created" reaches 600 KB on one line from a 100 KB form field, measured; the SSRF-rejection url and the target_name lines are the same shape) and are left uncapped deliberately, since truncating the operator's own configuration echoed back costs debuggability against no adversary; the log delivery target, which exists to emit the whole event; and GORM's default logger, which prints the interpolated SQL to stdout on a record-not-found and is unbounded on the receiver and login lookups. That last one is a real defect this audit turned up and is filed separately as #178, not fixed here. Tests drive client-chosen text at every site capped here, through both handlers internal/logger can install and through seven fills: plain text, plus the quotation mark, backslash, tab, newline, C0 control and astral non-printable. The C0 control is the one that matters most, costing six bytes on the line against the one it cost to send, and is the case a raw-byte budget breaks on first. The fill is 8 KB everywhere except the two lines past the username lookup, where it is 1 KB because a longer stored username overflows the session cookie and answers 500 before the success line is written. Each case holds the encoded line to the ceiling and asserts the markers at the far end of the input are absent, so a value that merely happened to be short cannot pass. Three of the sites go further and bound the whole flood's output, the total bytes a run of distinct invented values wrote: the 413 rejection, the unknown-entrypoint line and "user not found". The other sites carry the per-line bound only, which is what MaxAccessLogLineBytes states; the README names which sites carry which. internal/logfield gains a test that measures the per-rune charge against what the handlers really emit over roughly 3,000 code points on each, so an undercharged rune fails a test instead of quietly falsifying the ceiling. Verified by mutation: reverting the MaxBodySize cap alone fails 28 subtests with a 16,583-byte line against the 2,560 ceiling; reverting the other five fails 70; reverting either login-throttle WARN cap fails 14, through the direct calls those caps exist for; uncapping either of the two login lines past the username lookup fails both handlers on its own, so those two are independently pinned rather than jointly; budgeting raw bytes instead of encoded ones fails 23 across three packages.
311 lines
7.5 KiB
Go
311 lines
7.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
"sneak.berlin/go/webhooker/internal/logfield"
|
|
)
|
|
|
|
// HandleLoginPage returns a handler for the login page (GET)
|
|
func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// Check if already logged in
|
|
sess, err := h.session.Get(r)
|
|
if err == nil && h.session.IsAuthenticated(sess) {
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
|
|
return
|
|
}
|
|
|
|
// Render login page
|
|
data := map[string]any{
|
|
tmplKeyError: "",
|
|
}
|
|
|
|
h.renderTemplate(w, r, "login.html", data)
|
|
}
|
|
}
|
|
|
|
// HandleLoginSubmit handles the login form submission (POST)
|
|
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// 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
|
|
}
|
|
|
|
// PostFormValue, not FormValue: the credential must come
|
|
// from the body, never from the query string.
|
|
username := r.PostFormValue("username")
|
|
password := r.PostFormValue("password")
|
|
|
|
// Validate input
|
|
if username == "" || password == "" {
|
|
h.renderLoginError(
|
|
w, r,
|
|
"Username and password are required",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
user, err := h.authenticateUser(
|
|
w, r, username, password,
|
|
)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
err = h.createAuthenticatedSession(w, r, user)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
h.log.Info(
|
|
"user logged in",
|
|
"username", logfield.Truncate(
|
|
username, logfield.MaxBytes,
|
|
),
|
|
"user_id", user.ID,
|
|
)
|
|
|
|
// Redirect to home page
|
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
// renderLoginError renders the login page with an error message.
|
|
func (h *Handlers) renderLoginError(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
msg string,
|
|
status int,
|
|
) {
|
|
data := map[string]any{
|
|
tmplKeyError: msg,
|
|
}
|
|
|
|
w.WriteHeader(status)
|
|
h.renderTemplate(w, r, "login.html", data)
|
|
}
|
|
|
|
// authenticateUser looks up and verifies a user's credentials.
|
|
// On failure it writes an HTTP response and returns an error.
|
|
//
|
|
// The credential check runs BEFORE any rate-limit budget is
|
|
// consulted, and only a failed check spends budget. That is what
|
|
// keeps the single administrative path reachable: behind the reverse
|
|
// proxy this deployment requires, with TRUSTED_PROXIES unset, every
|
|
// client shares one bucket, so a limiter spent on arrival lets any
|
|
// stranger deny the operator's own correct password indefinitely.
|
|
//
|
|
// Verifying first means every login POST costs an Argon2id hash, so
|
|
// the work is taken under a bounded number of verification slots.
|
|
func (h *Handlers) authenticateUser(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
username, password string,
|
|
) (database.User, error) {
|
|
var user database.User
|
|
|
|
release, ok := h.mw.BeginPasswordVerification(r.Context())
|
|
if !ok {
|
|
h.log.Warn(
|
|
"password verification capacity exhausted",
|
|
"path", logfield.Truncate(
|
|
r.URL.Path, logfield.MaxBytes,
|
|
),
|
|
)
|
|
h.renderLoginError(
|
|
w, r,
|
|
"The server is busy verifying credentials. "+
|
|
"Please try again.",
|
|
http.StatusServiceUnavailable,
|
|
)
|
|
|
|
return user, errVerificationBusy
|
|
}
|
|
|
|
defer release()
|
|
|
|
err := h.db.DB().Where(
|
|
"username = ?", username,
|
|
).First(&user).Error
|
|
if err != nil {
|
|
// A username that does not exist is charged the same work
|
|
// as one that does. Skipping the hash here would answer in
|
|
// microseconds where a real account takes tens of
|
|
// milliseconds, handing every client a username oracle.
|
|
h.dummyVerifications.Add(1)
|
|
database.VerifyDummyPassword(password)
|
|
|
|
// Login is unauthenticated, and the submitted username is
|
|
// a form field the client fills to any length the 1 MB
|
|
// body cap allows. On this branch it matched no row, so
|
|
// nothing else bounds it. The rate limiter caps how often
|
|
// the line is written, not how wide it is.
|
|
h.log.Debug(
|
|
"user not found",
|
|
"username", logfield.Truncate(
|
|
username, logfield.MaxBytes,
|
|
),
|
|
)
|
|
h.rejectLogin(w, r, username)
|
|
|
|
return user, err
|
|
}
|
|
|
|
valid, err := database.VerifyPassword(password, user.Password)
|
|
if err != nil {
|
|
h.log.Error("failed to verify password", "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return user, err
|
|
}
|
|
|
|
if !valid {
|
|
// Reached only once the username matched a stored row, so
|
|
// it is bounded by the operator's own data. Capped anyway,
|
|
// so that every username this unauthenticated endpoint
|
|
// logs is capped and no reader has to work out which
|
|
// branch narrowed it.
|
|
h.log.Debug(
|
|
"invalid password",
|
|
"username", logfield.Truncate(
|
|
username, logfield.MaxBytes,
|
|
),
|
|
)
|
|
h.rejectLogin(w, r, username)
|
|
|
|
return user, errInvalidPassword
|
|
}
|
|
|
|
// The password was correct, so forgive whatever failures this
|
|
// client accumulated: an operator who mistypes a few times and
|
|
// then gets it right must not stay throttled afterwards.
|
|
h.mw.ForgiveLoginFailures(r, username)
|
|
|
|
return user, nil
|
|
}
|
|
|
|
// rejectLogin counts one failed credential verification and answers
|
|
// it: 401 while this client still has failure budget against the
|
|
// submitted username, 429 with a Retry-After once it is spent.
|
|
//
|
|
// The 429 throttles wrong passwords only. A correct one never
|
|
// reaches here, so no amount of failure — from this client or any
|
|
// other sharing its bucket — can keep the operator out.
|
|
func (h *Handlers) rejectLogin(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
username string,
|
|
) {
|
|
if !h.mw.RecordLoginFailure(r, username) {
|
|
h.renderLoginError(
|
|
w, r,
|
|
"Invalid username or password",
|
|
http.StatusUnauthorized,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Retry-After", strconv.Itoa(int(
|
|
h.mw.LoginFailureInterval().Seconds(),
|
|
)))
|
|
h.renderLoginError(
|
|
w, r,
|
|
"Too many failed login attempts. Please try again later.",
|
|
http.StatusTooManyRequests,
|
|
)
|
|
}
|
|
|
|
// createAuthenticatedSession regenerates the session and stores
|
|
// user info. On failure it writes an HTTP response and returns
|
|
// an error.
|
|
func (h *Handlers) createAuthenticatedSession(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
user database.User,
|
|
) error {
|
|
oldSess, err := h.session.Get(r)
|
|
if err != nil {
|
|
h.log.Error("failed to get session", "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
sess, err := h.session.Regenerate(r, w, oldSess)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to regenerate session", "error", err,
|
|
)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
h.session.SetUser(sess, user.ID, user.Username)
|
|
|
|
err = h.session.Save(r, w, sess)
|
|
if err != nil {
|
|
h.log.Error("failed to save session", "error", err)
|
|
http.Error(
|
|
w, "Internal server error",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// HandleLogout handles user logout
|
|
func (h *Handlers) HandleLogout() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
sess, err := h.session.Get(r)
|
|
if err != nil {
|
|
h.log.Error("failed to get session", "error", err)
|
|
http.Redirect(
|
|
w, r, "/pages/login", http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Destroy session
|
|
h.session.Destroy(sess)
|
|
|
|
// Save the destroyed session
|
|
err = h.session.Save(r, w, sess)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to save destroyed session",
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
// Redirect to login page
|
|
http.Redirect(w, r, "/pages/login", http.StatusSeeOther)
|
|
}
|
|
}
|