package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/errlog"
"craftdesign.group/quarry/internal/ops"
)
// proxyForge is a forge with sign-in, built with extra options, served
// in-process so each request's socket address can be set by hand.
func proxyForge(t *testing.T, extra ...Option) http.Handler {
t.Helper()
accts := newBetaAccts()
_ = accts.AddAccount("root", "root-pw", true)
sess := &betaSessions{accts: accts, admin: "root"}
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Accounts: accts,
Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
t.Fatal(err)
}
opts := append([]Option{WithSessions(sess), WithCaps(betaCaps{admin: "root"})}, extra...)
return HandlerWith(r, nil, nil, opts,
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied }))
}
// failLogin posts a wrong password for user from the socket address
// peer, with the given extra headers, and returns the status.
func failLogin(h http.Handler, user, peer string, headers map[string]string) int {
form := url.Values{"user": {user}, "password": {"wrong"}}
req := httptest.NewRequest("POST", "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.RemoteAddr = peer
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
// A client that rotates X-Forwarded-For on every attempt is still one
// address when no proxy is trusted: the socket is the only witness.
func TestAClientThatForgesForwardedForIsLimitedAsOneAddress(t *testing.T) {
h := proxyForge(t)
last := 0
for i := 0; i <= loginFreeMisses+1; i++ {
// A new name every time, so only the address key can hold.
last = failLogin(h, "nobody"+strconv.Itoa(i), "203.0.113.7:4711",
map[string]string{"X-Forwarded-For": "10.0.0." + strconv.Itoa(i)})
}
if last != http.StatusTooManyRequests {
t.Errorf("FAIL: rotating X-Forwarded-For escaped the limit: last status %d", last)
} else {
t.Log("ok: a forged X-Forwarded-For does not change the limited address")
}
}
// Behind a trusted proxy every request arrives from the proxy's own
// socket, so the proxy's header is the key, and X-Forwarded-For, which
// the client wrote, is not.
func TestATrustedProxysHeaderIsTheLimitedAddress(t *testing.T) {
h := proxyForge(t, WithTrustedProxy("CF-Connecting-IP"))
last := 0
for i := 0; i <= loginFreeMisses+1; i++ {
last = failLogin(h, "nobody"+strconv.Itoa(i), "127.0.0.1:50000",
map[string]string{"CF-Connecting-IP": "198.51.100.9", "X-Forwarded-For": "10.0.0." + strconv.Itoa(i)})
}
if last != http.StatusTooManyRequests {
t.Errorf("FAIL: the proxy's address was not held: last status %d", last)
} else {
t.Log("ok: the proxy's address is held whatever X-Forwarded-For says")
}
// Another reader behind the same proxy socket is not held with them.
if code := failLogin(h, "someone-new", "127.0.0.1:50000",
map[string]string{"CF-Connecting-IP": "198.51.100.10"}); code != http.StatusUnauthorized {
t.Errorf("FAIL: another address behind the proxy got %d, want %d", code, http.StatusUnauthorized)
} else {
t.Log("ok: another address behind the same proxy is limited separately")
}
}
// The proxy's word for the scheme is believed on the same terms as its
// word for the address, and nobody else's is.
func TestTheForwardedSchemeIsBelievedOnlyFromTheTrustedProxy(t *testing.T) {
for _, tc := range []struct {
name string
proxy TrustedProxy
headers map[string]string
want bool
}{
{"no proxy trusted: a client's X-Forwarded-Proto is ignored", "", map[string]string{"X-Forwarded-Proto": "https"}, false},
{"trusted proxy absent from the request: ignored", "CF-Connecting-IP", map[string]string{"X-Forwarded-Proto": "https"}, false},
{"trusted proxy present: believed", "CF-Connecting-IP", map[string]string{"CF-Connecting-IP": "198.51.100.9", "X-Forwarded-Proto": "https"}, true},
} {
got := false
h := tc.proxy.within(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { got = isHTTPS(req) }))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "127.0.0.1:50000"
for k, v := range tc.headers {
req.Header.Set(k, v)
}
h.ServeHTTP(httptest.NewRecorder(), req)
if got != tc.want {
t.Errorf("FAIL: %s: isHTTPS = %v, want %v", tc.name, got, tc.want)
} else {
t.Logf("ok: %s", tc.name)
}
}
// RequireHTTPS asks the same question: a forged "http" from a peer
// the proxy did not vouch for is not redirected or refused.
ran := false
h := RequireHTTPS("https://forge.example", "CF-Connecting-IP", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { ran = true }))
req := httptest.NewRequest("POST", "/login", nil)
req.RemoteAddr = "127.0.0.1:50000"
req.Header.Set("X-Forwarded-Proto", "http")
h.ServeHTTP(httptest.NewRecorder(), req)
if !ran {
t.Error("FAIL: RequireHTTPS acted on a scheme no trusted proxy vouched for")
} else {
t.Log("ok: RequireHTTPS ignores a scheme no trusted proxy vouched for")
}
}
// The local runner and the deploy's health probes talk to the listener
// on loopback with no forwarded headers at all; they are served as they
// always were, with or without a trusted proxy configured.
func TestALoopbackRequestWithNoForwardedHeadersIsServedAsBefore(t *testing.T) {
for _, proxy := range []TrustedProxy{"", "CF-Connecting-IP"} {
h := RequireHTTPS("https://forge.example", proxy, proxyForge(t, WithTrustedProxy(proxy)))
for _, tc := range []struct {
method string
want int
}{{"GET", http.StatusOK}, {"POST", http.StatusUnauthorized}} {
var req *http.Request
if tc.method == "POST" {
req = httptest.NewRequest("POST", "/login", strings.NewReader("user=root&password=wrong"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req = httptest.NewRequest("GET", "/login", nil)
}
req.RemoteAddr = "127.0.0.1:61000"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != tc.want || rec.Header().Get("Location") != "" || rec.Header().Get("Strict-Transport-Security") != "" {
t.Errorf("FAIL: proxy %q: loopback %s /login got %d (Location %q), want %d served directly",
proxy, tc.method, rec.Code, rec.Header().Get("Location"), tc.want)
} else {
t.Logf("ok: proxy %q: loopback %s /login is served as before", proxy, tc.method)
}
}
}
}
// behindProxy makes req look relayed by a proxy trusted through
// CF-Connecting-IP, as the handler's own wrapper would.
func behindProxy(req *http.Request) *http.Request {
req.RemoteAddr = "127.0.0.1:50000"
req.Header.Set("CF-Connecting-IP", "198.51.100.9")
return req.WithContext(context.WithValue(req.Context(), ctxProxy{}, TrustedProxy("CF-Connecting-IP")))
}
// The client-error route is mounted outside the web handler, and a
// browser behind the proxy names an https origin while the forge sees
// plain http. The report is journalled only if the route believes the
// proxy on the same terms as every page does.
func TestABrowserErrorReportBehindTheTrustedProxyIsJournalled(t *testing.T) {
errlog.Default = errlog.New(50)
h := ClientErrorHandler("", WithTrustedProxy("CF-Connecting-IP"))
req := httptest.NewRequest("POST", "http://forge.example/api/client-error",
strings.NewReader(`{"message":"behind the proxy","url":"/x"}`))
req.RemoteAddr = "127.0.0.1:50000"
req.Header.Set("Origin", "https://forge.example")
req.Header.Set("CF-Connecting-IP", "198.51.100.9")
req.Header.Set("X-Forwarded-Proto", "https")
h(httptest.NewRecorder(), req)
if got := errlog.Default.Recent(50); len(got) != 1 {
t.Errorf("FAIL: a trusted https report was dropped: journalled %d, want 1", len(got))
} else {
t.Log("ok: a trusted https report is journalled")
}
}
// The header is believed only from loopback, where the proxy connects
// from. A listener reachable directly must not let a client vouch for
// itself by sending the proxy's header.
func TestATrustedHeaderFromAPeerOffLoopbackIsIgnored(t *testing.T) {
h := proxyForge(t, WithTrustedProxy("CF-Connecting-IP"))
last := 0
for i := 0; i <= loginFreeMisses+1; i++ {
last = failLogin(h, "nobody"+strconv.Itoa(i), "203.0.113.7:4711",
map[string]string{"CF-Connecting-IP": "198.51.100." + strconv.Itoa(i)})
}
if last != http.StatusTooManyRequests {
t.Errorf("FAIL: a self-vouched header from off loopback escaped the limit: last status %d", last)
} else {
t.Log("ok: off loopback, the socket is the key whatever the header says")
}
got := false
probe := TrustedProxy("CF-Connecting-IP").within(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { got = isHTTPS(req) }))
req := httptest.NewRequest("GET", "/", nil)
req.RemoteAddr = "203.0.113.7:4711"
req.Header.Set("CF-Connecting-IP", "198.51.100.9")
req.Header.Set("X-Forwarded-Proto", "https")
probe.ServeHTTP(httptest.NewRecorder(), req)
if got {
t.Error("FAIL: off loopback, a self-vouched X-Forwarded-Proto was believed")
} else {
t.Log("ok: off loopback, X-Forwarded-Proto is not believed")
}
}
// origin() writes absolute links; a client's own X-Forwarded-Proto must
// not choose their scheme.
func TestOriginIgnoresAnUntrustedForwardedScheme(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.test/r/quarry/timeline", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if got := origin(req); got != "http://example.test" {
t.Errorf("FAIL: an untrusted scheme reached origin: %q", got)
} else {
t.Log("ok: origin ignores an untrusted X-Forwarded-Proto")
}
}