check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. Its constructor is New, like rproxy's. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
524 lines
15 KiB
Go
524 lines
15 KiB
Go
package rproxy_test
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"io"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
// questionBodyMaxBytes is the most of a body a question carries.
|
|
const questionBodyMaxBytes = servewaf.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()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.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(servewaf.ClientHeader, "198.51.100.2")
|
|
req.Header.Set(servewaf.PeerHeader, "198.51.100.3")
|
|
req.Header.Set(servewaf.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 := standIn.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(servewaf.ClientHeader, localhost)
|
|
want.Set(servewaf.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()
|
|
|
|
standIn := 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, standIn.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 := standIn.question(t)
|
|
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
|
asked.header.Get(servewaf.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()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.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 := standIn.question(t).header.Get(servewaf.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)
|
|
standIn := 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, standIn.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 := standIn.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 := standIn.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(servewaf.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()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.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 := standIn.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()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
addr, _ := startRProxy(t, standIn.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 := standIn.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()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.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(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
|
|
standIn.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)
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
reached <- r.Method
|
|
})
|
|
addr, _ := startRProxy(t, standIn.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 := standIn.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<b/ HTTP/1.1\r\n" + host,
|
|
"a target of a host": "GET site.example:443 HTTP/1.1\r\n" + host,
|
|
"a target of *": "GET * HTTP/1.1\r\n" + host,
|
|
"a target of //": "GET //x/y HTTP/1.1\r\n" + host,
|
|
"a target of // escaped": "GET //x%2Fadmin| HTTP/1.1\r\n" + host,
|
|
"an empty Host": "GET / HTTP/1.1\r\nHost:\r\n\r\n",
|
|
"HTTP/1.0 with no Host": "GET / HTTP/1.0\r\n\r\n",
|
|
"a Host with a zone": "GET / HTTP/1.1\r\nHost: [fe80::1%25eth0]:80\r\n\r\n",
|
|
"two User-Agents": "GET / HTTP/1.1\r\nUser-Agent: a\r\nUser-Agent: b\r\n" +
|
|
host,
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, request)
|
|
|
|
if status := readStatus(t, conn); status != http.StatusBadRequest {
|
|
t.Errorf("status %d, want 400", status)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != float64(http.StatusBadRequest) ||
|
|
line["disposition"] != nil || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 400 and no disposition", line)
|
|
}
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const host = "a%b"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: "+host+"\r\n\r\n")
|
|
|
|
if status := readStatus(t, conn); status != http.StatusOK {
|
|
t.Errorf("status %d, want the app's 200", status)
|
|
}
|
|
|
|
if asked := standIn.question(t); asked.host != host {
|
|
t.Errorf("asked about the host %q, want %q", asked.host, host)
|
|
}
|
|
}
|
|
|
|
func TestATrustedPeerWithNoForwardedProtoIsAskedAboutAsHTTP(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const want = "http"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
asked := standIn.question(t)
|
|
if got := asked.header.Get(forwardedProtoHeader); got != want {
|
|
t.Errorf("the question's %s is %q, want %s", forwardedProtoHeader, got, want)
|
|
}
|
|
}
|
|
|
|
func TestAQuestionNamesNoZone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, remoteAddr, forwardedFor, client, peer string
|
|
}{
|
|
{"from a link-local peer", "[fe80::1%eth0]:1234", "", "fe80::1", "fe80::1"},
|
|
{
|
|
"from X-Forwarded-For through a trusted peer", localhost + ":1234",
|
|
"2001:db8::1%eth0", "2001:db8::1", localhost,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
_, out, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
req.RemoteAddr = tc.remoteAddr
|
|
req.Header.Set(forwardedForHeader, tc.forwardedFor)
|
|
|
|
server.Handler.ServeHTTP(httptest.NewRecorder(), req)
|
|
|
|
asked := standIn.question(t)
|
|
if asked.header.Get(servewaf.ClientHeader) != tc.client ||
|
|
asked.header.Get(servewaf.PeerHeader) != tc.peer {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|