The rate limiter and the audit log key on the caller's address, and that address was read with chi's middleware.RealIP — which believes X-Forwarded-For from anyone. So a caller could set it to a new value on every request and get a fresh password-guess bucket each time, and could write any address they liked into the log the panel's watchers read. The one control meant for when a password is already gone was keyed on a number the attacker got to pick. Now the header is believed only when the machine that *connected* is a trusted proxy — by default anything on a private network, which is the shape of a reverse proxy or tunnel sharing the host or the docker bridge. A connection straight off the internet is trusted for nothing and is keyed on the address it actually came from. VERDANDE_TRUSTED_PROXIES overrides the set (CIDRs, or "none" to trust no proxy at all); VERDANDE_REAL_IP_HEADER names the header. Verified in the current deployment before the change: behind the Cloudflare Tunnel a spoofed X-Real-IP already got no fresh bucket, because cloudflared overwrites it. This makes that safety a property of the code, not of the deployment — expose the instance directly, or put a header-passing proxy in front, and it would have been a live hole. Also COOP: Cross-Origin-Opener-Policy: same-origin on every response, isolating the browsing context. verdande scripts no other window, so there is nothing to lose and a class of cross-window attacks to close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
95 lines
2.5 KiB
Go
95 lines
2.5 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/kristianwind/verdande/internal/config"
|
|
)
|
|
|
|
// The address the rate limiter keys on must be one the caller cannot choose. A
|
|
// forwarded header is believed only from a trusted proxy, and ignored from a
|
|
// stranger connecting directly — the difference between a control and a suggestion.
|
|
func TestRealIPTrustsOnlyTheProxy(t *testing.T) {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
seen := ""
|
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
seen = clientIP(r)
|
|
}))
|
|
|
|
cases := []struct {
|
|
name string
|
|
remoteAddr string
|
|
xff string
|
|
want string
|
|
}{
|
|
{
|
|
// A private peer is a proxy the operator put there: believe it.
|
|
name: "trusted proxy is believed",
|
|
remoteAddr: "10.1.2.3:5000",
|
|
xff: "203.0.113.9",
|
|
want: "203.0.113.9",
|
|
},
|
|
{
|
|
// A public peer connected straight to us; its header is a guess.
|
|
name: "public peer header ignored",
|
|
remoteAddr: "198.51.100.7:5000",
|
|
xff: "203.0.113.9",
|
|
want: "198.51.100.7",
|
|
},
|
|
{
|
|
// No header from a trusted proxy leaves the peer as the answer.
|
|
name: "trusted peer without header",
|
|
remoteAddr: "127.0.0.1:5000",
|
|
xff: "",
|
|
want: "127.0.0.1",
|
|
},
|
|
{
|
|
// A spoofed value from a stranger cannot become the key.
|
|
name: "spoof from outside does not win",
|
|
remoteAddr: "198.51.100.7:5000",
|
|
xff: "127.0.0.1",
|
|
want: "198.51.100.7",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
|
r.RemoteAddr = tc.remoteAddr
|
|
if tc.xff != "" {
|
|
r.Header.Set("X-Forwarded-For", tc.xff)
|
|
}
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
if seen != tc.want {
|
|
t.Errorf("keyed on %q, want %q", seen, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// "none" turns the trust off entirely, for an instance exposed directly.
|
|
func TestRealIPTrustNone(t *testing.T) {
|
|
t.Setenv("VERDANDE_TRUSTED_PROXIES", "none")
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := ""
|
|
h := realIP(cfg)(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
seen = clientIP(r)
|
|
}))
|
|
r := httptest.NewRequest("POST", "/", nil)
|
|
r.RemoteAddr = "10.1.2.3:5000" // even a private peer is not trusted now
|
|
r.Header.Set("X-Forwarded-For", "203.0.113.9")
|
|
h.ServeHTTP(httptest.NewRecorder(), r)
|
|
if seen != "10.1.2.3" {
|
|
t.Errorf("keyed on %q, want the peer 10.1.2.3", seen)
|
|
}
|
|
}
|