fix(auth): build password-reset links from an operator-set public address, never the Host header (M462 #4981)
test-go / test (push) Successful in 1m29s
test-web / test (push) Successful in 1m37s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
test-go / integration (push) Canceled after 2m45s
release / Build signed APK (releases and dev) (push) Canceled after 3m40s

buildResetURL used r.Host and r.TLS, so a forgot-password request with a
forged Host emailed the victim a real reset token on a link to the
attacker's server. Links now come only from network_settings.public_url
(migration 0062), and no reset email is sent while it is empty; the response
stays the same opaque 200 and the log says why.

The address is set on a new "Public address" card under Admin → Integrations,
which offers the page's own origin and warns while unset. PUT
/api/admin/network-settings takes either field alone, so the proxy card and
this one can't overwrite each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:32:10 -04:00
co-authored by Claude Opus 5.5
parent d411693bb2
commit 46194a609d
16 changed files with 433 additions and 26 deletions
+19 -12
View File
@@ -69,7 +69,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
if err == nil && user.Email != nil && *user.Email != "" {
matched = true
auditTarget = user.ID
if sendErr := h.sendResetEmail(r.Context(), r, user); sendErr != nil {
if sendErr := h.sendResetEmail(r.Context(), user); sendErr != nil {
h.logger.Warn("forgot-password: send failed",
"email", email, "err", sendErr)
// fall through; response is still 200
@@ -89,7 +89,7 @@ func (h *handlers) handleForgotPassword(w http.ResponseWriter, r *http.Request)
// sendResetEmail generates a token, persists it, renders + sends the
// email. Returns the underlying error (caller logs it but doesn't
// surface to the HTTP response).
func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq.User) error {
func (h *handlers) sendResetEmail(ctx context.Context, user dbq.User) error {
if h.mailer == nil {
return errors.New("forgot-password: no mailer configured")
}
@@ -109,7 +109,10 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq
return err
}
resetURL := buildResetURL(r, token)
resetURL, err := buildResetURL(h.netSettings.PublicURL(), token)
if err != nil {
return err
}
textBody, htmlBody, err := mailer.RenderResetEmail(mailer.ResetEmailVars{
Username: user.Username,
ResetURL: resetURL,
@@ -127,14 +130,18 @@ func (h *handlers) sendResetEmail(ctx context.Context, r *http.Request, user dbq
return h.mailer.Send(ctx, *user.Email, mailer.ResetEmailSubject, textBody, htmlBody)
}
func buildResetURL(r *http.Request, token string) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
// errNoPublicURL stops a reset email from going out before the operator has
// said where Minstrel lives. It surfaces in the log, not the response, which
// stays the same opaque 200 either way.
var errNoPublicURL = errors.New("forgot-password: no public URL set (Admin → Integrations → Public address); reset email not sent")
// buildResetURL builds the emailed link from the operator-set public URL.
// It deliberately ignores the request: the Host header is whatever the
// requester sent, and building from it let a forged Host plant a real reset
// token on a link to someone else's server.
func buildResetURL(publicURL, token string) (string, error) {
if publicURL == "" {
return "", errNoPublicURL
}
host := r.Host
if host == "" {
host = "localhost"
}
return scheme + "://" + host + "/reset-password/" + token
return publicURL + "/reset-password/" + token, nil
}