package rproxy_test import ( "bytes" "crypto/rand" "io" "maps" "net/http" "net/http/httptest" "slices" "strconv" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/proxy" ) // questionBodyMaxBytes is the most of a body a question carries. const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes // chunked is the encoding of a body whose length the visitor did not announce. const chunked = "chunked" // pathGoReencodes is a path Go's client would decode and escape again, %2F turned // into / and | into %7C, unless told to send it as it is. A test sets it as the // visitor's URL.Opaque, so that the visitor sends it unchanged too. const pathGoReencodes = "/some%2Fpath/with%20space|pipe" func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x" req := newRequest(t, http.MethodDelete, addr, target, http.NoBody) req.URL.Opaque = pathGoReencodes req.Host = site req.Header.Set("X-Test", "from the visitor") req.Header.Set(forwardedForHeader, "198.51.100.1") req.Header.Set(forwardedProtoHeader, "gopher") req.Header.Set(requestIDHeader, "the-visitor's") req.Header.Set(proxy.ClientHeader, "198.51.100.2") req.Header.Set(proxy.PeerHeader, "198.51.100.3") req.Header.Set(proxy.ContentLengthHeader, "12345") // Go's client then sends no User-Agent, so that one in the question would be // Go's own. req.Header["User-Agent"] = nil wantGreeting(t, visit(t, req)) asked := servewaf.question(t) if asked.method != http.MethodDelete || asked.target != target || asked.host != site { t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host) } if asked.header.Get(requestIDHeader) == "the-visitor's" { t.Error("the question carries the visitor's id") } // Exactly these: visit's connection has the visitor send Connection: close. want := http.Header{} want.Set("Connection", "close") want.Set("X-Test", "from the visitor") want.Set(forwardedForHeader, "198.51.100.1") want.Set(forwardedProtoHeader, "http") want.Set(requestIDHeader, asked.header.Get(requestIDHeader)) want.Set(proxy.ClientHeader, localhost) want.Set(proxy.PeerHeader, localhost) if !maps.EqualFunc(asked.header, want, slices.Equal) { t.Errorf("the question's headers are %v, want %v", asked.header, want) } } func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) { t.Parallel() const visitor = "203.0.113.9" for _, tc := range []struct { name, trusted, client, proto, forwarded string }{ { "from a trusted proxy", localhost + "/32", visitor, "https", visitor + ", " + localhost, }, {"from a peer not trusted", "10.0.0.0/8", localhost, "http", localhost}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, allow) forwarded := make(chan http.Header, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { forwarded <- r.Header.Clone() greet(w, r) }) addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{ trustedProxies: tc.trusted, }) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header.Set(forwardedForHeader, visitor) req.Header.Set(forwardedProtoHeader, "https") wantGreeting(t, visit(t, req)) asked := servewaf.question(t) if asked.header.Get(proxy.ClientHeader) != tc.client || asked.header.Get(proxy.PeerHeader) != localhost || asked.header.Get(forwardedForHeader) != visitor || asked.header.Get(forwardedProtoHeader) != tc.proto { t.Errorf("the question's headers are %v", asked.header) } if line := out.requestLine(t); line["client_ip"] != tc.client { t.Errorf("line %v, want client_ip %s", line, tc.client) } if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded { t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded) } }) } } func TestQuestionNamesTheClientAsTheForwardedAddressesSay(t *testing.T) { t.Parallel() for _, tc := range []struct { name string header []string client string }{ { "the first address outside the trusted proxies, from the right", []string{"198.51.100.7, 203.0.113.9, 10.0.0.2"}, "203.0.113.9", }, { "the leftmost, when every address is a trusted proxy", []string{"10.0.0.5, 10.0.0.2"}, "10.0.0.5", }, {"the peer, with no header", nil, localhost}, { "the last address read, before an entry that is not an address", []string{"203.0.113.9, unknown, 10.0.0.2"}, "10.0.0.2", }, { "read as one list, with several header lines", []string{"2001:db8::7", "10.0.0.2"}, "2001:db8::7", }, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, allow) app := startApp(t, greet) addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{ trustedProxies: localhost + "/32,10.0.0.0/8", }) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req.Header[forwardedForHeader] = tc.header wantGreeting(t, visit(t, req)) if got := servewaf.question(t).header.Get(proxy.ClientHeader); got != tc.client { t.Errorf("the question names %s, want %s", got, tc.client) } }) } } // randomBytes returns n random bytes. func randomBytes(n int) []byte { body := make([]byte, n) _, _ = rand.Read(body) // it never returns an error return body } func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) { t.Parallel() for _, size := range []int{ 1, questionBodyMaxBytes - 1, questionBodyMaxBytes, questionBodyMaxBytes + 1, 5 * questionBodyMaxBytes, } { for _, announced := range []bool{true, false} { name := strconv.Itoa(size) + " bytes, length announced: " + strconv.FormatBool(announced) t.Run(name, func(t *testing.T) { t.Parallel() body := randomBytes(size) servewaf := startServeWAF(t, allow) received := make(chan []byte, 1) app := startApp(t, func(w http.ResponseWriter, r *http.Request) { whole, _ := io.ReadAll(r.Body) received <- whole greet(w, r) }) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body)) if !announced { // Go's client sends a body of unknown length in chunks. req.ContentLength = -1 } wantGreeting(t, visit(t, req)) asked := servewaf.question(t) want := body[:min(size, questionBodyMaxBytes)] wantQuestionBody(t, asked, want, size, announced) if !bytes.Equal(<-received, body) { t.Error("the app did not get the whole body") } if report := servewaf.report(t); report.RequestBytes != int64(size) { t.Errorf("report %+v, of %d bytes", report, size) } }) } } } // wantQuestionBody checks the body a question carries, want, and how it gives // the length of the visitor's body, of size bytes. func wantQuestionBody( t *testing.T, asked question, want []byte, size int, announced bool, ) { t.Helper() if !bytes.Equal(asked.body, want) { t.Errorf("the question carries %d bytes, want the first %d", len(asked.body), len(want)) } values := asked.header.Values(proxy.ContentLengthHeader) if announced { if !slices.Equal(values, []string{strconv.Itoa(size)}) || asked.contentLength != int64(len(want)) { t.Errorf("announced %v and length %d, want %d and %d", values, asked.contentLength, size, len(want)) } return } if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{chunked}) { t.Errorf("announced %v and encoded %v, want none and chunked", values, asked.transferEncoding) } } func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, deny) app := startApp(t, greet) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) // Far more announced than sent. Go's server does not wait for that much, so // the answer comes at once. conn := dial(t, addr) send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+ strings.Repeat("x", questionBodyMaxBytes)) if status := readStatus(t, conn); status != http.StatusForbidden { t.Errorf("status %d, want servewaf's 403", status) } if report := servewaf.report(t); report.RequestBytes != questionBodyMaxBytes { t.Errorf("report %+v, want the first part's bytes", report) } } func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) { t.Parallel() for _, tc := range []struct { name, framing, body string encoding []string }{ { chunked, "Transfer-Encoding: chunked\r\n\r\n4\r\nbody\r\n0\r\n\r\n", "body", []string{chunked}, }, { "chunked and empty", "Transfer-Encoding: chunked\r\n\r\n0\r\n\r\n", "", []string{chunked}, }, {"with its length", announcedLength(4) + "body", "body", nil}, } { for _, method := range []string{ http.MethodGet, http.MethodDelete, http.MethodPost, } { t.Run(method+" "+tc.name, func(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, deny) addr, _ := startRProxy(t, servewaf.URL, nowhere, nil) conn := dial(t, addr) send(t, conn, method+" /x HTTP/1.1\r\nHost: site.example\r\n"+tc.framing) if status := readStatus(t, conn); status != http.StatusForbidden { t.Errorf("status %d, want servewaf's 403", status) } asked := servewaf.question(t) if string(asked.body) != tc.body || !slices.Equal(asked.transferEncoding, tc.encoding) { t.Errorf("the question carries %q, encoded %v, want %q, encoded %v", asked.body, asked.transferEncoding, tc.body, tc.encoding) } }) } } } func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { t.Parallel() for _, method := range []string{http.MethodTrace, "PROPFIND", "LOCK", "BREW"} { t.Run(method, func(t *testing.T) { t.Parallel() servewaf := startServeWAF(t, allow) app := startApp(t, func(http.ResponseWriter, *http.Request) { t.Error("the app was asked") }) addr, out := startRProxy(t, servewaf.URL, app.URL, nil) got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) if got.status != http.StatusMethodNotAllowed || got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY" { t.Errorf("answered %+v, want 405 naming the methods passed on", got) } line := out.requestLine(t) if line["status"] != float64(http.StatusMethodNotAllowed) || line["disposition"] != nil || line["default_disposition"] != nil { t.Errorf("line %v, want status 405 and no disposition", line) } if len(servewaf.questions) != 0 { t.Error("servewaf was asked") } servewaf.wantNoReport(t) }) } } func TestEachMethodPassedOnReachesServeWAFAndTheApp(t *testing.T) { t.Parallel() for _, method := range []string{ http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions, http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY", } { t.Run(method, func(t *testing.T) { t.Parallel() reached := make(chan string, 1) servewaf := startServeWAF(t, allow) app := startApp(t, func(_ http.ResponseWriter, r *http.Request) { reached <- r.Method }) addr, _ := startRProxy(t, servewaf.URL, app.URL, nil) got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) if got.status != http.StatusOK { t.Errorf("answered %+v, want the app's 200", got) } if asked := servewaf.question(t); asked.method != method { t.Errorf("servewaf was asked about %s", asked.method) } if atApp := <-reached; atApp != method { t.Errorf("the app was asked %s", atApp) } }) } } func TestARequestThatCannotGoToServeWAFAsSentIsRefused(t *testing.T) { t.Parallel() const host = "Host: site.example\r\n\r\n" for name, request := range map[string]string{ "a target of a scheme": "GET x:admin HTTP/1.1\r\n" + host, "a target of a URL": "GET http://site.example/ HTTP/1.1\r\n" + host, "a host Go will not send": "GET http://a