check / check (push) Failing after 14m44s
`smallwebwaf` with no command now prints the commands and exits with status 2. The image runs rproxy, or servewaf with SMALLWEBWAF_COMMAND=servewaf. The passing-on code, its settings and its log fields and metric go. internal/proxy keeps the checks, answers, reports and own endpoints; its tests run through the real rproxy. The documents describe the pair. Judgement call: clients.json keeps token_refused, since state files refuse unknown fields. Deviation: script/gitea-requests sets SWWAF_LOG_REQUEST_HEADERS, to label requests. Partially verified: make example-app and make gitea-requests never built the image, as Ubuntu's snapshot service answered 5xx. 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/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<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)
|
|
}
|
|
})
|
|
}
|
|
}
|