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
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:
@@ -12,6 +12,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -32,13 +34,18 @@ const (
|
||||
// so the API layer can answer 400 instead of surfacing a constraint violation.
|
||||
var ErrHopsOutOfRange = errors.New("trusted proxy hops must be between 0 and 10")
|
||||
|
||||
// ErrInvalidPublicURL is returned by SetPublicURL for anything that isn't a
|
||||
// bare http(s) origin, so the API layer can answer 400.
|
||||
var ErrInvalidPublicURL = errors.New("public URL must be an http:// or https:// address with a host and no path, query or fragment")
|
||||
|
||||
// Service caches the network settings and owns their persistence.
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
hops int
|
||||
mu sync.RWMutex
|
||||
hops int
|
||||
publicURL string
|
||||
}
|
||||
|
||||
// New loads the settings once and caches them.
|
||||
@@ -58,6 +65,7 @@ func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service
|
||||
return s, err
|
||||
}
|
||||
s.hops = int(row.TrustedProxyHops)
|
||||
s.publicURL = row.PublicUrl
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -106,3 +114,53 @@ func (s *Service) SetHops(ctx context.Context, hops int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PublicURL returns the operator-set address users reach Minstrel at, with no
|
||||
// trailing slash, or "" when it hasn't been set. Links that leave the app (a
|
||||
// password-reset email) are built from this and never from the request's
|
||||
// Host header, which the requester controls. Nil-safe like Hops.
|
||||
func (s *Service) PublicURL() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.publicURL
|
||||
}
|
||||
|
||||
// NormalizePublicURL validates raw as a bare http(s) origin and returns it
|
||||
// without a trailing slash. "" is valid and means unset.
|
||||
func NormalizePublicURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" ||
|
||||
u.User != nil || (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" {
|
||||
return "", ErrInvalidPublicURL
|
||||
}
|
||||
return u.Scheme + "://" + u.Host, nil
|
||||
}
|
||||
|
||||
// SetPublicURL validates, persists and caches the public URL. "" clears it.
|
||||
func (s *Service) SetPublicURL(ctx context.Context, raw string) error {
|
||||
normalized, err := NormalizePublicURL(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.pool == nil {
|
||||
return errors.New("network settings unavailable")
|
||||
}
|
||||
row, err := dbq.New(s.pool).UpdatePublicURL(ctx, normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.publicURL = row.PublicUrl
|
||||
s.mu.Unlock()
|
||||
if s.logger != nil {
|
||||
s.logger.Info("netsettings: public URL updated", "public_url", normalized)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user