check / check (push) Successful in 4m26s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over HTTP/1.1, and passes what servewaf allows to the app within its own limits. Any other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION decides only when servewaf cannot be connected to, does not answer at all, or does not answer within SWRP_TIMEOUT_MS. Only the methods SPEC.md lists pass on; any other gets 405. Duplication: internal/rproxy copies passing-on code from internal/proxy until #172 removes the originals. Rule suppressed: gosec G704 and G705; paralleltest on one test. Judgement call: an answer over 1 MiB, or with a status below 100, gets 502. Judgement call: every target not starting with one / gets 400. Model: opus-5-5
438 lines
13 KiB
Go
438 lines
13 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/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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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<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()
|
|
|
|
servewaf := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, servewaf.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(servewaf.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
|
|
servewaf.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
servewaf := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
_, out, server := startServer(t, servewaf.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 := servewaf.question(t)
|
|
if asked.header.Get(proxy.ClientHeader) != tc.client ||
|
|
asked.header.Get(proxy.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)
|
|
}
|
|
})
|
|
}
|
|
}
|