check / check (push) Waiting to run
Every setting of waf and rproxy is read through viper, in place of the os.LookupEnv function Main passed down. NewViper turns AutomaticEnv and AllowEmptyEnv on, so a setting that is set but empty reads as set. A setting may also be given as a file, the _FILE form, which rproxy's settings now have too; rproxy still writes nothing to disk. The settings are renamed: waf's take the prefix SWW_WAF_, rproxy's take SWW_RPROXY_, and the shared SWW_COMMAND keeps SWW_. The Core Rule Set's settings take SWW_WAF_CORE_RULE_SET_ rather than SWW_WAF_WAF_. Model: deepseek-v4-pro (first version); opus-5-5 (rework)
1775 lines
46 KiB
Go
1775 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/config/configtest"
|
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
|
"sneak.berlin/go/smallwebwaf/internal/rproxy"
|
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
|
)
|
|
|
|
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 = "SWW_RPROXY_TIMEOUT_MS"
|
|
defaultDisposition = "SWW_RPROXY_DEFAULT_DISPOSITION"
|
|
trustedProxies = "SWW_RPROXY_TRUSTED_PROXIES"
|
|
maxRequestSize = "SWW_RPROXY_MAX_REQUEST_SIZE"
|
|
maxResponseSize = "SWW_RPROXY_MAX_RESPONSE_SIZE"
|
|
maxRequestTime = "SWW_RPROXY_MAX_REQUEST_TIME_MS"
|
|
maxResponseTime = "SWW_RPROXY_MAX_RESPONSE_TIME_MS"
|
|
)
|
|
|
|
// The headers the tests set and look at.
|
|
const (
|
|
dispositionHeader = waf.DispositionHeader
|
|
forwardedForHeader = "X-Forwarded-For"
|
|
forwardedProtoHeader = "X-Forwarded-Proto"
|
|
requestIDHeader = "X-Request-ID"
|
|
asnHeader = "X-Client-Asn"
|
|
countryHeader = "X-Client-Country"
|
|
contentLengthHeader = "Content-Length"
|
|
// fromWAFHeader is a header the stand-in for waf adds to a deny.
|
|
fromWAFHeader = "X-From-Waf"
|
|
// 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 waf answers.
|
|
const (
|
|
dispositionAllow = waf.DispositionAllow
|
|
dispositionDeny = waf.DispositionDeny
|
|
dispositionClose = waf.DispositionClose
|
|
// deniedBody is the body of a deny.
|
|
deniedBody = "refused by waf"
|
|
)
|
|
|
|
// 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 waf received as a question.
|
|
type question struct {
|
|
method, target, host string
|
|
header http.Header
|
|
body []byte
|
|
contentLength int64
|
|
transferEncoding []string
|
|
}
|
|
|
|
// WAF is a stand-in for waf that answers each question as
|
|
// answer does, and keeps the questions and the reports it gets.
|
|
type WAF struct {
|
|
*httptest.Server
|
|
|
|
questions chan question
|
|
reports chan waf.Report
|
|
}
|
|
|
|
// startWAF starts a stand-in for waf.
|
|
func startWAF(t *testing.T, answer http.HandlerFunc) *WAF {
|
|
t.Helper()
|
|
|
|
s := &WAF{
|
|
questions: make(chan question, 10),
|
|
reports: make(chan waf.Report, 10),
|
|
}
|
|
s.Server = httptest.NewServer(http.HandlerFunc(
|
|
func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get(waf.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 waf does.
|
|
func (s *WAF) takeReport(
|
|
t *testing.T, w http.ResponseWriter, r *http.Request,
|
|
) {
|
|
t.Helper()
|
|
|
|
var report waf.Report
|
|
|
|
err := json.NewDecoder(r.Body).Decode(&report)
|
|
if err != nil || r.Method != http.MethodPost ||
|
|
r.URL.Path != waf.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 *WAF) 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 *WAF) report(t *testing.T) waf.Report {
|
|
t.Helper()
|
|
|
|
select {
|
|
case report := <-s.reports:
|
|
return report
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
|
|
return waf.Report{}
|
|
}
|
|
}
|
|
|
|
// question waits for a question.
|
|
func (s *WAF) question(t *testing.T) question {
|
|
t.Helper()
|
|
|
|
select {
|
|
case asked := <-s.questions:
|
|
return asked
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no question")
|
|
|
|
return question{}
|
|
}
|
|
}
|
|
|
|
// startWAFAnsweringReports starts a stand-in for waf that
|
|
// answers each question as answer does and each report as report does, and
|
|
// returns its URL.
|
|
func startWAFAnsweringReports(
|
|
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(waf.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(fromWAFHeader, 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 waf 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{
|
|
"SWW_RPROXY_WAF_BACKEND": backend, "SWW_RPROXY_UPSTREAM_URL": appURL,
|
|
}
|
|
maps.Copy(settings, env)
|
|
|
|
cfg, err := config.RProxyFromEnvironment(configtest.Viper(settings))
|
|
if err != nil {
|
|
t.Fatalf("settings %v: %v", settings, err)
|
|
}
|
|
|
|
server := rproxy.New(rproxy.Params{
|
|
Config: cfg,
|
|
RequestLog: requestLog,
|
|
ProcessLog: requestlog.NewProcessLogger(processLog, "test", slog.LevelInfo),
|
|
})
|
|
|
|
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
|
|
server.WaitForReports()
|
|
})
|
|
|
|
return listener.Addr().String(), server
|
|
}
|
|
|
|
// answered is the visitor's answer.
|
|
type answered struct {
|
|
status int
|
|
header http.Header
|
|
body string
|
|
}
|
|
|
|
// visit sends req as a visitor, on a connection of its own, and returns the
|
|
// answer.
|
|
func visit(t *testing.T, req *http.Request) answered {
|
|
t.Helper()
|
|
|
|
transport := &http.Transport{DisableKeepAlives: true, DisableCompression: true}
|
|
defer transport.CloseIdleConnections()
|
|
|
|
res, err := transport.RoundTrip(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
return answered{res.StatusCode, res.Header, string(body)}
|
|
}
|
|
|
|
// newRequest returns a request for target at addr.
|
|
func newRequest(
|
|
t *testing.T, method, addr, target string, body io.Reader,
|
|
) *http.Request {
|
|
t.Helper()
|
|
|
|
req, err := http.NewRequestWithContext(t.Context(), method, "http://"+addr+target,
|
|
body)
|
|
if err != nil {
|
|
t.Fatalf("new request: %v", err)
|
|
}
|
|
|
|
return req
|
|
}
|
|
|
|
// get sends a GET for target at addr, and returns the answer.
|
|
func get(t *testing.T, addr, target string) answered {
|
|
t.Helper()
|
|
|
|
return visit(t, newRequest(t, http.MethodGet, addr, target, http.NoBody))
|
|
}
|
|
|
|
// wantGreeting checks that the answer is the app's.
|
|
func wantGreeting(t *testing.T, got answered) {
|
|
t.Helper()
|
|
|
|
if got.status != http.StatusOK || got.body != greeting {
|
|
t.Errorf("answered %d %q, want the app's greeting", got.status, got.body)
|
|
}
|
|
}
|
|
|
|
// dial opens a connection to addr, closed as the test ends.
|
|
func dial(t *testing.T, addr string) net.Conn {
|
|
t.Helper()
|
|
|
|
conn, err := (&net.Dialer{}).DialContext(t.Context(), "tcp", addr)
|
|
if err != nil {
|
|
t.Fatalf("dial: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
_ = conn.Close()
|
|
})
|
|
|
|
err = conn.SetDeadline(time.Now().Add(waitLimit))
|
|
if err != nil {
|
|
t.Fatalf("set the deadline: %v", err)
|
|
}
|
|
|
|
return conn
|
|
}
|
|
|
|
// send writes text to conn.
|
|
func send(t *testing.T, conn net.Conn, text string) {
|
|
t.Helper()
|
|
|
|
_, err := io.WriteString(conn, text)
|
|
if err != nil {
|
|
t.Fatalf("send: %v", err)
|
|
}
|
|
}
|
|
|
|
// readStatus reads the answer's status from conn.
|
|
func readStatus(t *testing.T, conn net.Conn) int {
|
|
t.Helper()
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
return res.StatusCode
|
|
}
|
|
|
|
func TestPassesAnAllowedRequestToTheAppAndItsAnswerBack(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set(asnHeader, "AS64496")
|
|
w.Header().Set(countryHeader, "DE")
|
|
allow(w, r)
|
|
})
|
|
|
|
seen := make(chan *http.Request, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Clone(t.Context())
|
|
|
|
w.Header().Set("X-App", addedHeaderValue)
|
|
w.WriteHeader(http.StatusTeapot)
|
|
_, _ = io.WriteString(w, greeting)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
const target = pathGoReencodes + "?b=2&a=1&bad=%zz;x"
|
|
|
|
req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
|
|
req.URL.Opaque = pathGoReencodes
|
|
req.Host = site
|
|
req.Header.Set(asnHeader, "AS1")
|
|
req.Header.Set(countryHeader, "XX")
|
|
req.Header.Set(requestIDHeader, "the-visitor's")
|
|
|
|
got := visit(t, req)
|
|
if got.status != http.StatusTeapot || got.body != greeting ||
|
|
got.header.Get("X-App") != addedHeaderValue {
|
|
t.Errorf("answered %+v, want the app's answer", got)
|
|
}
|
|
|
|
received := <-seen
|
|
asked := standIn.question(t)
|
|
|
|
if received.RequestURI != target || received.Host != site {
|
|
t.Errorf("the app saw %s for %s", received.RequestURI, received.Host)
|
|
}
|
|
|
|
for name, want := range map[string]string{
|
|
asnHeader: "AS64496",
|
|
countryHeader: "DE",
|
|
forwardedForHeader: localhost,
|
|
requestIDHeader: asked.header.Get(requestIDHeader),
|
|
} {
|
|
if received.Header.Get(name) != want {
|
|
t.Errorf("the app saw %s %q, want %q", name, received.Header.Get(name), want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
removed := []string{
|
|
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
|
|
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
|
|
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
|
|
"X-Forwarded-Scheme", "X-Forwarded-Ssl", "X-Forwarded-Protocol",
|
|
"X-Forwarded-Path", "X-Forwarded-Client-Cert",
|
|
"X_Forwarded_Ssl", "X_Forwarded_Proto", "X_Real_IP",
|
|
}
|
|
|
|
standIn := startWAF(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: "10.0.0.0/8",
|
|
})
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
req.Header.Set(forwardedForHeader, "198.51.100.1")
|
|
req.Header.Set("X-Forwarded-Host", "claimed.example")
|
|
req.Header.Set(forwardedProtoHeader, "https")
|
|
|
|
for _, name := range removed {
|
|
req.Header.Set(name, "claimed")
|
|
}
|
|
|
|
// A made-up name, sent in lower case.
|
|
req.Header["x-forwarded-made-up"] = []string{"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 append(removed, "X-Forwarded-Made-Up") {
|
|
if got := header.Values(name); len(got) != 0 {
|
|
t.Errorf("the app saw %s %q, want none", name, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
seen := make(chan http.Header, 1)
|
|
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
|
|
seen <- r.Header.Clone()
|
|
|
|
greet(w, r)
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
sent := map[string]string{
|
|
"Forwarded": "for=198.51.100.1",
|
|
"X-Forwarded-Host": "public.example",
|
|
forwardedProtoHeader: "https",
|
|
"X-Real-Ip": "198.51.100.1",
|
|
"X-Forwarded-Ssl": "on",
|
|
"X_Forwarded_Ssl": "on",
|
|
}
|
|
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Host = site
|
|
|
|
for name, value := range sent {
|
|
req.Header.Set(name, value)
|
|
}
|
|
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
header := <-seen
|
|
|
|
for name, want := range sent {
|
|
if got := header.Values(name); !slices.Equal(got, []string{want}) {
|
|
t.Errorf("the app saw %s %q, want %q", name, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGetsNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
_, _ = io.WriteString(w, "<html>")
|
|
})
|
|
_, _, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
|
w := httptest.NewRecorder()
|
|
|
|
server.Handler.ServeHTTP(w, req)
|
|
|
|
// Go's server guesses a type when the first part of the body goes before the
|
|
// headers are flushed, which is a matter of timing here. So this looks for what
|
|
// stops it: the key with no value.
|
|
values, there := w.Header()["Content-Type"]
|
|
if !there || len(values) != 0 {
|
|
t.Errorf("the answer's Content-Type is %q, there: %v, want the key with no value",
|
|
values, there)
|
|
}
|
|
}
|
|
|
|
func TestRemovesTheVisitorsLookupHeadersWhenWAFGivesNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
backend func(t *testing.T) string
|
|
}{
|
|
{"waf allows", func(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
return startWAF(t, allow).URL
|
|
}},
|
|
{"waf 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 TestDenyGivesTheVisitorWAFsAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, deny)
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusForbidden || got.body != deniedBody ||
|
|
got.header.Get(fromWAFHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want waf's answer", got)
|
|
}
|
|
|
|
if len(got.header.Values(dispositionHeader)) != 0 {
|
|
t.Errorf("the visitor got %s", dispositionHeader)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusForbidden ||
|
|
report.ResponseBytes != int64(len(deniedBody)) ||
|
|
report.DurationUpstreamTotalMS != 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 waf's deny", line)
|
|
}
|
|
}
|
|
|
|
func TestADeniedHeadKeepsWAFsContentLength(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
got := visit(t, newRequest(t, http.MethodHead, addr, "/", http.NoBody))
|
|
if got.status != http.StatusForbidden ||
|
|
got.header.Get(contentLengthHeader) != strconv.Itoa(len(deniedBody)) {
|
|
t.Errorf("answered %+v, want waf'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 " + waf.HealthPath + host, "ok\n", http.StatusOK},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
log := heldLog{release: make(chan struct{})}
|
|
standIn := startWAF(t, deny)
|
|
app := startApp(t, greet)
|
|
addr, _ := startServerLogging(t, standIn.URL, app.URL, nil, log, io.Discard)
|
|
|
|
t.Cleanup(func() { close(log.release) })
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, tc.request)
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
defer func() {
|
|
_ = res.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(res.Body)
|
|
if err != nil || res.StatusCode != tc.status || string(body) != tc.body {
|
|
t.Errorf("answered %d %q (%v), want %d %q", res.StatusCode, body, err,
|
|
tc.status, tc.body)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxysOwnAnswerIsPlainText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET x:admin HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
res, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.Header.Get("Content-Type") != "text/plain; charset=utf-8" ||
|
|
res.Header.Get("X-Content-Type-Options") != "nosniff" {
|
|
t.Errorf("the answer's headers are %v", res.Header)
|
|
}
|
|
}
|
|
|
|
func TestCloseClosesTheConnectionWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set(dispositionHeader, dispositionClose)
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
|
|
text, err := io.ReadAll(conn)
|
|
if err != nil || len(text) != 0 {
|
|
t.Errorf("read %q (%v), want the connection closed without an answer",
|
|
text, err)
|
|
}
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != 0 || report.ResponseBytes != 0 {
|
|
t.Errorf("report %+v, of a visitor sent nothing", report)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionClose || line["status"] != 0.0 {
|
|
t.Errorf("line %v, want close and status 0", line)
|
|
}
|
|
}
|
|
|
|
func TestALineForARequestClosedWithoutAnAnswerHasNoNegativeTime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startWAFAnsweringReports(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_ms"].(float64)
|
|
if !ok || duration < 0 {
|
|
t.Errorf("line %v, want a duration_response_ms that is not negative", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// answeringWith returns what starts a stand-in for waf 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 startWAF(t, answer).URL
|
|
}
|
|
}
|
|
|
|
// noAnswers are the ways waf 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 TestAnAnswerOtherThanAllowGoesToTheVisitorAsWAFGaveIt(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},
|
|
{"waf's 400", "", "refused\n", http.StatusBadRequest},
|
|
{"a 500", "", "broken\n", http.StatusInternalServerError},
|
|
{"a 503 denied", dispositionDeny, "busy\n", http.StatusServiceUnavailable},
|
|
{"no header", "", "who knows\n", http.StatusOK},
|
|
{"an unknown disposition", "maybe", "who knows\n", http.StatusOK},
|
|
} {
|
|
for _, setting := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run(tc.name+" with the default "+setting, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
if tc.disposition != "" {
|
|
w.Header().Set(dispositionHeader, tc.disposition)
|
|
}
|
|
|
|
w.Header().Set(fromWAFHeader, addedHeaderValue)
|
|
w.WriteHeader(tc.status)
|
|
_, _ = io.WriteString(w, tc.body)
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
defaultDisposition: setting,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != tc.status || got.body != tc.body ||
|
|
got.header.Get(fromWAFHeader) != addedHeaderValue {
|
|
t.Errorf("answered %+v, want waf'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("waf gave no answer") {
|
|
t.Error("rproxy warned that waf gave no answer")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerWithoutAValidDispositionGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "refused", http.StatusBadRequest)
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadRequest {
|
|
t.Errorf("answered %+v, want waf's 400", got)
|
|
}
|
|
|
|
if line := out.requestLine(t); line["disposition"] != nil {
|
|
t.Errorf("line %v, want no disposition", line)
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAnAnswerWithoutAContentTypeGoesToTheVisitorWithNone(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header()["Content-Type"] = nil
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = io.WriteString(w, "<html>broken")
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, nil)
|
|
|
|
got := get(t, addr, "/")
|
|
if contentType, sent := got.header["Content-Type"]; sent {
|
|
t.Errorf("the visitor got the Content-Type %v", contentType)
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerOf1MiBGoesToTheVisitor(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const size = 1 << 20
|
|
|
|
standIn := startWAF(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write(make([]byte, size))
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusInternalServerError || len(got.body) != size {
|
|
t.Errorf("answered %d with %d bytes, want waf's 500 with %d",
|
|
got.status, len(got.body), size)
|
|
}
|
|
}
|
|
|
|
// startWAFWriting starts a stand-in for waf 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 startWAFWriting(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 startWAFWriting(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{
|
|
timeoutSetting: "60000",
|
|
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, "waf's answer is dropped")
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnAnswerThatCannotBePassedOnGetsNoReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, overOneMiB(dispositionDeny))
|
|
addr, out := startRProxy(t, standIn.URL, nowhere, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
if got := get(t, addr, "/"); got.status != http.StatusBadGateway {
|
|
t.Errorf("answered %+v, want 502", got)
|
|
}
|
|
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestWithoutAnAnswerTheDefaultDispositionDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for name, backend := range noAnswers() {
|
|
t.Run("fails open on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionAllow ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want allow by default", line)
|
|
}
|
|
})
|
|
|
|
t.Run("fails closed on "+name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, backend(t), app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
defaultDisposition: dispositionDeny,
|
|
})
|
|
|
|
got := get(t, addr, "/")
|
|
if got.status != http.StatusServiceUnavailable {
|
|
t.Errorf("answered %+v, want 503", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["disposition"] != dispositionDeny ||
|
|
line["default_disposition"] != true {
|
|
t.Errorf("line %v, want deny by default", line)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAVisitorThatGoesAwayWhileWAFIsAskedGetsNoDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, disposition := range []string{dispositionAllow, dispositionDeny} {
|
|
t.Run("with the default "+disposition, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
defaultDisposition: disposition,
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
|
|
standIn.question(t)
|
|
|
|
_ = conn.Close()
|
|
|
|
line := out.requestLine(t)
|
|
if line["status"] != 0.0 || line["disposition"] != nil ||
|
|
line["default_disposition"] != nil {
|
|
t.Errorf("line %v, want status 0 and no disposition", line)
|
|
}
|
|
|
|
if out.has("waf gave no answer") {
|
|
t.Error("rproxy warned that waf gave no answer")
|
|
}
|
|
|
|
standIn.wantNoReport(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRProxyWarnsWhenTheDefaultDecides(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "waf gave no answer")
|
|
}
|
|
|
|
func TestSendsNoReportWithoutAnAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, func(_ http.ResponseWriter, r *http.Request) {
|
|
<-r.Context().Done()
|
|
})
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
timeoutSetting: "200",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestAPathUnderOwnPrefixNeverReachesTheApp(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
app := startApp(t, func(http.ResponseWriter, *http.Request) {
|
|
t.Error("the app was asked")
|
|
})
|
|
addr, _ := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, "/_sww/metrics")
|
|
if got.status != http.StatusNotFound {
|
|
t.Errorf("answered %+v, want 404", got)
|
|
}
|
|
}
|
|
|
|
func TestHealthCheckIsAnsweredWhileWAFIsDown(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, nowhere, app.URL, nil)
|
|
|
|
got := get(t, addr, waf.HealthPath)
|
|
if got.status != http.StatusOK || got.body != "ok\n" {
|
|
t.Errorf("answered %+v, want ok", got)
|
|
}
|
|
|
|
line := out.requestLine(t)
|
|
if line["path"] != waf.HealthPath || line["disposition"] != nil {
|
|
t.Errorf("line %v, want one with no disposition", line)
|
|
}
|
|
|
|
// With waf up too, it is not asked.
|
|
addr, _ = startRProxy(t, standIn.URL, app.URL, nil)
|
|
get(t, addr, waf.HealthPath)
|
|
|
|
if len(standIn.questions) != 0 {
|
|
t.Error("waf was asked about the health check")
|
|
}
|
|
}
|
|
|
|
func TestTheVisitorDoesNotWaitForTheReport(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startWAFAnsweringReports(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 waf's 403", res.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAReportWAFLeavesUnansweredIsDroppedAtTheTimeLimit(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
release := make(chan struct{})
|
|
backend := startWAFAnsweringReports(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 TestAReportWAFRefusesIsDroppedWithAWarning(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
backend := startWAFAnsweringReports(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 := startWAFAnsweringReports(t, allow, switchAndHold)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, backend, app.URL, map[string]string{
|
|
timeoutSetting: "60000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.waitFor(t, "a report is dropped")
|
|
}
|
|
|
|
func TestAReportDueAfterWaitForReportsIsDropped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out, server := startServer(t, standIn.URL, app.URL, nil)
|
|
|
|
server.WaitForReports()
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
out.requestLine(t)
|
|
standIn.wantNoReport(t)
|
|
}
|
|
|
|
func TestReportGivesWhatHappened(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const body = "the visitor's body"
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
|
|
strings.NewReader(body))))
|
|
|
|
report := standIn.report(t)
|
|
|
|
if report.RequestID == "" || report.Status != http.StatusOK ||
|
|
report.RequestBytes != int64(len(body)) ||
|
|
report.ResponseBytes != int64(len(greeting)) ||
|
|
report.DurationTotalMS <= 0 || report.DurationUpstreamTotalMS <= 0 ||
|
|
report.DurationUpstreamTotalMS > report.DurationTotalMS {
|
|
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(waf.ClientHeader) != "" {
|
|
allow(w, r)
|
|
|
|
return
|
|
}
|
|
|
|
close(reportGotThere)
|
|
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}))
|
|
t.Cleanup(backend.Close)
|
|
|
|
app := startApp(t, greet)
|
|
addr, _, server := startServer(t, backend.URL, app.URL, map[string]string{
|
|
timeoutSetting: "5000",
|
|
})
|
|
|
|
wantGreeting(t, get(t, addr, "/"))
|
|
|
|
select {
|
|
case <-reportGotThere:
|
|
case <-time.After(waitLimit):
|
|
t.Fatal("no report")
|
|
}
|
|
|
|
waited := make(chan struct{})
|
|
|
|
go func() {
|
|
server.WaitForReports()
|
|
close(waited)
|
|
}()
|
|
|
|
select {
|
|
case <-waited:
|
|
t.Error("it did not wait for the report")
|
|
case <-time.After(10 * pollInterval):
|
|
}
|
|
|
|
close(release)
|
|
|
|
select {
|
|
case <-waited:
|
|
case <-time.After(waitLimit):
|
|
t.Error("it still waits, the report having been answered")
|
|
}
|
|
}
|
|
|
|
func TestReportOfAWebSocketCountsWhatItCarried(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, echoAfterUpgrade)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
maxRequestTime: "2000",
|
|
maxResponseTime: "2000",
|
|
})
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
if res.StatusCode != http.StatusSwitchingProtocols {
|
|
t.Fatalf("status %d, want %d", res.StatusCode, http.StatusSwitchingProtocols)
|
|
}
|
|
|
|
// Past both time limits, the connection still carries what is sent.
|
|
time.Sleep(2100 * time.Millisecond)
|
|
|
|
const message = "still here\n"
|
|
|
|
send(t, conn, message)
|
|
|
|
echoed, err := reader.ReadString('\n')
|
|
if err != nil || echoed != message {
|
|
t.Errorf("echo %q (%v), want %q", echoed, err, message)
|
|
}
|
|
|
|
_ = conn.Close()
|
|
|
|
report := standIn.report(t)
|
|
if report.Status != http.StatusSwitchingProtocols ||
|
|
report.RequestBytes != int64(len(message)) ||
|
|
report.ResponseBytes != int64(len(message)) {
|
|
t.Errorf("report %+v, of %d bytes each way", report, len(message))
|
|
}
|
|
|
|
if line := out.requestLine(t); line["status"] != float64(res.StatusCode) {
|
|
t.Errorf("line %v, want status 101", line)
|
|
}
|
|
}
|
|
|
|
func TestAWebSocketPassesOnThatTheVisitorStoppedSending(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const goodbye = "sent after the visitor stopped sending\n"
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_, _ = io.Copy(io.Discard, buffered)
|
|
|
|
_, _ = buffered.WriteString(goodbye)
|
|
_ = buffered.Flush()
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
res, err := http.ReadResponse(reader, nil)
|
|
if err != nil {
|
|
t.Fatalf("read the answer to the upgrade: %v", err)
|
|
}
|
|
|
|
_ = res.Body.Close()
|
|
|
|
tcpConn, ok := conn.(*net.TCPConn)
|
|
if !ok {
|
|
t.Fatalf("the connection is a %T", conn)
|
|
}
|
|
|
|
err = tcpConn.CloseWrite()
|
|
if err != nil {
|
|
t.Fatalf("stop sending: %v", err)
|
|
}
|
|
|
|
text, err := io.ReadAll(reader)
|
|
if err != nil || string(text) != goodbye {
|
|
t.Errorf("read %q (%v), want %q", text, err, goodbye)
|
|
}
|
|
}
|
|
|
|
// This test does not run in parallel. Go closes a connection nothing refers to at
|
|
// its next garbage collection, which other tests' allocations would soon start, and
|
|
// that would hide a connection left open.
|
|
//
|
|
//nolint:paralleltest // see above
|
|
func TestASwitchNotPassedOnClosesTheAppsConnection(t *testing.T) {
|
|
closed := make(chan error, 1)
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
closed <- err
|
|
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
// Not the protocol the visitor asked for, so ReverseProxy refuses the switch.
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: another\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(waitLimit))
|
|
|
|
_, err = buffered.ReadByte()
|
|
closed <- err
|
|
})
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
conn := dial(t, addr)
|
|
send(t, conn, "GET /socket HTTP/1.1\r\nHost: app\r\n"+
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
|
|
if status := readStatus(t, conn); status != http.StatusBadGateway {
|
|
t.Errorf("status %d, want 502", status)
|
|
}
|
|
|
|
err := <-closed
|
|
if !errors.Is(err, io.EOF) {
|
|
t.Errorf("the app's read gave %v, want its connection closed", err)
|
|
}
|
|
}
|
|
|
|
// echoAfterUpgrade is an app that switches protocols on request, and then sends
|
|
// back each line it receives.
|
|
func echoAfterUpgrade(w http.ResponseWriter, _ *http.Request) {
|
|
conn, buffered, err := http.NewResponseController(w).Hijack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
_, _ = buffered.WriteString("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
"Connection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
|
_ = buffered.Flush()
|
|
|
|
for {
|
|
line, err := buffered.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
_, _ = buffered.WriteString(line)
|
|
_ = buffered.Flush()
|
|
}
|
|
}
|
|
|
|
func TestEachRequestGetsAFreshID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const fromTraefik = "an-id-from-traefik"
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, _ := startRProxy(t, standIn.URL, app.URL, map[string]string{
|
|
trustedProxies: localhost + "/32",
|
|
})
|
|
|
|
ids := make([]string, 0, 2)
|
|
|
|
for range 2 {
|
|
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
|
|
req.Header.Set(requestIDHeader, fromTraefik)
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
ids = append(ids, standIn.question(t).header.Get(requestIDHeader))
|
|
}
|
|
|
|
if slices.Contains(ids, fromTraefik) || ids[0] == "" || ids[0] == ids[1] {
|
|
t.Errorf("ids %v, want two fresh ones", ids)
|
|
}
|
|
}
|
|
|
|
func TestLogLineGivesTheRequestAsSent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
path = "/search%2Fall"
|
|
query = "q=secret&token=%zz"
|
|
body = "the visitor's body"
|
|
)
|
|
|
|
standIn := startWAF(t, allow)
|
|
app := startApp(t, greet)
|
|
addr, out := startRProxy(t, standIn.URL, app.URL, nil)
|
|
|
|
req := newRequest(t, http.MethodPost, addr, path+"?"+query, strings.NewReader(body))
|
|
req.Host = site
|
|
wantGreeting(t, visit(t, req))
|
|
|
|
line := out.requestLine(t)
|
|
asked := standIn.question(t)
|
|
|
|
for name, want := range map[string]any{
|
|
"type": "request",
|
|
"request_id": asked.header.Get(requestIDHeader),
|
|
"client_ip": localhost,
|
|
"host": site,
|
|
"method": http.MethodPost,
|
|
"path": path,
|
|
"query": query,
|
|
"status": float64(http.StatusOK),
|
|
"request_bytes": float64(len(body)),
|
|
"response_bytes": float64(len(greeting)),
|
|
"disposition": dispositionAllow,
|
|
} {
|
|
if line[name] != want {
|
|
t.Errorf("%s is %v, want %v", name, line[name], want)
|
|
}
|
|
}
|
|
|
|
if line["default_disposition"] != nil {
|
|
t.Errorf("default_disposition is %v, want it left out", line["default_disposition"])
|
|
}
|
|
|
|
for _, name := range []string{"duration_request_ms", "duration_response_ms"} {
|
|
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)
|
|
}
|
|
}
|