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.
230 lines
5.8 KiB
Go
230 lines
5.8 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,
|
|
// PostFormValue, not FormValue: the credential must
|
|
// come from the body, never from the query string.
|
|
r.PostFormValue("current_password"),
|
|
r.PostFormValue("new_password"),
|
|
r.PostFormValue("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)
|
|
}
|