check / check (push) Failing after 5m50s
Four rproxy tests set a time limit of a second or less, and each needed a step done before that limit ran out. On a loaded host the limit ran out first, rproxy answered as SPEC.md says it should, and the test failed. Each of these limits is now 3 seconds: - TestAnAppTooSlowToFinishItsAnswerIsCutOff, which now also checks that the visitor got the app's 200 and the start of its answer; - TestAVisitorTooSlowToTakeTheAnswerIsCutOff; - TestAVisitorThatStopsSendingGetsAnAnswerRProxyGivesByTheDeadline; - TestAReportServeWAFLeavesUnansweredIsDroppedAtTheTimeLimit. Judgement call: tests whose limits already give 2 seconds or more are left as they are. 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/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)
|
|
// 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()
|
|
|
|
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)
|
|
}
|
|
}
|