check / check (push) Waiting to run
rproxy now passes PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK and UNLOCK to discriminator instead of answering 405, so Nextcloud's Files app and sync client work. The Core Rule Set's 911100 still refuses them wherever no app's exclusions allow them. Model: opus-5-5
528 lines
16 KiB
Go
528 lines
16 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/discriminator"
|
|
)
|
|
|
|
// questionBodyMaxBytes is the most of a body a question carries.
|
|
const questionBodyMaxBytes = discriminator.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 := startDiscriminator(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(discriminator.ClientHeader, "198.51.100.2")
|
|
req.Header.Set(discriminator.PeerHeader, "198.51.100.3")
|
|
req.Header.Set(discriminator.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(discriminator.ClientHeader, localhost)
|
|
want.Set(discriminator.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 := startDiscriminator(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(discriminator.ClientHeader) != tc.client ||
|
|
asked.header.Get(discriminator.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 := startDiscriminator(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(discriminator.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 TestFirst64KiBReachDiscriminatorAndTheWholeBodyTheApp(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 := startDiscriminator(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(discriminator.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 := startDiscriminator(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 discriminator'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 := startDiscriminator(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 discriminator'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, "MKCALENDAR", "BREW"} {
|
|
t.Run(method, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startDiscriminator(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, "+
|
|
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK" {
|
|
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("discriminator was asked")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, method := range []string{
|
|
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
|
|
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
|
|
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "REPORT", "SEARCH",
|
|
"LOCK", "UNLOCK",
|
|
} {
|
|
t.Run(method, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reached := make(chan string, 1)
|
|
standIn := startDiscriminator(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("discriminator was asked about %s", asked.method)
|
|
}
|
|
|
|
if atApp := <-reached; atApp != method {
|
|
t.Errorf("the app was asked %s", atApp)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestARequestThatCannotGoToDiscriminatorAsSentIsRefused(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 := startDiscriminator(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("discriminator was asked")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAHostWithAPercentSignButNoBracketGoesToDiscriminator(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const host = "a%b"
|
|
|
|
standIn := startDiscriminator(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 := startDiscriminator(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 := startDiscriminator(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(discriminator.ClientHeader) != tc.client ||
|
|
asked.header.Get(discriminator.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)
|
|
}
|
|
})
|
|
}
|
|
}
|