check / check (push) Failing after 4m4s
rproxy passes MKCALENDAR on. nextcloud.conf allows it on /remote.php/dav/calendars/ and PROPFIND on /.well-known/caldav and carddav. The XML of an MKCOL there skips 930120, 932120, 932250 and 932260. That of an MKCALENDAR, and of an MKCOL on the address books' paths, also skips 932125 and 932230, for the description. A PUT of text/calendar or text/vcard there skips 920530: the Calendar and Contacts apps name the charset twice when they change an event or a contact. Recorded from Nextcloud 35.0.2 with Calendar 6.6.5, Contacts 8.9.5 and requests imitating DAVx5. Model: opus-5-5
529 lines
16 KiB
Go
529 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, "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, "+
|
|
"MKCALENDAR" {
|
|
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", "MKCALENDAR",
|
|
} {
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|