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. 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
1761 lines
46 KiB
Go
1761 lines
46 KiB
Go
package rproxy_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
// waitLimit bounds how long a test waits for what should happen.
|
|
waitLimit = 10 * time.Second
|
|
// pollInterval is how often a test looks for a log line.
|
|
pollInterval = 10 * time.Millisecond
|
|
// localhost is where every test server listens, and so the address rproxy
|
|
// sees each test's requests come from.
|
|
localhost = "127.0.0.1"
|
|
// site is the host the tests' requests ask for.
|
|
site = "site.example"
|
|
// greeting is what the tests' app answers by default.
|
|
greeting = "hello from the app"
|
|
// nowhere is a URL nothing listens at: port 9 is the discard service, which
|
|
// nothing on a test host runs.
|
|
nowhere = "http://127.0.0.1:9"
|
|
)
|
|
|
|
// The settings the tests set.
|
|
const (
|
|
timeoutSetting = "SWRP_TIMEOUT_MS"
|
|
defaultDisposition = "SWRP_DEFAULT_DISPOSITION"
|
|
trustedProxies = "SWRP_TRUSTED_PROXIES"
|
|
maxRequestSize = "SWRP_MAX_REQUEST_SIZE"
|
|
maxResponseSize = "SWRP_MAX_RESPONSE_SIZE"
|
|
maxRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
|
|
maxResponseTime = "SWRP_MAX_RESPONSE_TIME_MS"
|
|
)
|
|
|
|
// The headers the tests set and look at.
|
|
const (
|
|
dispositionHeader = servewaf.DispositionHeader
|
|
forwardedForHeader = "X-Forwarded-For"
|
|
forwardedProtoHeader = "X-Forwarded-Proto"
|
|
requestIDHeader = "X-Request-ID"
|
|
asnHeader = "X-Client-Asn"
|
|
countryHeader = "X-Client-Country"
|
|
contentLengthHeader = "Content-Length"
|
|
// fromServeWAFHeader is a header the stand-in for servewaf adds to a deny.
|
|
fromServeWAFHeader = "X-From-Servewaf"
|
|
// addedHeaderValue is the value of a header a test adds to an answer, to see it
|
|
// reach the visitor.
|
|
addedHeaderValue = "yes"
|
|
)
|
|
|
|
// What the stand-in for servewaf answers.
|
|
const (
|
|
dispositionAllow = servewaf.DispositionAllow
|
|
dispositionDeny = servewaf.DispositionDeny
|
|
dispositionClose = servewaf.DispositionClose
|
|
// deniedBody is the body of a deny.
|
|
deniedBody = "refused by servewaf"
|
|
)
|
|
|
|
// output collects what rproxy writes on stdout.
|
|
type output struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
// Write adds lines rproxy writes.
|
|
func (o *output) Write(p []byte) (int, error) {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return o.buf.Write(p)
|
|
}
|
|
|
|
// requestLine waits for the request log line of a test's one request.
|
|
func (o *output) requestLine(t *testing.T) map[string]any {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
o.mu.Lock()
|
|
text := o.buf.String()
|
|
o.mu.Unlock()
|
|
|
|
for line := range strings.Lines(text) {
|
|
var fields map[string]any
|
|
|
|
err := json.Unmarshal([]byte(line), &fields)
|
|
if err != nil {
|
|
t.Fatalf("output line %q is not JSON: %v", line, err)
|
|
}
|
|
|
|
if fields["type"] == "request" {
|
|
return fields
|
|
}
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatal("no request log line")
|
|
|
|
return nil
|
|
}
|
|
|
|
// has reports whether what rproxy has written so far holds text.
|
|
func (o *output) has(text string) bool {
|
|
o.mu.Lock()
|
|
defer o.mu.Unlock()
|
|
|
|
return strings.Contains(o.buf.String(), text)
|
|
}
|
|
|
|
// waitFor waits until what rproxy has written holds text.
|
|
func (o *output) waitFor(t *testing.T, text string) {
|
|
t.Helper()
|
|
|
|
deadline := time.Now().Add(waitLimit)
|
|
for time.Now().Before(deadline) {
|
|
if o.has(text) {
|
|
return
|
|
}
|
|
|
|
time.Sleep(pollInterval)
|
|
}
|
|
|
|
t.Fatalf("rproxy did not write %q", text)
|
|
}
|
|
|
|
// heldLog is a request log whose Write waits until release is closed.
|
|
type heldLog struct {
|
|
release chan struct{}
|
|
}
|
|
|
|
// Write waits until release is closed.
|
|
func (l heldLog) Write(p []byte) (int, error) {
|
|
<-l.release
|
|
|
|
return len(p), nil
|
|
}
|
|
|
|
// question is what the stand-in for servewaf received as a question.
|
|
type question struct {
|
|
method, target, host string
|
|
header http.Header
|
|
body []byte
|
|
contentLength int64
|
|
transferEncoding []string
|
|
}
|
|
|
|
// serveWAF is a stand-in for servewaf that answers each question as answer
|
|
// does, and keeps the questions and the reports it gets.
|
|
type serveWAF struct {
|
|
*httptest.Server
|
|
|
|
questions chan question
|
|
reports chan servewaf.Report
|
|
}
|
|
|
|
// startServeWAF starts a stand-in for servewaf.
|
|
func startServeWAF(t *testing.T, answer http.HandlerFunc) *serveWAF {
|
|
t.Helper()
|
|
|
|
s := &serveWAF{
|
|
questions: make(chan question, 10),
|
|
reports: make(chan servewaf.Report, 10),
|
|
}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(servewaf.ClientHeader) == "" {
|
|
s.takeReport(t, w, r)
|
|
|
|
return
|
|
}
|
|
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
t.Errorf("read the question's body: %v", err)
|
|
}
|
|
|
|
s.questions <- question{
|
|
r.Method, r.RequestURI, r.Host, r.Header.Clone(), body, r.ContentLength,
|
|
r.TransferEncoding,
|
|
}
|
|
|
|
answer(w, r)
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
|
|
return s
|
|
}
|
|
|
|
// takeReport keeps a report and answers it as servewaf does.
|
|
func (s *serveWAF) takeReport(t *testing.T, w http.ResponseWriter, r *http.Request) {
|
|
t.Helper()
|
|
|
|
var report servewaf.Report
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&report)
|
|
if err != nil || r.Method != http.MethodPost || r.URL.Path != servewaf.ReportPath {
|
|
t.Errorf("%s %s is no report: %v", r.Method, r.URL.Path, err)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
s.reports <- report
|
|
}
|
|
|
|
// wantNoReport checks that no report comes for a moment.
|
|
func (s *serveWAF) wantNoReport(t *testing.T) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case report := <-s.reports:
|
|
t.Errorf("report %+v, want none", report)
|
|
case <-time.After(10 * pollInterval):
|
|
}
|
|
}
|
|
|
|
// report waits for a report.
|
|
func (s *serveWAF) report(t *testing.T) servewaf.Report {
|
|
t.Helper()
|
|
|
|
select {
|
|
case report := <-s.reports:
|
|
return report
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
|
|
return servewaf.Report{}
|
|
}
|
|
}
|
|
|
|
// question waits for a question.
|
|
func (s *serveWAF) question(t *testing.T) question {
|
|
t.Helper()
|
|
|
|
select {
|
|
case asked := <-s.questions:
|
|
return asked
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no question")
|
|
|
|
return question{}
|
|
}
|
|
}
|
|
|
|
// startServeWAFAnsweringReports starts a stand-in for servewaf that answers each
|
|
// question as answer does and each report as report does, and returns its URL.
|
|
func startServeWAFAnsweringReports(
|
|
t *testing.T, answer, report http.HandlerFunc,
|
|
) string {
|
|
t.Helper()
|
|
|
|
backend := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(servewaf.ClientHeader) != "" {
|
|
answer(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
report(w, r)
|
|
}))
|
|
t.Cleanup(backend.Close)
|
|
|
|
return backend.URL
|
|
}
|
|
|
|
// allow answers a question with allow.
|
|
func allow(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionAllow)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// deny answers a question with deny, status 403 and a body of its own.
|
|
func deny(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = io.WriteString(w, deniedBody)
|
|
}
|
|
|
|
// startApp starts app as the app rproxy passes requests to.
|
|
func startApp(t *testing.T, app http.HandlerFunc) *httptest.Server {
|
|
t.Helper()
|
|
|
|
server := httptest.NewServer(app)
|
|
t.Cleanup(server.Close)
|
|
|
|
return server
|
|
}
|
|
|
|
// greet is an app that reads the request's body and answers greeting.
|
|
func greet(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
_, _ = io.WriteString(w, greeting)
|
|
}
|
|
|
|
// startRProxy starts rproxy with servewaf at backend and the app at appURL, and
|
|
// the settings in env on top of the defaults. It returns where rproxy listens
|
|
// and what it writes.
|
|
func startRProxy(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
) (string, *output) {
|
|
t.Helper()
|
|
|
|
addr, out, _ := startServer(t, backend, appURL, env)
|
|
|
|
return addr, out
|
|
}
|
|
|
|
// startServer is startRProxy, and returns the server as well.
|
|
func startServer(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
) (string, *output, *rproxy.Server) {
|
|
t.Helper()
|
|
|
|
out := &output{}
|
|
addr, server := startServerLogging(t, backend, appURL, env, out, out)
|
|
|
|
return addr, out, server
|
|
}
|
|
|
|
// startServerLogging is startServer with rproxy's request lines going to
|
|
// requestLog and its process lines to processLog.
|
|
func startServerLogging(
|
|
t *testing.T, backend, appURL string, env map[string]string,
|
|
requestLog, processLog io.Writer,
|
|
) (string, *rproxy.Server) {
|
|
t.Helper()
|
|
|
|
settings := map[string]string{
|
|
"SWRP_WAF_BACKEND": backend, "SWRP_UPSTREAM_URL": appURL,
|
|
}
|
|
maps.Copy(settings, env)
|
|
|
|
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
|
|
value, ok := settings[name]
|
|
|
|
return value, ok
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", settings, err)
|
|
}
|
|
|
|
server := rproxy.New(rproxy.Params{
|
|
Config: cfg,
|
|
RequestLog: requestLog,
|
|
ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo),
|
|
})
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
|
|
server.WaitForReports()
|
|
})
|
|
|
|
return listener.Addr().String(), server
|
|
}
|
|
|
|
// answered is the visitor's answer.
|
|
type answered struct {
|
|
status int
|
|
header http.Header
|
|
body string
|
|
}
|
|
|
|
// visit sends req as a visitor, on a connection of its own, and returns the
|
|
// answer.
|
|
func visit(t *testing.T, req *http.Request) answered {
|
|
t.Helper()
|
|
|
|
transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true}
|
|
defer transport.CloseIdleConnections()
|
|
|
|
res, err := transport.RoundTrip(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
return answered{res.StatusCode, res.Header, string(body)}
|
|
}
|
|
|
|
// newRequest returns a request for target at addr.
|
|
func newRequest(
|
|
t *testing.T, method, addr, target string, body io.Reader,
|
|
) *http.Request {
|
|
t.Helper()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target,
|
|
body)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// get sends a GET for target at addr, and returns the answer.
|
|
func get(t *testing.T, addr, target string) answered {
|
|
t.Helper()
|
|
|
|
return visit(t, newRequest(t, http.MethodGet, addr, target, http.NoBody))
|
|
}
|
|
|
|
// wantGreeting checks that the answer is the app's.
|
|
func wantGreeting(t *testing.T, got answered) {
|
|
t.Helper()
|
|
|
|
if got.status != http.StatusOK || got.body != greeting {
|
|
t.Errorf("answered %d %q, want the app's greeting", got.status, got.body)
|
|
}
|
|
}
|
|
|
|
// dial opens a connection to addr, closed as the test ends.
|
|
func dial(t *testing.T, addr string) net.Conn {
|
|
t.Helper()
|
|
|
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = conn.Close()
|
|
})
|
|
|
|
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
|
if err != nil {
|
|
t.Fatalf("set the deadline: %v", err)
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
// send writes text to conn.
|
|
func send(t *testing.T, conn net.Conn, text string) {
|
|
t.Helper()
|
|
|
|
_, err := io.WriteString(conn, text)
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
}
|
|
|
|
// readStatus reads the answer's status from conn.
|
|
func readStatus(t *testing.T, conn net.Conn) int {
|
|
t.Helper()
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
return res.StatusCode
|
|
}
|
|
|
|
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(asnHeader, "AS64496")
|
|
w.Header().Set(countryHeader, "DE")
|
|
allow(w, r)
|
|
})
|
|
|
|
seen := make(chan *http.Request, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Clone(t.Context())
|
|
|
|
w.Header().Set("X-App", addedHeaderValue)
|
|
w.WriteHeader(http.StatusTeapot)
|
|
_, _ = io.WriteString(w, greeting)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
|
|
|
|
req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
|
|
req.URL.Opaque = pathGoReencodes
|
|
req.Host = site
|
|
req.Header.Set(asnHeader, "AS1")
|
|
req.Header.Set(countryHeader, "XX")
|
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
|
|
|
got := visit(t, req)
|
|
if got.status != http.StatusTeapot || got.body != greeting ||
|
|
got.header.Get("X-App") != addedHeaderValue {
|
|
t.Errorf("answered %+v, want the app's answer", got)
|
|
}
|
|
|
|
received := <-seen
|
|
asked := standIn.question(t)
|
|
|
|
if received.RequestURI != target || received.Host != site {
|
|
t.Errorf("the app saw %s for %s", received.RequestURI, received.Host)
|
|
}
|
|
|
|
for name, want := range map[string]string{
|
|
asnHeader: "AS64496",
|
|
countryHeader: "DE",
|
|
forwardedForHeader: localhost,
|
|
requestIDHeader: asked.header.Get(requestIDHeader),
|
|
} {
|
|
if received.Header.Get(name) != want {
|
|
t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
removed := []string{
|
|
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
|
|
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
|
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
|
}
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: "10.0.0.0/8",
|
|
})
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
|
req.Header.Set("X-Forwarded-Host", "claimed.example")
|
|
req.Header.Set(forwardedProtoHeader, "https")
|
|
|
|
for _, name := range removed {
|
|
req.Header.Set(name, "claimed")
|
|
}
|
|
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
|
|
for name, want := range map[string]string{
|
|
forwardedForHeader: localhost,
|
|
"X-Forwarded-Host": site,
|
|
forwardedProtoHeader: "http",
|
|
} {
|
|
if got := header.Values(name); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("the app saw %s %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
|
|
for _, name := range removed {
|
|
if got := header.Values(name); len(got) != 0 {
|
|
t.Errorf("the app saw %s %q, want none", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
sent := map[string]string{
|
|
"Forwarded": "for=198.51.100.1",
|
|
"X-Forwarded-Host": "public.example",
|
|
forwardedProtoHeader: "https",
|
|
"X-Real-Ip": "198.51.100.1",
|
|
}
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
|
|
for name, value := range sent {
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
|
|
for name, want := range sent {
|
|
if got := header.Values(name); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("the app saw %s %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
_, _ = io.WriteString(w, "<html>")
|
|
})
|
|
_, _, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
|
|
server.Handler.ServeHTTP(w, req)
|
|
|
|
// Go's server guesses a type when the first part of the body goes before the
|
|
// headers are flushed, which is a matter of timing here. So this looks for what
|
|
// stops it: the key with no value.
|
|
values, there := w.Header()["Content-Type"]
|
|
if !there || len(values) != 0 {
|
|
t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
|
|
values, there)
|
|
}
|
|
}
|
|
|
|
func TestRemovesTheVisitorsLookupHeadersWhenServeWAFGivesNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
backend func(t *testing.T) string
|
|
}{
|
|
{"servewaf allows", func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startServeWAF(t, allow).URL
|
|
}},
|
|
{"servewaf is down", func(*testing.T) string { return nowhere }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, tc.backend(t), app.URL, nil)
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(asnHeader, "AS1")
|
|
req.Header.Set(countryHeader, "XX")
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
if len(header.Values(asnHeader)) != 0 || len(header.Values(countryHeader)) != 0 {
|
|
t.Errorf("the app saw the visitor's lookup headers: %v", header)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDenyGivesTheVisitorServeWAFsAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
|
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want servewaf's answer", got)
|
|
}
|
|
|
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
|
t.Errorf("the visitor got %s", dispositionHeader)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusForbidden ||
|
|
report.ResponseBytes != int64(len(deniedBody)) ||
|
|
report.DurationUpstreamTotal != 0 {
|
|
t.Errorf("report %+v, of a request denied", report)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionDeny || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want servewaf's deny", line)
|
|
}
|
|
}
|
|
|
|
func TestADeniedHeadKeepsServeWAFsContentLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
|
if got.status != http.StatusForbidden ||
|
|
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
|
t.Errorf("answered %+v, want servewaf's 403 and its length", got)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerRProxyHoldsWholeIsSentBeforeItsLogLine(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const host = " HTTP/1.1\r\nHost: site.example\r\n\r\n"
|
|
|
|
for _, tc := range []struct {
|
|
name, request, body string
|
|
status int
|
|
}{
|
|
{"a deny", "GET /" + host, deniedBody, http.StatusForbidden},
|
|
{"rproxy's own answer", "GET x:admin" + host, "Bad Request\n", http.StatusBadRequest},
|
|
{"the health check", "GET " + servewaf.HealthPath + host, "ok\n", http.StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
log := heldLog{release: make(chan struct{})}
|
|
standIn := startServeWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
|
|
|
t.Cleanup(func() { close(log.release) })
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, tc.request)
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil || res.StatusCode != tc.status || string(body) != tc.body {
|
|
t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err,
|
|
tc.status, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET x:admin 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)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.Header.Get("Content-Type") != "text/plain; charset=utf-8" ||
|
|
res.Header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Errorf("the answer's headers are %v", res.Header)
|
|
}
|
|
}
|
|
|
|
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionClose)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
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")
|
|
|
|
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)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != 0 || report.ResponseBytes != 0 {
|
|
t.Errorf("report %+v, of a visitor sent nothing", report)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionClose || line["status"] != 0.0 {
|
|
t.Errorf("line %v, want close and status 0", line)
|
|
}
|
|
}
|
|
|
|
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startServeWAFAnsweringReports(t,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionClose)
|
|
},
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
// A negative time would be about a microsecond, which one request would seldom
|
|
// show.
|
|
for range 100 {
|
|
addr, out := startRProxy(t, backend, nowhere, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
line := out.requestLine(t)
|
|
|
|
duration, ok := line["duration_response"].(float64)
|
|
if !ok || duration < 0 {
|
|
t.Errorf("line %v, want a duration_response that is not negative", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// answeringWith returns what starts a stand-in for servewaf that answers each
|
|
// question as answer does, and returns its URL.
|
|
func answeringWith(answer http.HandlerFunc) func(t *testing.T) string {
|
|
return func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startServeWAF(t, answer).URL
|
|
}
|
|
}
|
|
|
|
// noAnswers are the ways servewaf can leave the decision to the default.
|
|
func noAnswers() map[string]func(t *testing.T) string {
|
|
return map[string]func(t *testing.T) string{
|
|
"no connection": func(*testing.T) string { return nowhere },
|
|
"no answer at all": answeringWith(func(http.ResponseWriter, *http.Request) {
|
|
panic(http.ErrAbortHandler)
|
|
}),
|
|
"a slow start": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
}),
|
|
"headers followed by a stalled body": answeringWith(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
<-r.Context().Done()
|
|
}),
|
|
"an answer cut short": answeringWith(
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionDeny)
|
|
w.Header().Set(contentLengthHeader, "100")
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = io.WriteString(w, deniedBody)
|
|
// Go's server drops on this panic what it has not flushed.
|
|
_ = http.NewResponseController(w).Flush()
|
|
|
|
panic(http.ErrAbortHandler)
|
|
}),
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerOtherThanAllowGoesToTheVisitorAsServeWAFGaveIt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name, disposition, body string
|
|
status int
|
|
}{
|
|
{"a refusal", dispositionDeny, deniedBody, http.StatusForbidden},
|
|
{"a refusal, 429", dispositionDeny, deniedBody, http.StatusTooManyRequests},
|
|
{"servewaf's 400", "", "refused\n", http.StatusBadRequest},
|
|
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
|
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
|
{"no header", "", "who knows\n", http.StatusOK},
|
|
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
|
} {
|
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
if tc.disposition != "" {
|
|
w.Header().Set(dispositionHeader, tc.disposition)
|
|
}
|
|
|
|
w.Header().Set(fromServeWAFHeader, addedHeaderValue)
|
|
w.WriteHeader(tc.status)
|
|
_, _ = io.WriteString(w, tc.body)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
defaultDisposition: setting,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != tc.status || got.body != tc.body ||
|
|
got.header.Get(fromServeWAFHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want servewaf's answer", got)
|
|
}
|
|
|
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
|
t.Errorf("the visitor got %s", dispositionHeader)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != float64(tc.status) || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status %d and no default", line, tc.status)
|
|
}
|
|
|
|
if out.has("servewaf gave no answer") {
|
|
t.Error("rproxy warned that servewaf gave no answer")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "refused", http.StatusBadRequest)
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
|
t.Errorf("answered %+v, want servewaf's 400", got)
|
|
}
|
|
|
|
if line := out.requestLine(t); line["disposition"] != nil {
|
|
t.Errorf("line %v, want no disposition", line)
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, "<html>broken")
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if contentType, sent := got.header["Content-Type"]; sent {
|
|
t.Errorf("the visitor got the Content-Type %v", contentType)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const size = 1 << 20
|
|
|
|
standIn := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write(make([]byte, size))
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
|
t.Errorf("answered %d with %d bytes, want servewaf's 500 with %d",
|
|
got.status, len(got.body), size)
|
|
}
|
|
}
|
|
|
|
// startServeWAFWriting starts a stand-in for servewaf that reads each question and
|
|
// answers it with text, as it is, and returns its URL. It can send what Go's
|
|
// server would not.
|
|
func startServeWAFWriting(t *testing.T, text string) string {
|
|
t.Helper()
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
})
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(waitLimit))
|
|
_, _ = http.ReadRequest(bufio.NewReader(conn))
|
|
_, _ = io.WriteString(conn, text)
|
|
_ = conn.Close()
|
|
}
|
|
}()
|
|
|
|
return "http://" + listener.Addr().String()
|
|
}
|
|
|
|
// overOneMiB answers a question with a body over the most rproxy reads, and with
|
|
// disposition unless it is "".
|
|
func overOneMiB(disposition string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
if disposition != "" {
|
|
w.Header().Set(dispositionHeader, disposition)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write(make([]byte, 1<<20+1))
|
|
}
|
|
}
|
|
|
|
// switchAndHold answers a 101 and holds the connection open until rproxy closes
|
|
// it, or until long after any test has stopped waiting.
|
|
func switchAndHold(w http.ResponseWriter, _ *http.Request) {
|
|
conn, _, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_ = conn.SetDeadline(time.Now().Add(2 * waitLimit))
|
|
_, _ = io.WriteString(conn, "HTTP/1.1 101 Switching Protocols\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
}
|
|
|
|
func TestAnAnswerThatCannotBePassedOnIsAnswered502(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, backend := range map[string]func(t *testing.T) string{
|
|
"a deny over 1 MiB": answeringWith(overOneMiB(dispositionDeny)),
|
|
"an allow over 1 MiB": answeringWith(overOneMiB(dispositionAllow)),
|
|
"no header over 1 MiB": answeringWith(overOneMiB("")),
|
|
"a status below 100": func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startServeWAFWriting(t, "HTTP/1.1 099 Odd\r\nContent-Length: 0\r\n\r\n")
|
|
},
|
|
"a 101 that holds its connection open": answeringWith(switchAndHold),
|
|
} {
|
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run(name+" with the default "+setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
defaultDisposition: setting,
|
|
})
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %+v, want 502", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != float64(http.StatusBadGateway) ||
|
|
line["disposition"] != nil || line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 502 and no disposition", line)
|
|
}
|
|
|
|
out.waitFor(t, "servewaf's answer is dropped")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, overOneMiB(dispositionDeny))
|
|
addr, out := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %+v, want 502", got)
|
|
}
|
|
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, backend := range noAnswers() {
|
|
t.Run("fails open on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionAllow ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want allow by default", line)
|
|
}
|
|
})
|
|
|
|
t.Run("fails closed on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
defaultDisposition: dispositionDeny,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusServiceUnavailable {
|
|
t.Errorf("answered %+v, want 503", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionDeny ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want deny by default", line)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatGoesAwayWhileServeWAFIsAskedGetsNoDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run("with the default "+disposition, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
defaultDisposition: disposition,
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
standIn.question(t)
|
|
|
|
_ = conn.Close()
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != 0.0 || line["disposition"] != nil ||
|
|
line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
|
}
|
|
|
|
if out.has("servewaf gave no answer") {
|
|
t.Error("rproxy warned that servewaf gave no answer")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "servewaf gave no answer")
|
|
}
|
|
|
|
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, "/_smallwebwaf/metrics")
|
|
if got.status != http.StatusNotFound {
|
|
t.Errorf("answered %+v, want 404", got)
|
|
}
|
|
}
|
|
|
|
func TestHealthCheckIsAnsweredWhileServeWAFIsDown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, servewaf.HealthPath)
|
|
if got.status != http.StatusOK || got.body != "ok\n" {
|
|
t.Errorf("answered %+v, want ok", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["path"] != servewaf.HealthPath || line["disposition"] != nil {
|
|
t.Errorf("line %v, want one with no disposition", line)
|
|
}
|
|
|
|
// With servewaf up too, it is not asked.
|
|
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
|
get(t, addr, servewaf.HealthPath)
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("servewaf was asked about the health check")
|
|
}
|
|
}
|
|
|
|
func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startServeWAFAnsweringReports(t, deny,
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
// Go's server reads the next request on a connection only once the handler has
|
|
// returned, so the second answer comes only if the handler does not wait for
|
|
// the first report, which the stand-in leaves unanswered.
|
|
conn := dial(t, addr)
|
|
reader := bufio.NewReader(conn)
|
|
|
|
for range 2 {
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status %d, want servewaf's 403", res.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startServeWAFAnsweringReports(t, allow,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
<-release
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
// The time limit is long enough for a loaded host to answer the question before
|
|
// it runs out.
|
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "3000",
|
|
})
|
|
|
|
t.Cleanup(func() { close(release) })
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportServeWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startServeWAFAnsweringReports(t, allow,
|
|
func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
})
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend, app.URL, nil)
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportAnswered101IsDroppedAtOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startServeWAFAnsweringReports(t, allow, switchAndHold)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
server.WaitForReports()
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestReportGivesWhatHappened(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const body = "the visitor's body"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
strings.NewReader(body))))
|
|
|
|
report := standIn.report(t)
|
|
|
|
if report.RequestID == "" || report.Status != http.StatusOK ||
|
|
report.RequestBytes != int64(len(body)) ||
|
|
report.ResponseBytes != int64(len(greeting)) ||
|
|
report.DurationTotal <= 0 || report.DurationUpstreamTotal <= 0 ||
|
|
report.DurationUpstreamTotal > report.DurationTotal {
|
|
t.Errorf("report %+v, of %d bytes up and %d back", report, len(body), len(greeting))
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["request_id"] != report.RequestID {
|
|
t.Errorf("line %v, report %+v, want the same request id", line, report)
|
|
}
|
|
}
|
|
|
|
func TestWaitForReportsWaitsForAReportBeingSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
reportGotThere := make(chan struct{})
|
|
release := make(chan struct{})
|
|
backend := httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(servewaf.ClientHeader) != "" {
|
|
allow(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
close(reportGotThere)
|
|
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
t.Cleanup(backend.Close)
|
|
|
|
app := startApp(t, greet)
|
|
addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{
|
|
timeoutSetting: "5000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
select {
|
|
case <-reportGotThere:
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
}
|
|
|
|
waited := make(chan struct{})
|
|
|
|
go func() {
|
|
server.WaitForReports()
|
|
close(waited)
|
|
}()
|
|
|
|
select {
|
|
case <-waited:
|
|
t.Error("it did not wait for the report")
|
|
case <-time.After(10 * pollInterval):
|
|
}
|
|
|
|
close(release)
|
|
|
|
select {
|
|
case <-waited:
|
|
case <-time.After(waitLimit):
|
|
t.Error("it still waits, the report having been answered")
|
|
}
|
|
}
|
|
|
|
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, echoAfterUpgrade)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "2000",
|
|
maxResponseTime: "2000",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
|
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
|
}
|
|
|
|
// Past both time limits, the connection still carries what is sent.
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
const message = "still here\n"
|
|
|
|
send(t, conn, message)
|
|
|
|
echoed, err := reader.ReadString('\n')
|
|
if err != nil || echoed != message {
|
|
t.Errorf("echo %q (%v), want %q", echoed, err, message)
|
|
}
|
|
|
|
_ = conn.Close()
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusSwitchingProtocols ||
|
|
report.RequestBytes != int64(len(message)) ||
|
|
report.ResponseBytes != int64(len(message)) {
|
|
t.Errorf("report %+v, of %d bytes each way", report, len(message))
|
|
}
|
|
|
|
if line := out.requestLine(t); line["status"] != float64(res.StatusCode) {
|
|
t.Errorf("line %v, want status 101", line)
|
|
}
|
|
}
|
|
|
|
func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const goodbye = "sent after the visitor stopped sending\n"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_, _ = io.Copy(io.Discard, buffered)
|
|
|
|
_, _ = buffered.WriteString(goodbye)
|
|
_ = buffered.Flush()
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
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(reader)
|
|
if err != nil || string(text) != goodbye {
|
|
t.Errorf("read %q (%v), want %q", text, err, goodbye)
|
|
}
|
|
}
|
|
|
|
// This test does not run in parallel. Go closes a connection nothing refers to at
|
|
// its next garbage collection, which other tests' allocations would soon start, and
|
|
// that would hide a connection left open.
|
|
//
|
|
//nolint:paralleltest // see above
|
|
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
|
closed := make(chan error, 1)
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
closed <- err
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
// Not the protocol the visitor asked for, so ReverseProxy refuses the switch.
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: another\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(waitLimit))
|
|
|
|
_, err = buffered.ReadByte()
|
|
closed <- err
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
if status := readStatus(t, conn); status != http.StatusBadGateway {
|
|
t.Errorf("status %d, want 502", status)
|
|
}
|
|
|
|
err := <-closed
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Errorf("the app's read gave %v, want its connection closed", err)
|
|
}
|
|
}
|
|
|
|
// echoAfterUpgrade is an app that switches protocols on request, and then sends
|
|
// back each line it receives.
|
|
func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
for {
|
|
line, err := buffered.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, _ = buffered.WriteString(line)
|
|
_ = buffered.Flush()
|
|
}
|
|
}
|
|
|
|
func TestEachRequestGetsAFreshID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const fromTraefik = "an-id-from-traefik"
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
ids := make([]string, 0, 2)
|
|
|
|
for range 2 {
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(requestIDHeader, fromTraefik)
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
|
|
}
|
|
|
|
if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
|
|
t.Errorf("ids %v, want two fresh ones", ids)
|
|
}
|
|
}
|
|
|
|
func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
path = "/search%2Fall"
|
|
query = "q=secret&token=%zz"
|
|
body = "the visitor's body"
|
|
)
|
|
|
|
standIn := startServeWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
|
|
req.Host = site
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
line := out.requestLine(t)
|
|
asked := standIn.question(t)
|
|
|
|
for name, want := range map[string]any{
|
|
"type": "request",
|
|
"request_id": asked.header.Get(requestIDHeader),
|
|
"client_ip": localhost,
|
|
"host": site,
|
|
"method": http.MethodPost,
|
|
"path": path,
|
|
"query": query,
|
|
"status": float64(http.StatusOK),
|
|
"request_bytes": float64(len(body)),
|
|
"response_bytes": float64(len(greeting)),
|
|
"disposition": dispositionAllow,
|
|
} {
|
|
if line[name] != want {
|
|
t.Errorf("%s is %v, want %v", name, line[name], want)
|
|
}
|
|
}
|
|
|
|
if line["default_disposition"] != nil {
|
|
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
|
|
}
|
|
|
|
for _, name := range []string{"duration_request", "duration_response"} {
|
|
duration, ok := line[name].(float64)
|
|
if !ok || duration <= 0 {
|
|
t.Errorf("%s is %v, want a time", name, line[name])
|
|
}
|
|
}
|
|
|
|
text, _ := line["time"].(string)
|
|
|
|
_, err := time.Parse(time.RFC3339, text)
|
|
if err != nil {
|
|
t.Errorf("time: %v", err)
|
|
}
|
|
}
|