Skip to content
quarry Artifact [e63341552e]
Login

Artifact [e63341552e]

Artifact e63341552e1d9fcad783fa69dfa43d68b222d8682096c9360f1a0080dfd2c845:


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")
	}
}