Files
smallwebwaf/internal/rproxy/rproxy_test.go
T
clawbot 17c5dd2438
check / check (push) Successful in 7m25s
rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over
HTTP/1.1, and passes what servewaf allows to the app within its limits. Any
other answer goes to the visitor as servewaf gave it. SWRP_DEFAULT_DISPOSITION
decides only when servewaf cannot be connected to, does not answer at all, or
does not answer within SWRP_TIMEOUT_MS. A method SPEC.md does not list gets 405.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 and G705; gosec at
internal/smallwebwaf/rproxy_test.go:431; tagliatelle at
internal/requestlog/requestlog.go:203; paralleltest on one test.
Judgement call: an answer over 1 MiB, or with a status below 200, gets 502.
Judgement call: every target not starting with one / gets 400.

Model: opus-5-5
2026-11-05 06:32:08 +01:00

1759 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/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
)
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 = proxy.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 = proxy.DispositionAllow
dispositionDeny = proxy.DispositionDeny
dispositionClose = proxy.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 proxy.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 proxy.Report, 10),
}
s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.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 proxy.Report
err := json.NewDecoder(r.Body).Decode(&report)
if err != nil || r.Method != http.MethodPost || r.URL.Path != proxy.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) proxy.Report {
t.Helper()
select {
case report := <-s.reports:
return report
case <-time.After(waitLimit):
t.Fatal("no report")
return proxy.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(proxy.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()
servewaf := 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, servewaf.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 := servewaf.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",
}
servewaf := 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, servewaf.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()
servewaf := 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, servewaf.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()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header()["Content-Type"] = nil
_, _ = io.WriteString(w, "<html>")
})
_, _, server := startServer(t, servewaf.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()
servewaf := startServeWAF(t, deny)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, out := startRProxy(t, servewaf.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 := servewaf.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()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.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 " + proxy.HealthPath + host, "ok\n", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
log := heldLog{release: make(chan struct{})}
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
addr, _ := startServerLogging(t, servewaf.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()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.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()
servewaf := 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, servewaf.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 := servewaf.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()
servewaf := 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, servewaf.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()
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
})
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.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)
}
servewaf.wantNoReport(t)
}
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
t.Parallel()
servewaf := 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, servewaf.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
servewaf := startServeWAF(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write(make([]byte, size))
})
addr, _ := startRProxy(t, servewaf.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()
servewaf := startServeWAF(t, overOneMiB(dispositionDeny))
addr, out := startRProxy(t, servewaf.URL, nowhere, nil)
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
t.Errorf("answered %+v, want 502", got)
}
out.requestLine(t)
servewaf.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()
servewaf := 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, servewaf.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")
servewaf.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")
}
servewaf.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()
servewaf := startServeWAF(t, func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
timeoutSetting: "200",
})
wantGreeting(t, get(t, addr, "/"))
out.requestLine(t)
servewaf.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()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, nowhere, app.URL, nil)
got := get(t, addr, proxy.HealthPath)
if got.status != http.StatusOK || got.body != "ok\n" {
t.Errorf("answered %+v, want ok", got)
}
line := out.requestLine(t)
if line["path"] != proxy.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, servewaf.URL, app.URL, nil)
get(t, addr, proxy.HealthPath)
if len(servewaf.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)
addr, out := startRProxy(t, backend, app.URL, map[string]string{
timeoutSetting: "1000",
})
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()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out, server := startServer(t, servewaf.URL, app.URL, nil)
server.WaitForReports()
wantGreeting(t, get(t, addr, "/"))
out.requestLine(t)
servewaf.wantNoReport(t)
}
func TestReportGivesWhatHappened(t *testing.T) {
t.Parallel()
const body = "the visitor's body"
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader(body))))
report := servewaf.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(proxy.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()
servewaf := startServeWAF(t, allow)
app := startApp(t, echoAfterUpgrade)
addr, out := startRProxy(t, servewaf.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 := servewaf.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"
servewaf := 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, servewaf.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)
servewaf := 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, servewaf.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"
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.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, servewaf.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"
)
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.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 := servewaf.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)
}
}