All checks were successful
check / check (push) Successful in 2m53s
r.FormValue falls back to the query string, so
POST /source/{id}/targets?url=<secret> created a working target from a
value carried on the request line — where proxy logs, browser history
and Referer all record it. Every form read is now r.PostFormValue,
including the login password and both password-change fields, which had
the same defect in a more acute form.
The Sentry leg needed more than the query string. sentryhttp attaches
the whole request to the scope, and ApplyToEvent copies the teed body
into Request.Data with no SendDefaultPII guard — so reading every field
from the body only pointed every credential this change protects at the
one field the first revision did not scrub. Body and query are now
redacted, Cookies and Env cleared, and Headers reduced to an allowlist,
because the SDK's own filter removes four names and would otherwise ship
X-Csrf-Token and the shared secrets senders put on the receiver route.
Also adds json:"-" to Target.Config, APIKey.Key and Setting.Value —
TargetView is the masking barrier for the HTML path only, and the first
handler to marshal a model would serialise a bearer token or the session
encryption key.
Independently reviewed three times. The second review found the Data
leak and proved it with a scratch module; the third disproved the
PR's own claim that BeforeSend gets no request, so the README now
records that redacting unconditionally is a deliberate choice rather
than a limitation — which is what makes #179 cheap to fix.
286 lines
6.7 KiB
Go
286 lines
6.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// 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", username,
|
|
"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", r.URL.Path,
|
|
)
|
|
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)
|
|
|
|
h.log.Debug("user not found", "username", username)
|
|
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 {
|
|
h.log.Debug("invalid password", "username", username)
|
|
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)
|
|
}
|
|
}
|