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>
97 lines
3.6 KiB
Go
97 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
|
)
|
|
|
|
type networkSettingsResp struct {
|
|
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
|
MaxHops int `json:"max_hops"`
|
|
// DetectedClientIP is what the CURRENT setting resolves this very request
|
|
// to. It's the difference between a number the operator has to reason
|
|
// about and one they can verify: set the value, reload, and check the
|
|
// address matches the machine you're sitting at.
|
|
DetectedClientIP string `json:"detected_client_ip"`
|
|
// ForwardedChain is the raw X-Forwarded-For as received, so an operator
|
|
// whose detected address looks wrong can see how many hops actually
|
|
// arrived and count them rather than guess.
|
|
ForwardedChain string `json:"forwarded_chain"`
|
|
RemoteAddr string `json:"remote_addr"`
|
|
// PublicURL is where users reach Minstrel; reset emails link to it and
|
|
// are not sent while it is empty.
|
|
PublicURL string `json:"public_url"`
|
|
}
|
|
|
|
// Both fields are optional so the proxy card and the public-address card can
|
|
// each save their own value without overwriting the other's.
|
|
type updateNetworkSettingsReq struct {
|
|
TrustedProxyHops *int `json:"trusted_proxy_hops"`
|
|
PublicURL *string `json:"public_url"`
|
|
}
|
|
|
|
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
|
}
|
|
|
|
func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
|
var req updateNetworkSettingsReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
|
return
|
|
}
|
|
if req.TrustedProxyHops == nil && req.PublicURL == nil {
|
|
writeErr(w, apierror.BadRequest("invalid_body", "nothing to update"))
|
|
return
|
|
}
|
|
// Validate everything before writing anything, so a bad public URL can't
|
|
// leave the hops half-saved.
|
|
if req.TrustedProxyHops != nil && (*req.TrustedProxyHops < 0 || *req.TrustedProxyHops > netsettings.MaxTrustedProxyHops) {
|
|
writeErr(w, apierror.BadRequest("invalid_hops", netsettings.ErrHopsOutOfRange.Error()))
|
|
return
|
|
}
|
|
if req.PublicURL != nil {
|
|
if _, err := netsettings.NormalizePublicURL(*req.PublicURL); err != nil {
|
|
writeErr(w, apierror.BadRequest("invalid_public_url", err.Error()))
|
|
return
|
|
}
|
|
}
|
|
if req.TrustedProxyHops != nil {
|
|
if err := h.netSettings.SetHops(r.Context(), *req.TrustedProxyHops); err != nil {
|
|
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
|
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
|
return
|
|
}
|
|
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
|
return
|
|
}
|
|
}
|
|
if req.PublicURL != nil {
|
|
if err := h.netSettings.SetPublicURL(r.Context(), *req.PublicURL); err != nil {
|
|
writeErrWithLog(w, h.logger, "admin network: public URL update failed", apierror.Internal(err))
|
|
return
|
|
}
|
|
}
|
|
// Echo the payload recomputed under the NEW value, so the card can show
|
|
// immediately what the change did to this request's own address rather
|
|
// than making the operator reload to find out.
|
|
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
|
}
|
|
|
|
func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
|
|
hops := h.netSettings.Hops()
|
|
return networkSettingsResp{
|
|
TrustedProxyHops: hops,
|
|
MaxHops: netsettings.MaxTrustedProxyHops,
|
|
DetectedClientIP: auth.ClientIP(r, hops),
|
|
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
|
RemoteAddr: r.RemoteAddr,
|
|
PublicURL: h.netSettings.PublicURL(),
|
|
}
|
|
}
|