package rproxy_test import ( "bytes" "crypto/rand" "io" "maps" "net/http" "slices" "strconv" "strings" "testing" "sneak.berlin/go/smallwebwaf/internal/proxy" ) // questionBodyMaxBytes is the most of a body a question carries. const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes 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 = "/some%2Fpath/with%20space?b=2&a=1&bad=%zz;x" req := newRequest(t, http.MethodDelete, addr, target, http.NoBody) 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) } }) } } // 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) } }