Files
smallwebwaf/internal/rproxy/question_test.go
T
sneak e0b71be0f7
check / check (push) Failing after 12m17s
rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
`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 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. A method SPEC.md does not list gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; gosec at
internal/smallwebwaf/rproxy_test.go:431; tagliatelle at
internal/requestlog/requestlog.go:201; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 200, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
2026-11-05 04:07:40 +00:00

478 lines
14 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 TestAHostWithAPercentSignButNoBracketGoesToServeWAF(t *testing.T) {
t.Parallel()
const host = "a%b"
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.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 := servewaf.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"
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
trustedProxies: localhost + "/32",
})
wantGreeting(t, get(t, addr, "/"))
asked := servewaf.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()
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)
}
})
}
}