check / check (push) Waiting to run
The package passes nothing to an app since rproxy took that over, so it is named after the command it serves. Its constructor is New, like rproxy's. The rproxy tests call their stand-in for servewaf standIn, since a local servewaf would hide the package. No change of behaviour. Model: opus-5-5
832 lines
22 KiB
Go
832 lines
22 KiB
Go
package rproxy_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/smallwebwaf/internal/config"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/servewaf"
|
|
)
|
|
|
|
// post is the start of a POST whose headers end with what follows it.
|
|
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
|
|
|
|
// announcedLength is the header that announces a body of size bytes, and the
|
|
// end of the headers.
|
|
func announcedLength(size int) string {
|
|
return "Content-Length: " + strconv.Itoa(size) + "\r\n\r\n"
|
|
}
|
|
|
|
func TestServerHasTheFixedLimits(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
|
if name == "SWRP_WAF_BACKEND" {
|
|
return nowhere, true
|
|
}
|
|
|
|
return "", false
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("settings: %v", err)
|
|
}
|
|
|
|
server := rproxy.New(rproxy.Params{
|
|
Config: cfg,
|
|
RequestLog: io.Discard,
|
|
ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo),
|
|
})
|
|
|
|
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
|
|
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
|
|
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
|
|
"header timeout %s", server.Addr, server.MaxHeaderBytes,
|
|
server.IdleTimeout, server.ReadHeaderTimeout)
|
|
}
|
|
}
|
|
|
|
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, limit string
|
|
size, asked int
|
|
}{
|
|
{"with the limit within the first part", "1K", 1025, 1024},
|
|
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestSize: tc.limit,
|
|
})
|
|
|
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
bytes.NewReader(make([]byte, tc.size))))
|
|
if got.status != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("answered %+v, want 413", got)
|
|
}
|
|
|
|
if asked := standIn.question(t); len(asked.body) != tc.asked {
|
|
t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("report %+v, want status 413", report)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestSize: "1K",
|
|
})
|
|
|
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
bytes.NewReader(make([]byte, 1025))))
|
|
if got.status != http.StatusForbidden {
|
|
t.Errorf("answered %+v, want servewaf's 403", got)
|
|
}
|
|
}
|
|
|
|
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, limit string
|
|
size int
|
|
// mayReachApp is true when the part of the body the limit lets through
|
|
// goes on to the app before the limit is met. The app may still not have
|
|
// started on the request when it is cut off.
|
|
mayReachApp bool
|
|
}{
|
|
{"within the first part", "1K", 2 << 10, false},
|
|
{"past the first part", "128K", 129 << 10, true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var asked atomic.Bool
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
asked.Store(true)
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestSize: tc.limit,
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+
|
|
strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+
|
|
"\r\n0\r\n\r\n")
|
|
|
|
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("status %d, want 413", status)
|
|
}
|
|
|
|
if asked.Load() && !tc.mayReachApp {
|
|
t.Error("the app was asked")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAResponseOverItsLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
large := strings.Repeat("x", 2<<10)
|
|
|
|
t.Run("announced, is refused", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
|
|
_, _ = io.WriteString(w, large)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseSize: "1K",
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %d, want 502", got.status)
|
|
}
|
|
|
|
if report := standIn.report(t); report.Status != http.StatusBadGateway {
|
|
t.Errorf("report %+v, want status 502", report)
|
|
}
|
|
})
|
|
|
|
t.Run("not announced, is cut off", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, large[:1])
|
|
_ = http.NewResponseController(w).Flush()
|
|
_, _ = io.WriteString(w, large[1:])
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseSize: "1K",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
text, err := io.ReadAll(conn)
|
|
if err != nil || bytes.Contains(text, []byte(large)) {
|
|
t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
|
|
t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestAHeadWhoseAnswerAnnouncesMoreThanTheResponseLimitIsPassedOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const announced = "2048"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(contentLengthHeader, announced)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseSize: "1K",
|
|
})
|
|
|
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
|
if got.status != http.StatusOK || got.header.Get(contentLengthHeader) != announced {
|
|
t.Errorf("answered %+v, want the app's 200 announcing %s bytes", got, announced)
|
|
}
|
|
}
|
|
|
|
func TestASettingOfOffSetsNoLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, setting := range []string{
|
|
maxRequestSize, maxResponseSize, maxRequestTime, maxResponseTime,
|
|
} {
|
|
t.Run(setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
setting: "off",
|
|
})
|
|
|
|
// A body and an answer that a limit of zero would cut off, as it would the
|
|
// next answer, which rproxy gives itself.
|
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
strings.NewReader("a body"))))
|
|
|
|
if got := get(t, addr, servewaf.HealthPath); got.status != http.StatusOK {
|
|
t.Errorf("answered %+v, want rproxy's own 200", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, rest string
|
|
}{
|
|
{"with its length announced", announcedLength(100) + "some of it"},
|
|
{"with no length announced", "Transfer-Encoding: chunked\r\n\r\na\r\nsome of it\r\n"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "500",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+tc.rest)
|
|
|
|
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
|
|
t.Errorf("status %d, want 408", status)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != nil || line["request_bytes"] != 10.0 {
|
|
t.Errorf("line %v, want no disposition and 10 bytes", line)
|
|
}
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+announcedLength(100)+"some of it")
|
|
_ = conn.Close()
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != 0.0 || line["disposition"] != nil {
|
|
t.Errorf("line %v, want no answer and no disposition", line)
|
|
}
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
}
|
|
|
|
func TestABodyThatCannotBeReadClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, request string
|
|
stopsSending bool
|
|
}{
|
|
{
|
|
"a malformed chunk", post + "Transfer-Encoding: chunked\r\n\r\nnot a chunk\r\n",
|
|
false,
|
|
},
|
|
{"a visitor that stops sending", post + announcedLength(100) + "some of it", true},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, tc.request)
|
|
|
|
if tc.stopsSending {
|
|
tcpConn, ok := conn.(*net.TCPConn)
|
|
if !ok {
|
|
t.Fatalf("the connection is a %T", conn)
|
|
}
|
|
|
|
err := tcpConn.CloseWrite()
|
|
if err != nil {
|
|
t.Fatalf("stop sending: %v", err)
|
|
}
|
|
}
|
|
|
|
text, err := io.ReadAll(conn)
|
|
if err != nil || len(text) != 0 {
|
|
t.Errorf("read %q (%v), want the connection closed without an answer",
|
|
text, err)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != 0.0 || line["disposition"] != nil {
|
|
t.Errorf("line %v, want no answer and no disposition", line)
|
|
}
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// deadlineRecorder notes the time limits rproxy sets on reading the visitor's body
|
|
// and on sending its answer.
|
|
type deadlineRecorder struct {
|
|
*httptest.ResponseRecorder
|
|
|
|
readDeadline time.Time
|
|
writeDeadline time.Time
|
|
}
|
|
|
|
// SetReadDeadline notes deadline.
|
|
func (w *deadlineRecorder) SetReadDeadline(deadline time.Time) error {
|
|
w.readDeadline = deadline
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetWriteDeadline notes deadline.
|
|
func (w *deadlineRecorder) SetWriteDeadline(deadline time.Time) error {
|
|
w.writeDeadline = deadline
|
|
|
|
return nil
|
|
}
|
|
|
|
func TestABodyLeftUnreadIsReadOnlyUntilTheRequestTimeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
limit = 5 * time.Minute
|
|
// announced is over what Go's server reads of a body left unread, so that
|
|
// the app's own server answers without waiting for the rest.
|
|
announced = 100 * questionBodyMaxBytes
|
|
)
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
|
})
|
|
|
|
// The visitor sends the first part and a little more, and then nothing.
|
|
stalled, stop := io.Pipe()
|
|
|
|
t.Cleanup(func() { _ = stop.Close() })
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/",
|
|
io.MultiReader(bytes.NewReader(make([]byte, questionBodyMaxBytes+1)), stalled))
|
|
req.ContentLength = announced
|
|
req.Header.Set(contentLengthHeader, strconv.Itoa(announced))
|
|
|
|
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
|
|
|
before := time.Now()
|
|
|
|
server.Handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Errorf("status %d, want the app's 204", w.Code)
|
|
}
|
|
|
|
if w.readDeadline.Before(before.Add(limit)) ||
|
|
w.readDeadline.After(time.Now().Add(limit)) {
|
|
t.Errorf("the reading of the body ends at %s, want %s from the request",
|
|
w.readDeadline, limit)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerRProxyGivesItselfHasTheResponseTimeToBeSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const limit = 5 * time.Minute
|
|
|
|
for _, tc := range []struct {
|
|
name, target string
|
|
status int
|
|
}{
|
|
{"servewaf's deny", "/", http.StatusForbidden},
|
|
{"rproxy's own 400", "x:admin", http.StatusBadRequest},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
_, _, server := startServer(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseTime: strconv.FormatInt(limit.Milliseconds(), 10),
|
|
})
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, tc.target,
|
|
http.NoBody)
|
|
w := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()}
|
|
|
|
before := time.Now()
|
|
|
|
server.Handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != tc.status {
|
|
t.Errorf("status %d, want %d", w.Code, tc.status)
|
|
}
|
|
|
|
if w.writeDeadline.Before(before.Add(limit)) ||
|
|
w.writeDeadline.After(time.Now().Add(limit)) {
|
|
t.Errorf("the time limit on sending ends at %s, want %s from the answer",
|
|
w.writeDeadline, limit)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "3000",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
|
|
strings.Repeat("x", questionBodyMaxBytes+1))
|
|
|
|
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
|
|
t.Errorf("status %d, want 408", status)
|
|
}
|
|
|
|
if asked := standIn.question(t); len(asked.body) != questionBodyMaxBytes {
|
|
t.Errorf("the question carries %d bytes", len(asked.body))
|
|
}
|
|
|
|
if report := standIn.report(t); report.Status != http.StatusRequestTimeout {
|
|
t.Errorf("report %+v, want status 408", report)
|
|
}
|
|
}
|
|
|
|
// startAppThatTakesNothing starts an app that accepts connections and never
|
|
// reads from them, and returns its URL.
|
|
func startAppThatTakesNothing(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
stopped := make(chan struct{})
|
|
|
|
t.Cleanup(func() {
|
|
close(stopped)
|
|
|
|
_ = listener.Close()
|
|
})
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
<-stopped
|
|
|
|
_ = conn.Close()
|
|
}()
|
|
}
|
|
}()
|
|
|
|
return "http://" + listener.Addr().String()
|
|
}
|
|
|
|
func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// More than the connections hold while nobody reads, written a little at a
|
|
// time, so that rproxy soon waits for the app.
|
|
const (
|
|
bodySize = 64 << 20
|
|
writeSize = 32 << 10
|
|
)
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
addr, _ := startRProxy(t, standIn.URL, startAppThatTakesNothing(t),
|
|
map[string]string{maxRequestTime: "3000"})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+announcedLength(bodySize))
|
|
|
|
go func() {
|
|
part := make([]byte, writeSize)
|
|
|
|
for range bodySize / writeSize {
|
|
_, err := conn.Write(part)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
if status := readStatus(t, conn); status != http.StatusGatewayTimeout {
|
|
t.Errorf("status %d, want 504", status)
|
|
}
|
|
}
|
|
|
|
func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
<-release
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseTime: "300",
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusGatewayTimeout {
|
|
t.Errorf("answered %d, want 504", got.status)
|
|
}
|
|
|
|
if report := standIn.report(t); report.Status != http.StatusGatewayTimeout {
|
|
t.Errorf("report %+v, want status 504", report)
|
|
}
|
|
}
|
|
|
|
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const start = "the start"
|
|
|
|
release := make(chan struct{})
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, start)
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
<-release
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
// The time limit is long enough for a loaded host to pass on the start of the
|
|
// answer before it runs out.
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseTime: "3000",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
// The body breaks off when rproxy closes the connection. If the connection
|
|
// stays open, dial's deadline ends this read with a timeout instead.
|
|
body, err := io.ReadAll(res.Body)
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusOK || string(body) != start ||
|
|
!errors.Is(err, io.ErrUnexpectedEOF) {
|
|
t.Errorf("answered %d %q (%v), want the app's 200 %q, then the connection closed",
|
|
res.StatusCode, body, err, start)
|
|
}
|
|
|
|
if report := standIn.report(t); report.Status != http.StatusOK {
|
|
t.Errorf("report %+v, want the status the visitor got", report)
|
|
}
|
|
}
|
|
|
|
func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// More than the connections hold while nobody reads.
|
|
const (
|
|
bodySize = 64 << 20
|
|
writeSize = 32 << 10
|
|
)
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
part := make([]byte, writeSize)
|
|
|
|
for range bodySize / writeSize {
|
|
_, err := w.Write(part)
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
})
|
|
// The time limit is long enough for a loaded host to start passing on the
|
|
// answer before it runs out.
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxResponseTime: "3000",
|
|
})
|
|
|
|
// The visitor asks, and never reads the answer.
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
line := out.requestLine(t)
|
|
|
|
responseBytes, _ := line["response_bytes"].(float64)
|
|
if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize {
|
|
t.Errorf("line %v, want status 200 and the answer cut off", line)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
|
|
t.Errorf("report %+v, want status 200 and the answer cut off", report)
|
|
}
|
|
}
|
|
|
|
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %d, want 502", got.status)
|
|
}
|
|
|
|
if report := standIn.report(t); report.Status != http.StatusBadGateway {
|
|
t.Errorf("report %+v, want status 502", report)
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, greeting)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "3000",
|
|
})
|
|
|
|
// Far more announced than sent, so that Go's server answers for the app
|
|
// without waiting for the rest.
|
|
conn := dial(t, addr)
|
|
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
|
|
strings.Repeat("x", questionBodyMaxBytes))
|
|
|
|
if status := readStatus(t, conn); status != http.StatusOK {
|
|
t.Errorf("status %d, want the app's 200", status)
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatStopsSendingWhileTheAppWorksGetsNoAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
working := make(chan struct{})
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
close(working)
|
|
<-r.Context().Done()
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
select {
|
|
case <-working:
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("the app was not asked")
|
|
}
|
|
|
|
tcpConn, ok := conn.(*net.TCPConn)
|
|
if !ok {
|
|
t.Fatalf("the connection is a %T", conn)
|
|
}
|
|
|
|
err := tcpConn.CloseWrite()
|
|
if err != nil {
|
|
t.Fatalf("stop sending: %v", err)
|
|
}
|
|
|
|
text, err := io.ReadAll(conn)
|
|
if err != nil || len(text) != 0 {
|
|
t.Errorf("read %q (%v), want the connection closed without an answer", text, err)
|
|
}
|
|
|
|
if line := out.requestLine(t); line["status"] != 0.0 {
|
|
t.Errorf("line %v, want status 0", line)
|
|
}
|
|
}
|
|
|
|
func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const answer = "the end of a long answer"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
_, _ = io.WriteString(w, answer)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "2000",
|
|
})
|
|
|
|
got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))
|
|
if got.status != http.StatusOK || got.body != answer {
|
|
t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body)
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, start string
|
|
status int
|
|
}{
|
|
{"a deny", post, http.StatusForbidden},
|
|
{
|
|
"the health check",
|
|
"GET " + servewaf.HealthPath + " HTTP/1.1\r\nHost: site.example\r\n",
|
|
http.StatusOK,
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
// The time limit is long enough for a loaded host to read the first part of
|
|
// the body before it runs out.
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "3000",
|
|
})
|
|
|
|
// Go's server waits for a little of the body left before it sends an answer,
|
|
// and the time for sending the request ends that wait.
|
|
conn := dial(t, addr)
|
|
send(t, conn, tc.start+announcedLength(questionBodyMaxBytes+100)+
|
|
strings.Repeat("x", questionBodyMaxBytes))
|
|
|
|
if status := readStatus(t, conn); status != tc.status {
|
|
t.Errorf("status %d, want %d", status, tc.status)
|
|
}
|
|
})
|
|
}
|
|
}
|