package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"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)
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.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.Header.Set("CF-Connecting-IP", "198.51.100.9")
return req.WithContext(context.WithValue(req.Context(), ctxProxy{}, TrustedProxy("CF-Connecting-IP")))
}