rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 14m17s

`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf, and
passes what servewaf allows to the app within its own size and time limits.
It reports each request servewaf answered. SWRP_DEFAULT_DISPOSITION decides
when servewaf gives no answer.

Duplication: internal/rproxy copies passing-on code from internal/proxy until
#172 removes the originals.
Rule suppressed: gosec G704 on servewaf's URL, a setting.
Rule suppressed: gosec G705 on the answer written to the visitor.
Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not.

Model: opus-5-5
This commit is contained in:
2026-11-05 00:08:53 +00:00
parent 2eeea136e8
commit fbb5158377
23 changed files with 4266 additions and 50 deletions
+6 -4
View File
@@ -1,7 +1,9 @@
// Package config reads smallwebwaf's settings. Every setting is an
// environment variable whose name starts with SWWAF_, or a file such a
// variable names, every setting has a default, and this package is the
// one place they are read.
// Package config reads smallwebwaf's settings, and is the one place they are
// read. Every setting is an environment variable. The settings of
// `smallwebwaf` and `smallwebwaf servewaf` start with SWWAF_, every one of them
// has a default, and each can be given as a file instead. Those of `smallwebwaf
// rproxy` start with SWRP_. Each of them but SWRP_WAF_BACKEND has a default, and
// none can be given as a file, since rproxy never touches disk.
package config
import (
+165
View File
@@ -0,0 +1,165 @@
package config
import (
"errors"
"fmt"
"log/slog"
"math"
"net/netip"
"net/url"
"strconv"
"strings"
"time"
)
// RProxy is the settings of `smallwebwaf rproxy`. A size or time limit of zero
// is off.
type RProxy struct {
// ListenAddr is where rproxy listens (SWRP_LISTEN_ADDR).
ListenAddr string
// UpstreamURL is the app (SWRP_UPSTREAM_URL).
UpstreamURL *url.URL
// TrustedProxies are the netblocks whose X-Forwarded-For is believed
// (SWRP_TRUSTED_PROXIES).
TrustedProxies []netip.Prefix
// WAFBackend is servewaf's URL (SWRP_WAF_BACKEND).
WAFBackend *url.URL
// Timeout is how long rproxy waits for servewaf's answer to a question,
// and to a report (SWRP_TIMEOUT_MS). It cannot be off.
Timeout time.Duration
// DefaultDisposition is what is done with a request servewaf gives no
// answer about, allow or deny (SWRP_DEFAULT_DISPOSITION).
DefaultDisposition string
// MaxRequestSize is the largest request body (SWRP_MAX_REQUEST_SIZE), and
// MaxResponseSize the largest response body (SWRP_MAX_RESPONSE_SIZE).
MaxRequestSize int64
MaxResponseSize int64
// MaxRequestTime bounds sending the whole request: the visitor's request
// line and headers, and then, from their end, its body on its way to the
// app (SWRP_MAX_REQUEST_TIME_MS). MaxResponseTime bounds the answer, from
// the end of the request to its last byte reaching the visitor
// (SWRP_MAX_RESPONSE_TIME_MS).
MaxRequestTime time.Duration
MaxResponseTime time.Duration
// settings are the values read, as given or by default, for the log line
// at start.
settings []slog.Attr
}
var (
errNeedsWAFBackend = errors.New(
"is required: it is servewaf's URL, such as http://servewaf:8080")
errNotDisposition = errors.New("is not allow or deny")
errNotMilliseconds = errors.New(
"is not a whole number of milliseconds above zero, such as 2000")
errNotMillisecondsOrOff = errors.New(
"is not a whole number of milliseconds above zero, such as 60000, or off")
)
// RProxyFromEnvironment reads rproxy's settings with lookupEnv, normally
// os.LookupEnv. A setting that is not set takes its default, and
// SWRP_WAF_BACKEND, which has none, must be set. A setting that is set but
// invalid is an error that names it. Since rproxy never touches disk, its
// settings have no _FILE form.
func RProxyFromEnvironment(lookupEnv func(string) (string, bool)) (*RProxy, error) {
env := &environment{lookupEnv: func(name string) (string, bool) {
if strings.HasSuffix(name, "_FILE") {
return "", false
}
return lookupEnv(name)
}}
cfg := &RProxy{
ListenAddr: env.address("SWRP_LISTEN_ADDR", defaultListenAddr),
UpstreamURL: env.appURL("SWRP_UPSTREAM_URL", defaultUpstreamURL),
TrustedProxies: env.netblocks("SWRP_TRUSTED_PROXIES", privateRanges),
WAFBackend: env.wafBackend("SWRP_WAF_BACKEND"),
Timeout: env.milliseconds("SWRP_TIMEOUT_MS", "2000"),
DefaultDisposition: env.disposition("SWRP_DEFAULT_DISPOSITION", "allow"),
MaxRequestSize: env.size("SWRP_MAX_REQUEST_SIZE", "100M"),
MaxResponseSize: env.size("SWRP_MAX_RESPONSE_SIZE", "1G"),
MaxRequestTime: env.millisecondsOrOff("SWRP_MAX_REQUEST_TIME_MS", "60000"),
MaxResponseTime: env.millisecondsOrOff("SWRP_MAX_RESPONSE_TIME_MS", "1800000"),
}
if env.err != nil {
return nil, env.err
}
cfg.settings = env.settings
return cfg, nil
}
// LogValue makes an RProxy log as each setting's name with the value it was
// given, or its default.
func (c *RProxy) LogValue() slog.Value {
return slog.GroupValue(c.settings...)
}
// wafBackend reads the setting that is servewaf's URL, which must be set.
func (e *environment) wafBackend(name string) *url.URL {
value := e.value(name, "")
if value == "" {
e.check(name, errNeedsWAFBackend)
return nil
}
backend, err := parseUpstreamURL(value)
e.check(name, err)
return backend
}
// disposition reads the setting that is what is done with a request servewaf
// gives no answer about: allow or deny.
func (e *environment) disposition(name, defaultValue string) string {
value := e.value(name, defaultValue)
if value != "allow" && value != "deny" {
e.check(name, fmt.Errorf("%q %w", value, errNotDisposition))
}
return value
}
// milliseconds reads a setting that is a whole number of milliseconds above
// zero.
func (e *environment) milliseconds(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMilliseconds))
}
return duration
}
// millisecondsOrOff reads a setting that is a whole number of milliseconds
// above zero, or off, which is 0.
func (e *environment) millisecondsOrOff(name, defaultValue string) time.Duration {
value := e.value(name, defaultValue)
if value == off {
return 0
}
duration, valid := parseMilliseconds(value)
if !valid {
e.check(name, fmt.Errorf("%q %w", value, errNotMillisecondsOrOff))
}
return duration
}
// parseMilliseconds reads a whole number of milliseconds above zero, and
// reports whether it is one.
func parseMilliseconds(value string) (time.Duration, bool) {
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n <= 0 || n > math.MaxInt64/int64(time.Millisecond) {
return 0, false
}
return time.Duration(n) * time.Millisecond, true
}
+206
View File
@@ -0,0 +1,206 @@
package config_test
import (
"net/netip"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
)
// rproxy's settings, by name, and their values in these tests.
const (
wafBackend = "SWRP_WAF_BACKEND"
rproxyTimeout = "SWRP_TIMEOUT_MS"
rproxyDefault = "SWRP_DEFAULT_DISPOSITION"
rproxyListen = "SWRP_LISTEN_ADDR"
rproxyUpstream = "SWRP_UPSTREAM_URL"
rproxyTrusted = "SWRP_TRUSTED_PROXIES"
rproxyMaxRequest = "SWRP_MAX_REQUEST_SIZE"
rproxyMaxResponse = "SWRP_MAX_RESPONSE_SIZE"
rproxyRequestTime = "SWRP_MAX_REQUEST_TIME_MS"
rproxyAnswerTime = "SWRP_MAX_RESPONSE_TIME_MS"
backendURL = "http://servewaf:8080"
rproxyDefaultAddr = ":8080"
rproxyDefaultApp = "http://127.0.0.1:8081"
notASize = "a lot"
)
// rproxyConfig reads rproxy's settings from env.
func rproxyConfig(env map[string]string) (*config.RProxy, error) {
return config.RProxyFromEnvironment(func(name string) (string, bool) {
value, ok := env[name]
return value, ok
})
}
func TestRProxyDefaults(t *testing.T) {
t.Parallel()
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil {
t.Fatalf("settings: %v", err)
}
if cfg.ListenAddr != rproxyDefaultAddr ||
cfg.UpstreamURL.String() != rproxyDefaultApp ||
cfg.WAFBackend.String() != backendURL {
t.Errorf("addresses %q, %v, %v", cfg.ListenAddr, cfg.UpstreamURL, cfg.WAFBackend)
}
if cfg.Timeout != 2*time.Second || cfg.DefaultDisposition != "allow" {
t.Errorf("timeout %v, default disposition %q", cfg.Timeout, cfg.DefaultDisposition)
}
if cfg.MaxRequestSize != 100<<20 || cfg.MaxResponseSize != 1<<30 {
t.Errorf("sizes %d and %d", cfg.MaxRequestSize, cfg.MaxResponseSize)
}
if cfg.MaxRequestTime != time.Minute || cfg.MaxResponseTime != 30*time.Minute {
t.Errorf("times %v and %v", cfg.MaxRequestTime, cfg.MaxResponseTime)
}
want := []netip.Prefix{
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
}
if !slices.Equal(cfg.TrustedProxies, want) {
t.Errorf("trusted proxies %v, want %v", cfg.TrustedProxies, want)
}
}
func TestRProxySettingsAreRead(t *testing.T) {
t.Parallel()
cfg, err := rproxyConfig(map[string]string{
wafBackend: "https://waf.example:8443",
rproxyListen: ":9000",
rproxyUpstream: "http://app:3000",
rproxyTrusted: "192.0.2.0/24",
rproxyTimeout: "150",
rproxyDefault: "deny",
rproxyMaxRequest: "1K",
rproxyMaxResponse: off,
rproxyRequestTime: off,
rproxyAnswerTime: "250",
})
if err != nil {
t.Fatalf("settings: %v", err)
}
if cfg.ListenAddr != ":9000" || cfg.UpstreamURL.Host != "app:3000" ||
cfg.WAFBackend.Host != "waf.example:8443" ||
cfg.TrustedProxies[0] != netip.MustParsePrefix("192.0.2.0/24") {
t.Errorf("addresses %+v", cfg)
}
if cfg.Timeout != 150*time.Millisecond || cfg.DefaultDisposition != "deny" ||
cfg.MaxRequestSize != 1024 || cfg.MaxResponseSize != 0 ||
cfg.MaxRequestTime != 0 || cfg.MaxResponseTime != 250*time.Millisecond {
t.Errorf("limits %+v", cfg)
}
}
func TestRProxyInvalidSettingsAreRefused(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, value string
}{
{wafBackend, ""},
{wafBackend, "servewaf:8080"},
{wafBackend, "http://servewaf:8080/path"},
{rproxyTimeout, "0"},
{rproxyTimeout, "-1"},
{rproxyTimeout, off},
{rproxyTimeout, "2s"},
{rproxyTimeout, "1.25"},
{rproxyTimeout, "99999999999999999"},
{rproxyDefault, "maybe"},
{rproxyListen, "8080"},
{rproxyUpstream, "app"},
{rproxyTrusted, "not a netblock"},
{rproxyMaxRequest, notASize},
{rproxyMaxResponse, notASize},
{rproxyRequestTime, "0"},
{rproxyRequestTime, "1m"},
{rproxyAnswerTime, "0"},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Parallel()
_, err := rproxyConfig(map[string]string{wafBackend: backendURL, tc.name: tc.value})
if err == nil || !strings.HasPrefix(err.Error(), tc.name+": ") {
t.Errorf("error %v, want one naming %s", err, tc.name)
}
})
}
}
func TestRProxyNeedsItsBackend(t *testing.T) {
t.Parallel()
_, err := rproxyConfig(nil)
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want one saying %s is required", err, wafBackend)
}
}
func TestRProxyReadsNoFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "backend")
err := os.WriteFile(path, []byte("http://from-a-file:8080\n"), 0o600)
if err != nil {
t.Fatalf("write: %v", err)
}
_, err = rproxyConfig(map[string]string{wafBackend + "_FILE": path})
if err == nil || !strings.HasPrefix(err.Error(), wafBackend+": is required") {
t.Errorf("error %v, want %s still required", err, wafBackend)
}
cfg, err := rproxyConfig(map[string]string{
wafBackend: backendURL,
rproxyTimeout + "_FILE": "/nonexistent/timeout",
})
if err != nil {
t.Fatalf("settings: %v, with the file left unread", err)
}
if cfg.Timeout != 2*time.Second {
t.Errorf("timeout %v, want the default", cfg.Timeout)
}
}
func TestRProxySettingsAreLoggedByName(t *testing.T) {
t.Parallel()
cfg, err := rproxyConfig(map[string]string{wafBackend: backendURL})
if err != nil {
t.Fatalf("settings: %v", err)
}
group := cfg.LogValue().Group()
names := make([]string, 0, len(group))
for _, attr := range group {
names = append(names, attr.Key)
}
for _, name := range []string{
rproxyListen, rproxyUpstream, rproxyTrusted, wafBackend, rproxyTimeout, rproxyDefault,
rproxyMaxRequest, rproxyMaxResponse, rproxyRequestTime, rproxyAnswerTime,
} {
if !slices.Contains(names, name) {
t.Errorf("%s is not logged, only %v", name, names)
}
}
}
+43 -2
View File
@@ -1,6 +1,7 @@
// Package requestlog writes the lines smallwebwaf prints on stdout: one
// JSON object per request, or two from servewaf, marked "type":"request", and
// the process's own messages as JSON lines marked "type":"process".
// JSON object per request, two from servewaf and a short one from rproxy, marked
// "type":"request", and the process's own messages as JSON lines marked
// "type":"process".
package requestlog
import (
@@ -193,10 +194,50 @@ type Line struct {
DurationUpstreamTotal *float64 `json:"duration_upstream_total,omitempty"`
}
// RProxyLine is rproxy's short line in the request log about one request. The
// field names, and their order, are those of docs/request-log.md. A field that
// does not apply to a request is left out of its line.
//
//nolint:tagliatelle // docs/request-log.md names the fields in snake_case
type RProxyLine struct {
Type string `json:"type"`
Time string `json:"time"`
RequestID string `json:"request_id"`
ClientIP string `json:"client_ip"`
Host string `json:"host"`
Method string `json:"method"`
Path string `json:"path"`
Query string `json:"query"`
Status int `json:"status"`
RequestBytes int64 `json:"request_bytes"`
ResponseBytes int64 `json:"response_bytes"`
// DurationRequest runs in milliseconds from the request's headers being read
// until the app has been sent the whole request, or the answer has started
// if that is sooner. DurationResponse runs from then until the answer ends.
DurationRequest float64 `json:"duration_request"`
DurationResponse float64 `json:"duration_response"`
// Disposition is servewaf's answer or, while DefaultDisposition is true, the
// value of SWRP_DEFAULT_DISPOSITION. It is "" when servewaf was not asked.
Disposition string `json:"disposition,omitempty"`
DefaultDisposition bool `json:"default_disposition,omitempty"`
}
// Write writes line to w as one JSON line marked "type":"request".
func Write(w io.Writer, line *Line) error {
line.Type = "request"
return writeJSONLine(w, line)
}
// WriteRProxyLine writes line to w as one JSON line marked "type":"request".
func WriteRProxyLine(w io.Writer, line *RProxyLine) error {
line.Type = "request"
return writeJSONLine(w, line)
}
// writeJSONLine writes line to w as one JSON line.
func writeJSONLine(w io.Writer, line any) error {
encoded, err := json.Marshal(line)
if err != nil {
return fmt.Errorf("encode the request log line: %w", err)
+22
View File
@@ -67,6 +67,28 @@ func TestWriteWritesOneJSONLineMarkedRequest(t *testing.T) {
}
}
func TestWriteRProxyLineWritesOneJSONLineWithOnlyItsFields(t *testing.T) {
t.Parallel()
var out bytes.Buffer
err := requestlog.WriteRProxyLine(&out, &requestlog.RProxyLine{
RequestID: "an-id",
Disposition: "allow",
})
if err != nil {
t.Fatalf("write: %v", err)
}
const want = `{"type":"request","time":"","request_id":"an-id","client_ip":"",` +
`"host":"","method":"","path":"","query":"","status":0,"request_bytes":0,` +
`"response_bytes":0,"duration_request":0,"duration_response":0,` +
`"disposition":"allow"}` + "\n"
if out.String() != want {
t.Errorf("wrote %s, want %s", out.String(), want)
}
}
func TestProcessLinesAreMarkedProcessAndGiveTheInstance(t *testing.T) {
t.Parallel()
+211
View File
@@ -0,0 +1,211 @@
package rproxy
import (
"bufio"
"errors"
"io"
"net"
"net/http"
"sync/atomic"
)
// errResponseTooLarge ends an app's response body that is longer than
// SWRP_MAX_RESPONSE_SIZE.
var errResponseTooLarge = errors.New(
"the response body is over SWRP_MAX_RESPONSE_SIZE")
// requestBody is the visitor's request body. rproxy first reads up to the first
// 64 KiB of it into held, and then, on its way to the app, the transport reads
// it on a goroutine of its own.
type requestBody struct {
// body is the visitor's body, ending in an *http.MaxBytesError past
// SWRP_MAX_REQUEST_SIZE.
body io.ReadCloser
rq *request
// held is the part read before the request goes to the app, which Read gives
// first.
held []byte
// waiting is true while a Read waits for the visitor to send more.
waiting atomic.Bool
// received is true once the visitor has sent the whole body.
received atomic.Bool
// bytes is how much of the body has been read.
bytes atomic.Int64
}
// Read reads from the visitor's body, after the part held, which has been
// counted already.
func (b *requestBody) Read(p []byte) (int, error) {
if len(b.held) > 0 {
n := copy(p, b.held)
b.held = b.held[n:]
return n, nil
}
b.waiting.Store(true)
n, err := b.body.Read(p)
b.waiting.Store(false)
b.bytes.Add(int64(n))
var tooLarge *http.MaxBytesError
switch {
case errors.Is(err, io.EOF):
b.received.Store(true)
case errors.As(err, &tooLarge):
b.rq.refuse(http.StatusRequestEntityTooLarge)
}
return n, err
}
// Close closes the visitor's body.
func (b *requestBody) Close() error {
return b.body.Close()
}
// responseBody is the app's response body on its way to the visitor.
type responseBody struct {
// body is the app's body, ending in an *http.MaxBytesError past
// SWRP_MAX_RESPONSE_SIZE.
body io.ReadCloser
rq *request
}
// Read reads from the app's body.
func (b *responseBody) Read(p []byte) (int, error) {
n, err := b.body.Read(p)
if err == nil {
return n, nil
}
var tooLarge *http.MaxBytesError
switch {
case errors.Is(err, io.EOF):
b.rq.responseReceived()
case errors.As(err, &tooLarge):
b.rq.refuse(http.StatusBadGateway)
return n, errResponseTooLarge
case b.rq.in.Context().Err() == nil:
// The answer broke off, not because the visitor went away. If a time
// limit cut it, that refusal came first and is the one kept.
b.rq.refuse(http.StatusBadGateway)
}
return n, err
}
// Close closes the app's body.
func (b *responseBody) Close() error {
return b.body.Close()
}
// upgradedConn is the connection to the app once the app has switched
// protocols, as for a WebSocket. ReverseProxy writes to it what the visitor
// sends and reads from it what the app sends, on goroutines of its own, until
// the connection closes; it counts the bytes each way, for the report.
type upgradedConn struct {
io.ReadWriteCloser
// fromApp is how many bytes the app has sent, and toApp how many the
// visitor has.
fromApp atomic.Int64
toApp atomic.Int64
}
// Read reads what the app sends.
func (c *upgradedConn) Read(p []byte) (int, error) {
n, err := c.ReadWriteCloser.Read(p)
c.fromApp.Add(int64(n))
return n, err
}
// Write sends the app what the visitor sent.
func (c *upgradedConn) Write(p []byte) (int, error) {
n, err := c.ReadWriteCloser.Write(p)
c.toApp.Add(int64(n))
return n, err
}
// CloseWrite tells the app that the visitor sends no more, while what the app
// sends still passes. ReverseProxy calls it once the visitor has stopped
// sending, and closes the connection there if it is not supported.
func (c *upgradedConn) CloseWrite() error {
conn, ok := c.ReadWriteCloser.(interface{ CloseWrite() error })
if !ok {
return http.ErrNotSupported
}
return conn.CloseWrite()
}
// limitBody returns body, cut off with an *http.MaxBytesError after maxBytes, or
// unchanged if maxBytes is zero, which is off.
func limitBody(body io.ReadCloser, maxBytes int64) io.ReadCloser {
if maxBytes == 0 {
return body
}
// Without a ResponseWriter, MaxBytesReader only counts and cuts off.
return http.MaxBytesReader(nil, body, maxBytes)
}
// responseWriter is the response to the visitor. It notes the status and size
// for the log line and the report.
type responseWriter struct {
http.ResponseWriter
// status is the final status sent, or zero before one is.
status int
bytes int64
}
// WriteHeader sends the status and headers. An informational 1xx status is
// passed on and the final status still comes later.
func (w *responseWriter) WriteHeader(status int) {
if status >= http.StatusOK && w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
// Write sends part of the body.
func (w *responseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(p) //nolint:gosec // the answer, as it was given
w.bytes += int64(n)
return n, err
}
// FlushError sends what has been written so far. http.ResponseController calls
// it, as ReverseProxy does after each write.
func (w *responseWriter) FlushError() error {
return http.NewResponseController(w.ResponseWriter).Flush()
}
// Hijack takes over the connection to the visitor, which ReverseProxy does only
// to pass on the app's 101 Switching Protocols: that is then the status sent.
func (w *responseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
conn, buffered, err := http.NewResponseController(w.ResponseWriter).Hijack()
if err == nil {
w.status = http.StatusSwitchingProtocols
}
return conn, buffered, err
}
// Unwrap lets http.ResponseController reach net/http's own ResponseWriter for
// what responseWriter does not do itself.
func (w *responseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
+120
View File
@@ -0,0 +1,120 @@
package rproxy
import (
"net/http"
"net/netip"
"slices"
"strings"
)
// The headers rproxy sets on the requests it passes to the app. Go writes every
// header name in this form, as it sends it and as it receives it, so
// X-Client-ASN arrives as X-Client-Asn, and Del removes a visitor's own whatever
// their case; header names are not case-sensitive.
const (
requestIDHeader = "X-Request-ID"
asnHeader = "X-Client-Asn"
countryHeader = "X-Client-Country"
)
// peerAddress is the address of the request's TCP peer, normally traefik.
func peerAddress(r *http.Request) netip.Addr {
addrPort, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return netip.Addr{}
}
return addrPort.Addr().Unmap()
}
// clientAddress works out who the visitor is. A peer outside the trusted
// proxies is the visitor, and what it says in X-Forwarded-For is ignored. For a
// peer inside them, X-Forwarded-For is read from the right, and the first
// address outside them is the visitor; if every address in it is inside, the
// leftmost is, and with no header, the peer. An entry that is not an address
// ends the reading, since nothing to its left can be believed.
func clientAddress(
peer netip.Addr, forwardedFor []string, trusted []netip.Prefix,
) netip.Addr {
client := peer
if !isInside(peer, trusted) {
return client
}
entries := strings.Split(strings.Join(forwardedFor, ","), ",")
for _, entry := range slices.Backward(entries) {
addr, err := netip.ParseAddr(strings.TrimSpace(entry))
if err != nil {
break
}
client = addr.Unmap()
if !isInside(client, trusted) {
break
}
}
return client
}
// scheme is how the visitor reached traefik, as a trusted proxy says in
// X-Forwarded-Proto, or otherwise http, the only scheme rproxy serves.
func scheme(r *http.Request, peerTrusted bool) string {
proto := r.Header.Get("X-Forwarded-Proto")
if !peerTrusted || proto == "" {
return "http"
}
return proto
}
// isInside reports whether addr is in one of the netblocks.
func isInside(addr netip.Addr, netblocks []netip.Prefix) bool {
return slices.ContainsFunc(netblocks, func(netblock netip.Prefix) bool {
return netblock.Contains(addr)
})
}
// setForwardedHeaders sets the headers in which the app learns about the
// visitor, so that it sees what it would see from traefik directly. A trusted
// proxy's forwarded headers pass on, with the proxy's own address added to
// X-Forwarded-For. Those of any other peer are its own claims and are replaced:
// X-Forwarded-For names the peer, X-Forwarded-Host the host it asked for, and
// X-Forwarded-Proto plain http, which is how it reached rproxy.
func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
forwardedFor := peer.String()
if trusted {
// ReverseProxy removes these from out before Rewrite.
for _, name := range []string{"Forwarded", "X-Forwarded-Host", "X-Forwarded-Proto"} {
values, ok := in.Header[name]
if ok {
out.Header[name] = values
}
}
prior := in.Header.Values("X-Forwarded-For")
if len(prior) > 0 {
forwardedFor = strings.Join(prior, ", ") + ", " + forwardedFor
}
out.Header.Set("X-Forwarded-For", forwardedFor)
return
}
// ReverseProxy has removed Forwarded and the three set below; these are the
// other headers in which traefik tells the app about the visitor and its
// request.
for _, name := range []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",
} {
out.Header.Del(name)
}
out.Header.Set("X-Forwarded-For", forwardedFor)
out.Header.Set("X-Forwarded-Host", in.Host)
out.Header.Set("X-Forwarded-Proto", "http")
}
+543
View File
@@ -0,0 +1,543 @@
package rproxy_test
import (
"bytes"
"io"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
)
// post is the start of a POST whose headers end with what follows it.
const post = "POST / HTTP/1.1\r\nHost: site.example\r\n"
// announcedLength is the header that announces a body of size bytes, and the
// end of the headers.
func announcedLength(size int) string {
return "Content-Length: " + strconv.Itoa(size) + "\r\n\r\n"
}
func TestServerHasTheFixedLimits(t *testing.T) {
t.Parallel()
cfg, err := config.RProxyFromEnvironment(func(name string) (string, bool) {
if name == "SWRP_WAF_BACKEND" {
return nowhere, true
}
return "", false
})
if err != nil {
t.Fatalf("settings: %v", err)
}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: io.Discard,
ProcessLog: requestlog.NewProcessLogger(io.Discard, "test", slog.LevelInfo),
})
if server.Addr != ":8080" || server.MaxHeaderBytes != 28<<10 ||
server.IdleTimeout != 2*time.Minute || server.ReadHeaderTimeout != time.Minute {
t.Errorf("server listens on %q with header limit %d, idle time %s and "+
"header timeout %s", server.Addr, server.MaxHeaderBytes,
server.IdleTimeout, server.ReadHeaderTimeout)
}
}
func TestABodyAnnouncedOverItsLimitIsRefusedOnceServeWAFAllowsIt(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, limit string
size, asked int
}{
{"with the limit within the first part", "1K", 1025, 1024},
{"with the limit past the first part", "128K", 129 << 10, questionBodyMaxBytes},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
t.Error("the app was asked")
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestSize: tc.limit,
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, tc.size))))
if got.status != http.StatusRequestEntityTooLarge {
t.Errorf("answered %+v, want 413", got)
}
if asked := servewaf.question(t); len(asked.body) != tc.asked {
t.Errorf("the question carries %d bytes, want %d", len(asked.body), tc.asked)
}
report := servewaf.report(t)
if report.Status != http.StatusRequestEntityTooLarge {
t.Errorf("report %+v, want status 413", report)
}
})
}
}
func TestADenyOutranksTheBodySizeLimit(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestSize: "1K",
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/",
bytes.NewReader(make([]byte, 1025))))
if got.status != http.StatusForbidden {
t.Errorf("answered %+v, want servewaf's 403", got)
}
}
func TestABodyNotAnnouncedOverItsLimitIsRefused(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, limit string
size int
// reachesApp is true when the part of the body the limit lets through
// goes on to the app before the limit is met.
reachesApp bool
}{
{"within the first part", "1K", 2 << 10, false},
{"past the first part", "128K", 129 << 10, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var asked atomic.Bool
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
asked.Store(true)
greet(w, r)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestSize: tc.limit,
})
conn := dial(t, addr)
send(t, conn, post+"Transfer-Encoding: chunked\r\n\r\n"+
strconv.FormatInt(int64(tc.size), 16)+"\r\n"+strings.Repeat("x", tc.size)+
"\r\n0\r\n\r\n")
if status := readStatus(t, conn); status != http.StatusRequestEntityTooLarge {
t.Errorf("status %d, want 413", status)
}
if asked.Load() != tc.reachesApp {
t.Errorf("the app was asked: %v, want %v", asked.Load(), tc.reachesApp)
}
})
}
}
func TestAResponseOverItsLimit(t *testing.T) {
t.Parallel()
large := strings.Repeat("x", 2<<10)
t.Run("announced, is refused", func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(contentLengthHeader, strconv.Itoa(len(large)))
_, _ = io.WriteString(w, large)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseSize: "1K",
})
got := get(t, addr, "/")
if got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status)
}
if report := servewaf.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report)
}
})
t.Run("not announced, is cut off", func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, large[:1])
_ = http.NewResponseController(w).Flush()
_, _ = io.WriteString(w, large[1:])
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseSize: "1K",
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
text, err := io.ReadAll(conn)
if err != nil || bytes.Contains(text, []byte(large)) {
t.Errorf("read %d bytes (%v), want the answer cut off", len(text), err)
}
report := servewaf.report(t)
if report.Status != http.StatusOK || report.ResponseBytes > 1<<10 {
t.Errorf("report %+v, want status 200 and at most 1K of the answer", report)
}
})
}
func TestAVisitorTooSlowToSendTheFirstPartIsAnswered408(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, rest string
}{
{"with its length announced", announcedLength(100) + "some of it"},
{"with no length announced", "Transfer-Encoding: chunked\r\n\r\na\r\nsome of it\r\n"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "500",
})
conn := dial(t, addr)
send(t, conn, post+tc.rest)
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
t.Errorf("status %d, want 408", status)
}
line := out.requestLine(t)
if line["disposition"] != nil || line["request_bytes"] != 10.0 {
t.Errorf("line %v, want no disposition and 10 bytes", line)
}
if len(servewaf.questions) != 0 {
t.Error("servewaf was asked")
}
servewaf.wantNoReport(t)
})
}
}
func TestAVisitorThatGoesAwayBeforeTheFirstPartIsNotAsked(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, out := startRProxy(t, servewaf.URL, app.URL, nil)
conn := dial(t, addr)
send(t, conn, post+announcedLength(100)+"some of it")
_ = conn.Close()
line := out.requestLine(t)
if line["status"] != 0.0 || line["disposition"] != nil {
t.Errorf("line %v, want no answer and no disposition", line)
}
if len(servewaf.questions) != 0 {
t.Error("servewaf was asked")
}
}
func TestAVisitorTooSlowToSendTheRestIsAnswered408(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "3000",
})
conn := dial(t, addr)
send(t, conn, post+announcedLength(2*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes+1))
if status := readStatus(t, conn); status != http.StatusRequestTimeout {
t.Errorf("status %d, want 408", status)
}
if asked := servewaf.question(t); len(asked.body) != questionBodyMaxBytes {
t.Errorf("the question carries %d bytes", len(asked.body))
}
if report := servewaf.report(t); report.Status != http.StatusRequestTimeout {
t.Errorf("report %+v, want status 408", report)
}
}
// startAppThatTakesNothing starts an app that accepts connections and never
// reads from them, and returns its URL.
func startAppThatTakesNothing(t *testing.T) string {
t.Helper()
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
stopped := make(chan struct{})
t.Cleanup(func() {
close(stopped)
_ = listener.Close()
})
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
<-stopped
_ = conn.Close()
}()
}
}()
return "http://" + listener.Addr().String()
}
func TestAnAppTooSlowToTakeTheRequestIsAnswered504(t *testing.T) {
t.Parallel()
// More than the connections hold while nobody reads, written a little at a
// time, so that rproxy soon waits for the app.
const (
bodySize = 64 << 20
writeSize = 32 << 10
)
servewaf := startServeWAF(t, allow)
addr, _ := startRProxy(t, servewaf.URL, startAppThatTakesNothing(t),
map[string]string{maxRequestTime: "3000"})
conn := dial(t, addr)
send(t, conn, post+announcedLength(bodySize))
go func() {
part := make([]byte, writeSize)
for range bodySize / writeSize {
_, err := conn.Write(part)
if err != nil {
return
}
}
}()
if status := readStatus(t, conn); status != http.StatusGatewayTimeout {
t.Errorf("status %d, want 504", status)
}
}
func TestAnAppTooSlowToAnswerIsAnswered504(t *testing.T) {
t.Parallel()
release := make(chan struct{})
servewaf := startServeWAF(t, allow)
app := startApp(t, func(http.ResponseWriter, *http.Request) {
<-release
})
t.Cleanup(func() { close(release) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: "300",
})
got := get(t, addr, "/")
if got.status != http.StatusGatewayTimeout {
t.Errorf("answered %d, want 504", got.status)
}
if report := servewaf.report(t); report.Status != http.StatusGatewayTimeout {
t.Errorf("report %+v, want status 504", report)
}
}
func TestAnAppTooSlowToFinishItsAnswerIsCutOff(t *testing.T) {
t.Parallel()
release := make(chan struct{})
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "the start")
_ = http.NewResponseController(w).Flush()
<-release
})
t.Cleanup(func() { close(release) })
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: "300",
})
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
// dial's deadline ends this read with an error if the connection stays open.
_, err := io.ReadAll(conn)
if err != nil {
t.Errorf("read: %v, want the connection closed", err)
}
if report := servewaf.report(t); report.Status != http.StatusOK {
t.Errorf("report %+v, want the status the visitor got", report)
}
}
func TestAVisitorTooSlowToTakeTheAnswerIsCutOff(t *testing.T) {
t.Parallel()
// More than the connections hold while nobody reads.
const (
bodySize = 64 << 20
writeSize = 32 << 10
)
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
part := make([]byte, writeSize)
for range bodySize / writeSize {
_, err := w.Write(part)
if err != nil {
return
}
}
})
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxResponseTime: "500",
})
// The visitor asks, and never reads the answer.
conn := dial(t, addr)
send(t, conn, "GET / HTTP/1.1\r\nHost: site.example\r\n\r\n")
line := out.requestLine(t)
responseBytes, _ := line["response_bytes"].(float64)
if line["status"] != float64(http.StatusOK) || responseBytes >= bodySize {
t.Errorf("line %v, want status 200 and the answer cut off", line)
}
report := servewaf.report(t)
if report.Status != http.StatusOK || report.ResponseBytes >= bodySize {
t.Errorf("report %+v, want status 200 and the answer cut off", report)
}
}
func TestAnAppThatCannotBeReachedIsAnswered502WithAReport(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
addr, _ := startRProxy(t, servewaf.URL, nowhere, nil)
got := get(t, addr, "/")
if got.status != http.StatusBadGateway {
t.Errorf("answered %d, want 502", got.status)
}
if report := servewaf.report(t); report.Status != http.StatusBadGateway {
t.Errorf("report %+v, want status 502", report)
}
}
func TestAVisitorThatStopsSendingAfterTheAppHasAnsweredGetsTheAnswer(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, greeting)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "3000",
})
// Far more announced than sent, so that Go's server answers for the app
// without waiting for the rest.
conn := dial(t, addr)
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusOK {
t.Errorf("status %d, want the app's 200", status)
}
}
func TestTheRequestTimeLimitDoesNotCutAnAnswerOnceTheAppHasTheRequest(t *testing.T) {
t.Parallel()
const answer = "the end of a long answer"
servewaf := startServeWAF(t, allow)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
_ = http.NewResponseController(w).Flush()
time.Sleep(2100 * time.Millisecond)
_, _ = io.WriteString(w, answer)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "2000",
})
got := visit(t, newRequest(t, http.MethodPost, addr, "/", strings.NewReader("a body")))
if got.status != http.StatusOK || got.body != answer {
t.Errorf("answered %d %q, want the app's whole answer", got.status, got.body)
}
}
func TestAVisitorThatStopsSendingAfterADenyGetsTheAnswerByTheDeadline(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, map[string]string{
maxRequestTime: "500",
})
// Go's server waits for a little of the body left before it sends an answer,
// and the time for sending the request ends that wait.
conn := dial(t, addr)
send(t, conn, post+announcedLength(questionBodyMaxBytes+100)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want servewaf's 403", status)
}
}
+228
View File
@@ -0,0 +1,228 @@
package rproxy_test
import (
"bytes"
"crypto/rand"
"io"
"maps"
"net/http"
"slices"
"strconv"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/proxy"
)
// questionBodyMaxBytes is the most of a body a question carries.
const questionBodyMaxBytes = proxy.QuestionBodyMaxBytes
func TestQuestionIsTheVisitorsRequestWithRProxysHeaders(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
const target = "/some%2Fpath/with%20space?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodDelete, addr, target, http.NoBody)
req.Host = site
req.Header.Set("X-Test", "from the visitor")
req.Header.Set(forwardedForHeader, "198.51.100.1")
req.Header.Set(forwardedProtoHeader, "gopher")
req.Header.Set(requestIDHeader, "the-visitor's")
req.Header.Set(proxy.ClientHeader, "198.51.100.2")
req.Header.Set(proxy.PeerHeader, "198.51.100.3")
req.Header.Set(proxy.ContentLengthHeader, "12345")
// Go's client then sends no User-Agent, so that one in the question would be
// Go's own.
req.Header["User-Agent"] = nil
wantGreeting(t, visit(t, req))
asked := servewaf.question(t)
if asked.method != http.MethodDelete || asked.target != target ||
asked.host != site {
t.Errorf("asked about %s %s at %s", asked.method, asked.target, asked.host)
}
if asked.header.Get(requestIDHeader) == "the-visitor's" {
t.Error("the question carries the visitor's id")
}
// Exactly these: visit's connection has the visitor send Connection: close.
want := http.Header{}
want.Set("Connection", "close")
want.Set("X-Test", "from the visitor")
want.Set(forwardedForHeader, "198.51.100.1")
want.Set(forwardedProtoHeader, "http")
want.Set(requestIDHeader, asked.header.Get(requestIDHeader))
want.Set(proxy.ClientHeader, localhost)
want.Set(proxy.PeerHeader, localhost)
if !maps.EqualFunc(asked.header, want, slices.Equal) {
t.Errorf("the question's headers are %v, want %v", asked.header, want)
}
}
func TestQuestionNamesTheVisitorATrustedProxySpeaksFor(t *testing.T) {
t.Parallel()
const visitor = "203.0.113.9"
for _, tc := range []struct {
name, trusted, client, proto, forwarded string
}{
{
"from a trusted proxy", localhost + "/32", visitor, "https",
visitor + ", " + localhost,
},
{"from a peer not trusted", "10.0.0.0/8", localhost, "http", localhost},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, allow)
forwarded := make(chan http.Header, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
forwarded <- r.Header.Clone()
greet(w, r)
})
addr, out := startRProxy(t, servewaf.URL, app.URL, map[string]string{
trustedProxies: tc.trusted,
})
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)
req.Header.Set(forwardedForHeader, visitor)
req.Header.Set(forwardedProtoHeader, "https")
wantGreeting(t, visit(t, req))
asked := servewaf.question(t)
if asked.header.Get(proxy.ClientHeader) != tc.client ||
asked.header.Get(proxy.PeerHeader) != localhost ||
asked.header.Get(forwardedForHeader) != visitor ||
asked.header.Get(forwardedProtoHeader) != tc.proto {
t.Errorf("the question's headers are %v", asked.header)
}
if line := out.requestLine(t); line["client_ip"] != tc.client {
t.Errorf("line %v, want client_ip %s", line, tc.client)
}
if got := (<-forwarded).Get(forwardedForHeader); got != tc.forwarded {
t.Errorf("the app saw %s %q, want %q", forwardedForHeader, got, tc.forwarded)
}
})
}
}
// randomBytes returns n random bytes.
func randomBytes(n int) []byte {
body := make([]byte, n)
_, _ = rand.Read(body) // it never returns an error
return body
}
func TestFirst64KiBReachServeWAFAndTheWholeBodyTheApp(t *testing.T) {
t.Parallel()
for _, size := range []int{
1, questionBodyMaxBytes - 1, questionBodyMaxBytes, questionBodyMaxBytes + 1,
5 * questionBodyMaxBytes,
} {
for _, announced := range []bool{true, false} {
name := strconv.Itoa(size) + " bytes, length announced: " +
strconv.FormatBool(announced)
t.Run(name, func(t *testing.T) {
t.Parallel()
body := randomBytes(size)
servewaf := startServeWAF(t, allow)
received := make(chan []byte, 1)
app := startApp(t, func(w http.ResponseWriter, r *http.Request) {
whole, _ := io.ReadAll(r.Body)
received <- whole
greet(w, r)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
req := newRequest(t, http.MethodPost, addr, "/", bytes.NewReader(body))
if !announced {
// Go's client sends a body of unknown length in chunks.
req.ContentLength = -1
}
wantGreeting(t, visit(t, req))
asked := servewaf.question(t)
want := body[:min(size, questionBodyMaxBytes)]
wantQuestionBody(t, asked, want, size, announced)
if !bytes.Equal(<-received, body) {
t.Error("the app did not get the whole body")
}
if report := servewaf.report(t); report.RequestBytes != int64(size) {
t.Errorf("report %+v, of %d bytes", report, size)
}
})
}
}
}
// wantQuestionBody checks the body a question carries, want, and how it gives
// the length of the visitor's body, of size bytes.
func wantQuestionBody(
t *testing.T, asked question, want []byte, size int, announced bool,
) {
t.Helper()
if !bytes.Equal(asked.body, want) {
t.Errorf("the question carries %d bytes, want the first %d", len(asked.body),
len(want))
}
values := asked.header.Values(proxy.ContentLengthHeader)
if announced {
if !slices.Equal(values, []string{strconv.Itoa(size)}) ||
asked.contentLength != int64(len(want)) {
t.Errorf("announced %v and length %d, want %d and %d", values,
asked.contentLength, size, len(want))
}
return
}
if len(values) != 0 || !slices.Equal(asked.transferEncoding, []string{"chunked"}) {
t.Errorf("announced %v and encoded %v, want none and chunked", values,
asked.transferEncoding)
}
}
func TestDenyNeedsOnlyTheFirstPartOfALargeBody(t *testing.T) {
t.Parallel()
servewaf := startServeWAF(t, deny)
app := startApp(t, greet)
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
// Far more announced than sent. Go's server does not wait for that much, so
// the answer comes at once.
conn := dial(t, addr)
send(t, conn, post+announcedLength(100*questionBodyMaxBytes)+
strings.Repeat("x", questionBodyMaxBytes))
if status := readStatus(t, conn); status != http.StatusForbidden {
t.Errorf("status %d, want servewaf's 403", status)
}
if report := servewaf.report(t); report.RequestBytes != questionBodyMaxBytes {
t.Errorf("report %+v, want the first part's bytes", report)
}
}
+564
View File
@@ -0,0 +1,564 @@
package rproxy
import (
"context"
"crypto/rand"
"errors"
"io"
"net/http"
"net/http/httptrace"
"net/http/httputil"
"net/netip"
"net/url"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rules"
)
// flushAfterEachWrite has ReverseProxy pass on each part of the app's answer as
// soon as it arrives.
const flushAfterEachWrite time.Duration = -1
// request is one request on its way through rproxy, from the moment its headers
// have been read to its log line and report.
type request struct {
h *handler
in *http.Request
// rc sets the deadlines of the connection to the visitor.
rc *http.ResponseController
out *responseWriter
body *requestBody // nil for a request without a body
// id is the fresh id made for the request when it arrived.
id string
client netip.Addr
peer netip.Addr
peerTrusted bool
start time.Time
// disposition is what is done with the request, "" until servewaf has been
// asked.
disposition string
// answered is true when servewaf gave disposition, which makes a report due,
// and byDefault when SWRP_DEFAULT_DISPOSITION did.
answered bool
byDefault bool
// asn and country are servewaf's X-Client-ASN and X-Client-Country, for the
// app.
asn, country string
// upstreamStart is when the request was handed to the app.
upstreamStart time.Time
// cancel ends the request to the app.
cancel context.CancelFunc
// refused is the status of the first refusal, from whichever goroutine meets
// it: what the visitor is answered if the response has not started yet.
refused atomic.Int64
// upgraded is the connection to the app once the app has switched protocols,
// as for a WebSocket, and nil otherwise.
upgraded *upgradedConn
// mu guards what follows. The time limits run on goroutines of their own,
// and the transport starts and stops them, and notes the times below, from
// its own; once timersStopped is set, neither time limit acts any more.
mu sync.Mutex
timersStopped bool
requestTimer *time.Timer
responseTimer *time.Timer
// requestEnd is when the app had been sent the whole request, or the answer
// started, if that was sooner.
requestEnd time.Time
// answerStarted is when the first byte of the app's answer arrived.
answerStarted time.Time
}
// newRequest starts handling r: it notes the time, works out the visitor from
// the TCP peer and X-Forwarded-For, and makes the request's id.
func (h *handler) newRequest(w http.ResponseWriter, r *http.Request) *request {
peer := peerAddress(r)
trusted := h.config.TrustedProxies
rq := &request{
h: h,
in: r,
rc: http.NewResponseController(w),
out: &responseWriter{ResponseWriter: w},
id: rand.Text(),
client: clientAddress(peer, r.Header.Values("X-Forwarded-For"), trusted),
peer: peer,
peerTrusted: isInside(peer, trusted),
start: time.Now(),
}
if r.Body != http.NoBody {
rq.body = &requestBody{body: limitBody(r.Body, h.config.MaxRequestSize), rq: rq}
}
return rq
}
// answerHealth answers the health check.
func (rq *request) answerHealth() {
rq.out.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(rq.out, "ok\n")
}
// readFirstPartOfBody reads up to the first 64 KiB of the body into memory,
// within SWRP_MAX_REQUEST_TIME_MS, and reports whether the request goes on. A
// visitor too slow to send it is answered 408, and one that went away is not
// answered. A body over SWRP_MAX_REQUEST_SIZE goes on with the part read, so
// that servewaf is asked first; passToApp then refuses it.
func (rq *request) readFirstPartOfBody() bool {
if rq.body == nil {
return true
}
_ = rq.rc.SetReadDeadline(rq.requestDeadline())
held, err := io.ReadAll(io.LimitReader(rq.body, proxy.QuestionBodyMaxBytes))
// From here on, the request timer ends a read of the rest of the body, so that
// the answer can name the side rproxy was waiting on.
_ = rq.rc.SetReadDeadline(time.Time{})
rq.body.held = held
switch {
case err == nil || rq.refused.Load() != 0:
return true
case errors.Is(err, os.ErrDeadlineExceeded):
rq.answer(http.StatusRequestTimeout)
return false
default:
return false
}
}
// setPathAsSent has out, the URL of a request made from in, send the path as the
// visitor sent it. Go sends the path URL.EscapedPath gives, which, for a path
// holding a character Go escapes, such as |, is the whole path decoded and
// escaped again, %2F turned into /. Opaque is sent as it is. Left out are a
// target that is no path, such as *, and a path starting with //, which Go would
// send in Opaque as a scheme and host.
func setPathAsSent(out *url.URL, in *http.Request) {
path := rules.Path(in)
if strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "//") {
out.Opaque = path
}
}
// forward passes the request to the app, the part of the body held and then the
// rest, and the app's answer back. ctx is the request's own context.
func (rq *request) forward(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
rq.cancel = cancel
ctx = httptrace.WithClientTrace(ctx, &httptrace.ClientTrace{
WroteRequest: rq.wroteRequest,
GotFirstResponseByte: rq.gotFirstResponseByte,
})
out := rq.in.WithContext(ctx)
if rq.body != nil {
out.Body = rq.body
}
reverseProxy := &httputil.ReverseProxy{
Rewrite: rq.rewrite,
Transport: rq.h.transport,
FlushInterval: flushAfterEachWrite,
ErrorLog: rq.h.errorLog,
ModifyResponse: rq.modifyResponse,
ErrorHandler: rq.answerError,
}
rq.startRequestTimer()
rq.upstreamStart = time.Now()
reverseProxy.ServeHTTP(rq.out, out)
}
// rewrite makes the request the app receives: the visitor's request, unchanged,
// sent to SWRP_UPSTREAM_URL, with the forwarded headers and the request's id set,
// without any X-Client-ASN or X-Client-Country the visitor sent, and with
// servewaf's.
func (rq *request) rewrite(pr *httputil.ProxyRequest) {
upstream := rq.h.config.UpstreamURL
pr.Out.URL.Scheme = upstream.Scheme
pr.Out.URL.Host = upstream.Host
// ReverseProxy drops query parameters it cannot parse; the app gets the
// query as the visitor sent it.
pr.Out.URL.RawQuery = pr.In.URL.RawQuery
setPathAsSent(pr.Out.URL, pr.In)
setForwardedHeaders(pr.In, pr.Out, rq.peer, rq.peerTrusted)
pr.Out.Header.Set(requestIDHeader, rq.id)
pr.Out.Header.Del(asnHeader)
pr.Out.Header.Del(countryHeader)
if rq.asn != "" {
pr.Out.Header.Set(asnHeader, rq.asn)
}
if rq.country != "" {
pr.Out.Header.Set(countryHeader, rq.country)
}
}
// modifyResponse looks at the app's answer before ReverseProxy passes it on.
func (rq *request) modifyResponse(res *http.Response) error {
if res.StatusCode == http.StatusSwitchingProtocols {
// An upgraded connection, such as a WebSocket, is not cut by the time
// limits. ReverseProxy writes this answer straight to the connection it
// takes over, not through rq.out, and then copies what passes each way
// through res.Body, the connection to the app. It may still refuse the
// switch, as when the app's Upgrade is not the visitor's, so the status
// is noted only once it has taken over the connection, by rq.out's
// Hijack.
rq.stopTimers()
conn, ok := res.Body.(io.ReadWriteCloser)
if ok {
rq.upgraded = &upgradedConn{ReadWriteCloser: conn}
res.Body = rq.upgraded
}
return nil
}
maxBytes := rq.h.config.MaxResponseSize
if maxBytes > 0 && res.Body != http.NoBody && res.ContentLength > maxBytes {
rq.refuse(http.StatusBadGateway)
return errResponseTooLarge
}
// Go's server adds a Content-Type it guesses from the body to a response
// whose headers have no Content-Type key. The key with no value sends none,
// as the app sent none.
if res.Header.Get("Content-Type") == "" {
rq.out.Header()["Content-Type"] = nil
}
res.Body = &responseBody{body: limitBody(res.Body, maxBytes), rq: rq}
rq.setResponseDeadline()
return nil
}
// answerError is ReverseProxy's ErrorHandler: the request could not be passed to
// the app, or the app's answer cannot be passed on.
func (rq *request) answerError(_ http.ResponseWriter, _ *http.Request, err error) {
if rq.upgraded != nil {
// The app switched protocols, but the switch was not passed on, and
// ReverseProxy leaves the app's connection open when it refuses it.
_ = rq.upgraded.Close()
}
status := int(rq.refused.Load())
if status == 0 {
if rq.in.Context().Err() != nil {
return // the visitor has gone, and there is no one to answer
}
rq.h.processLog.Warn("request to the app failed", "error", err.Error())
status = http.StatusBadGateway
}
rq.answer(status)
}
// answer sends rproxy's own answer, unless the response has already started.
func (rq *request) answer(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.out.status != 0 {
return // too late to answer: the connection can only be cut
}
// A visitor found too slow is read no more; any other may go on sending until
// its time is up, so that Go's server can read the rest of the body and end
// the request cleanly.
deadline := rq.requestDeadline()
if status == http.StatusRequestTimeout {
deadline = time.Now()
}
rq.prepareOwnAnswer(deadline)
http.Error(rq.out, http.StatusText(status), status)
}
// prepareOwnAnswer readies the connection for an answer rproxy gives itself: the
// reading of a body that has not arrived whole ends at deadline, and the answer
// has SWRP_MAX_RESPONSE_TIME_MS from now to reach the visitor, even when the app
// used that time up first.
func (rq *request) prepareOwnAnswer(deadline time.Time) {
rq.stopReadingBody(deadline)
rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(time.Now().Add(limit))
}
}
// refuse records status, unless an earlier refusal was, and ends the request to
// the app, if one was made.
func (rq *request) refuse(status int) {
rq.refused.CompareAndSwap(0, int64(status))
if rq.cancel != nil {
rq.cancel()
}
}
// finish ends the request's time limits, writes its log line and, if servewaf
// answered the question, sends the report. ctx is the request's own context.
func (rq *request) finish(ctx context.Context) {
rq.stopTimers()
if rq.refused.Load() == 0 {
rq.stopReadingBody(rq.requestDeadline())
}
end := time.Now()
requestEnd := rq.endRequest()
requestBytes, responseBytes := rq.bytesEachWay()
err := requestlog.WriteRProxyLine(rq.h.requestLog, &requestlog.RProxyLine{
Time: requestlog.FormatTime(rq.start),
RequestID: rq.id,
ClientIP: rq.client.String(),
Host: rq.in.Host,
Method: rq.in.Method,
Path: rules.Path(rq.in),
Query: rq.in.URL.RawQuery,
Status: rq.out.status,
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationRequest: requestlog.Milliseconds(requestEnd.Sub(rq.start)),
DurationResponse: requestlog.Milliseconds(end.Sub(requestEnd)),
Disposition: rq.disposition,
DefaultDisposition: rq.byDefault,
})
if err != nil {
rq.h.processLog.Error("writing the request log failed", "error", err.Error())
}
if !rq.answered {
return
}
report := &proxy.Report{
RequestID: rq.id,
Status: rq.out.status,
RequestBytes: requestBytes,
ResponseBytes: responseBytes,
DurationTotal: requestlog.Milliseconds(end.Sub(rq.start)),
}
if !rq.upstreamStart.IsZero() {
report.DurationUpstreamTotal = requestlog.Milliseconds(end.Sub(rq.upstreamStart))
}
rq.h.sendReport(ctx, report)
}
// bytesEachWay returns the body bytes of the request and of the answer. For an
// upgraded connection, such as a WebSocket, which has closed by then, what it
// carried from the visitor counts with the request's and what it carried from the
// app with the answer's.
func (rq *request) bytesEachWay() (int64, int64) {
var requestBytes int64
if rq.body != nil {
requestBytes = rq.body.bytes.Load()
}
responseBytes := rq.out.bytes
if rq.upgraded != nil {
requestBytes += rq.upgraded.toApp.Load()
responseBytes += rq.upgraded.fromApp.Load()
}
return requestBytes, responseBytes
}
// requestDeadline is when the visitor must have sent its whole request, or zero
// when SWRP_MAX_REQUEST_TIME_MS is off.
func (rq *request) requestDeadline() time.Time {
limit := rq.h.config.MaxRequestTime
if limit == 0 {
return time.Time{}
}
return rq.start.Add(limit)
}
// stopReadingBody ends, at deadline, the reading of a visitor's body that has not
// arrived whole: Go's server then reads no more of it, and closes the connection
// after the answer.
func (rq *request) stopReadingBody(deadline time.Time) {
if rq.body == nil || rq.body.received.Load() {
return
}
_ = rq.rc.SetReadDeadline(deadline)
}
// startRequestTimer starts SWRP_MAX_REQUEST_TIME_MS, which runs from the
// request's headers being read until the app has been sent the whole request.
func (rq *request) startRequestTimer() {
deadline := rq.requestDeadline()
if deadline.IsZero() {
return
}
rq.mu.Lock()
defer rq.mu.Unlock()
rq.requestTimer = time.AfterFunc(time.Until(deadline), rq.requestTimedOut)
}
// requestTimedOut is called when SWRP_MAX_REQUEST_TIME_MS runs out while the
// request is still on its way to the app. The answer names the side rproxy was
// waiting on at that moment: 408 when it was waiting for the visitor to send more
// of its body, 504 when it was waiting for the app to be reached or to take what
// it had.
func (rq *request) requestTimedOut() {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
if rq.body == nil || !rq.body.waiting.Load() {
// Once the app has answered, the transport, ReverseProxy and Go's server
// each close the visitor's body, and closing it reads what is left of a
// body under 256 KiB without requestBody.Read, so waiting is false while
// that read waits for the visitor. That read ends now, before refuse:
// should rproxy still answer itself, answer then sets the deadline it
// gives its own answers.
if !rq.answerStarted.IsZero() {
rq.stopReadingBody(time.Now())
}
rq.refuse(http.StatusGatewayTimeout)
return
}
rq.refuse(http.StatusRequestTimeout)
// The transport gives up on the app only once its Read of the visitor's body
// returns, so that Read is ended now. The lock keeps this from reaching the
// connection after the request is handled.
_ = rq.rc.SetReadDeadline(time.Now())
}
// gotFirstResponseByte is called once the first byte of the app's answer has
// arrived.
func (rq *request) gotFirstResponseByte() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.answerStarted = time.Now()
}
// wroteRequest is called once the app has been sent the whole request:
// SWRP_MAX_REQUEST_TIME_MS ends and SWRP_MAX_RESPONSE_TIME_MS starts.
func (rq *request) wroteRequest(info httptrace.WroteRequestInfo) {
if info.Err != nil {
return // the transport gives up, or tries again
}
requestEnd := rq.endRequest()
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.timersStopped {
return
}
stopTimer(rq.requestTimer)
limit := rq.h.config.MaxResponseTime
if limit > 0 {
rq.responseTimer = time.AfterFunc(time.Until(requestEnd.Add(limit)),
rq.responseTimedOut)
}
}
// responseTimedOut is called when SWRP_MAX_RESPONSE_TIME_MS runs out before the
// app has sent its whole answer.
func (rq *request) responseTimedOut() {
rq.mu.Lock()
defer rq.mu.Unlock()
if !rq.timersStopped {
rq.refuse(http.StatusGatewayTimeout)
}
}
// responseReceived is called once the app has sent its whole answer. An app may
// answer before the visitor has sent its whole body, as with a 404 it sends
// without reading it. The time limits then run on: until the transport's read of
// the body returns, Go's server cannot send the answer, and
// SWRP_MAX_REQUEST_TIME_MS is what ends that read for a visitor that stops
// sending.
func (rq *request) responseReceived() {
if rq.body != nil && !rq.body.received.Load() {
return
}
rq.stopTimers()
}
// setResponseDeadline sets SWRP_MAX_RESPONSE_TIME_MS on the connection to the
// visitor: the app's answer must reach the visitor within it of the end of the
// request.
func (rq *request) setResponseDeadline() {
requestEnd := rq.endRequest()
limit := rq.h.config.MaxResponseTime
if limit > 0 {
_ = rq.rc.SetWriteDeadline(requestEnd.Add(limit))
}
}
// endRequest notes now as the end of the request, unless an end is noted, and
// returns the end noted.
func (rq *request) endRequest() time.Time {
rq.mu.Lock()
defer rq.mu.Unlock()
if rq.requestEnd.IsZero() {
rq.requestEnd = time.Now()
}
return rq.requestEnd
}
// stopTimers stops the request's time limits and keeps any from starting later:
// the app's answer is complete, the connection upgraded, or the request handled.
func (rq *request) stopTimers() {
rq.mu.Lock()
defer rq.mu.Unlock()
rq.timersStopped = true
stopTimer(rq.requestTimer)
stopTimer(rq.responseTimer)
}
// stopTimer stops t, which is nil when its time limit is off.
func stopTimer(t *time.Timer) {
if t != nil {
t.Stop()
}
}
+188
View File
@@ -0,0 +1,188 @@
// Package rproxy is the reverse proxy in front of one app that asks servewaf
// about each request. It passes a request servewaf allows to the app and the
// app's answer back, within the size and time limits. When servewaf gives no
// answer, SWRP_DEFAULT_DISPOSITION decides. It keeps nothing between requests
// and never touches disk. "rproxy" in SPEC.md says what it does.
package rproxy
import (
"context"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/proxy"
)
// How rproxy keeps connections to the app and to servewaf open between
// requests.
const (
idleConns = 100
idleConnTimeout = 90 * time.Second
)
// The limits on a connection from a visitor that have no setting.
const (
// requestHeaderMaxBytes is the largest request line and headers a visitor
// may send.
requestHeaderMaxBytes = 32 << 10
// goServerExtraHeaderBytes is how far past MaxHeaderBytes Go's server reads
// before it refuses.
goServerExtraHeaderBytes = 4 << 10
// idleTimeout is how long a kept-open connection may wait for its next
// request.
idleTimeout = 120 * time.Second
)
// adminPrefix starts the path of every request for smallwebwaf itself, which
// never reaches the app.
const adminPrefix = "/_smallwebwaf/"
// Params are what New needs.
type Params struct {
Config *config.RProxy
// RequestLog receives one short JSON line per request.
RequestLog io.Writer
// ProcessLog receives the process's own messages.
ProcessLog *slog.Logger
}
// Server is the server `smallwebwaf rproxy` runs.
type Server struct {
*http.Server
h *handler
}
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
// the headers arrive. The handler applies the time and size limits from then
// on.
func New(params Params) *Server {
backend := params.Config.WAFBackend
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
h := &handler{
config: params.Config,
requestLog: params.RequestLog,
processLog: params.ProcessLog,
errorLog: errorLog,
transport: newTransport(),
reportURL: (&url.URL{
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath,
}).String(),
}
return &Server{
Server: &http.Server{
Addr: params.Config.ListenAddr,
Handler: h,
ErrorLog: errorLog,
ReadHeaderTimeout: params.Config.MaxRequestTime,
IdleTimeout: idleTimeout,
MaxHeaderBytes: requestHeaderMaxBytes - goServerExtraHeaderBytes,
},
h: h,
}
}
// WaitForReports returns once the reports being sent have gone or failed,
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
// a WebSocket that closes while rproxy stops, for one.
func (s *Server) WaitForReports() {
s.h.reportsMu.Lock()
s.h.reportsStopped = true
s.h.reportsMu.Unlock()
s.h.reports.Wait()
}
// handler is the proxy. It holds what every request shares; what belongs to
// one request is in a request.
type handler struct {
config *config.RProxy
requestLog io.Writer
processLog *slog.Logger
errorLog *log.Logger
transport http.RoundTripper
// reportURL is where the reports go.
reportURL string
// reports waits for the reports being sent. reportsMu guards starting one
// against reportsStopped, which is set once nothing is to wait for another.
reports sync.WaitGroup
reportsMu sync.Mutex
reportsStopped bool
}
// newTransport returns what carries requests to the app and to servewaf. It
// never goes through a proxy named in the environment, and leaves answers
// compressed or not as they were sent.
func newTransport() *http.Transport {
return &http.Transport{
MaxIdleConns: idleConns,
MaxIdleConnsPerHost: idleConns,
IdleConnTimeout: idleConnTimeout,
DisableCompression: true,
}
}
// ServeHTTP handles one request: it answers the health check itself, and for
// any other request reads the first part of the body, asks servewaf, and does
// what the answer says. Once the answer has ended, it writes the request's log
// line and sends its report.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rq := h.newRequest(w, r)
defer rq.finish(r.Context())
if r.Method == http.MethodGet && r.URL.Path == proxy.HealthPath {
rq.answerHealth()
return
}
if !rq.readFirstPartOfBody() {
return
}
answer := rq.askServeWAF(r.Context())
switch rq.disposition {
case proxy.DispositionAllow:
rq.passToApp(r.Context())
case proxy.DispositionDeny:
if answer == nil {
rq.answer(http.StatusServiceUnavailable)
return
}
rq.answerWith(answer)
default: // proxy.DispositionClose
// This panic has Go's server close the connection without an answer,
// and log nothing; the deferred finish still runs.
panic(http.ErrAbortHandler)
}
}
// passToApp passes the request on to the app, unless a limit or its path keeps
// it from the app. servewaf answers a path under adminPrefix itself, so such a
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
// request's own context.
func (rq *request) passToApp(ctx context.Context) {
maxBytes := rq.h.config.MaxRequestSize
switch {
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
rq.answer(http.StatusRequestEntityTooLarge)
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
rq.answer(http.StatusNotFound)
default:
rq.forward(ctx)
}
}
+998
View File
@@ -0,0 +1,998 @@
package rproxy_test
import (
"bufio"
"bytes"
"encoding/json"
"io"
"log/slog"
"maps"
"net"
"net/http"
"net/http/httptest"
"slices"
"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"
)
// 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
}
// 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{}
}
}
// 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, "yes")
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()
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)
}
out := &output{}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: out,
ProcessLog: requestlog.NewProcessLogger(out, "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(), out, 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", "yes")
w.WriteHeader(http.StatusTeapot)
_, _ = io.WriteString(w, greeting)
})
addr, _ := startRProxy(t, servewaf.URL, app.URL, nil)
const target = "/some%2Fpath/with%20space?b=2&a=1&bad=%zz;x"
req := newRequest(t, http.MethodGet, addr, target, http.NoBody)
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") != "yes" {
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 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) != "yes" {
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 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)
}
}
// noAnswers are the ways servewaf can fail to give an answer that holds.
func noAnswers() map[string]func(t *testing.T) string {
answeringWith := func(answer http.HandlerFunc) func(t *testing.T) string {
return func(t *testing.T) string {
t.Helper()
return startServeWAF(t, answer).URL
}
}
return map[string]func(t *testing.T) string{
"down": func(*testing.T) string { return nowhere },
"too slow": answeringWith(func(_ http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}),
"no disposition": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}),
"a question refused": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "refused", http.StatusBadRequest)
}),
"an unknown disposition": answeringWith(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, "maybe")
w.WriteHeader(http.StatusOK)
}),
"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)
panic(http.ErrAbortHandler)
}),
"an answer too long": answeringWith(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(dispositionHeader, dispositionDeny)
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write(make([]byte, 1<<20+1))
}),
}
}
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 TestSendsNoReportWithoutAnAnswer(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)
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 TestReportComesAfterTheAnswerWithoutTheVisitorWaiting(t *testing.T) {
t.Parallel()
release := make(chan struct{})
reports := make(chan proxy.Report, 1)
backend := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(proxy.ClientHeader) != "" {
allow(w, r)
return
}
var report proxy.Report
_ = json.NewDecoder(r.Body).Decode(&report)
reports <- report
select {
case <-release:
case <-r.Context().Done():
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(backend.Close)
const body = "the visitor's body"
app := startApp(t, greet)
addr, out := startRProxy(t, backend.URL, app.URL, nil)
// The stand-in does not answer the report until the visitor has its answer.
wantGreeting(t, visit(t, newRequest(t, http.MethodPost, addr, "/",
strings.NewReader(body))))
close(release)
var report proxy.Report
select {
case report = <-reports:
case <-time.After(waitLimit):
t.Fatal("no report")
}
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)
}
}
// 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)
}
}
+212
View File
@@ -0,0 +1,212 @@
package rproxy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"sneak.berlin/go/smallwebwaf/internal/proxy"
)
// answerBodyMaxBytes is the most of the body of servewaf's answer that is read.
// It is far above any answer's.
const answerBodyMaxBytes = 1 << 20
// reportAnswerMaxBytes is the most of the answer to a report that is read,
// which has no body when servewaf takes the report.
const reportAnswerMaxBytes = 4 << 10
var (
errNoDisposition = errors.New("the answer has no valid " + proxy.DispositionHeader)
errAnswerTooLong = errors.New("the answer's body is over 1 MiB")
errReportRefused = errors.New("the report was answered")
)
// servewafAnswer is what servewaf answered to a question.
type servewafAnswer struct {
status int
header http.Header
body []byte
}
// askServeWAF asks servewaf about the request, and notes what is to be done
// with it. It returns servewaf's answer, or nil when servewaf gave none, and
// SWRP_DEFAULT_DISPOSITION decides. ctx is the request's own context.
func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer {
answer, err := rq.ask(ctx)
if err != nil {
rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error())
rq.disposition = rq.h.config.DefaultDisposition
rq.byDefault = true
return nil
}
rq.disposition = answer.header.Get(proxy.DispositionHeader)
rq.answered = true
rq.asn = answer.header.Get(asnHeader)
rq.country = answer.header.Get(countryHeader)
return answer
}
// ask sends servewaf the question, and returns its answer, which must have come
// whole within SWRP_TIMEOUT_MS, with a valid disposition. It ends when ctx does,
// as when the visitor goes away.
func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) {
ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout)
defer cancel()
question, err := rq.newQuestion(ctx)
if err != nil {
return nil, err
}
res, err := rq.h.transport.RoundTrip(question)
if err != nil {
return nil, fmt.Errorf("send the question: %w", err)
}
defer func() {
_ = res.Body.Close()
}()
body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1))
if err != nil {
return nil, fmt.Errorf("read the answer: %w", err)
}
if len(body) > answerBodyMaxBytes {
return nil, errAnswerTooLong
}
switch res.Header.Get(proxy.DispositionHeader) {
case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose:
return &servewafAnswer{res.StatusCode, res.Header, body}, nil
default:
return nil, fmt.Errorf("%w, and status %d", errNoDisposition, res.StatusCode)
}
}
// newQuestion returns the question about the request, as "The question" in
// SPEC.md gives it: the visitor's request as the visitor sent it, with the part
// of the body it holds and rproxy's headers.
func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) {
in := rq.in
var held []byte
if rq.body != nil {
held = rq.body.held
}
question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held))
if err != nil {
return nil, fmt.Errorf("make the question: %w", err)
}
backend := question.URL
target := *in.URL
target.Scheme = backend.Scheme
target.Host = backend.Host
setPathAsSent(&target, in)
question.URL = &target
question.Host = in.Host
// The length NewRequestWithContext set is that of what is held. A body whose
// length the visitor did not announce goes chunked.
if in.ContentLength < 0 {
question.ContentLength = -1
}
question.Header = in.Header.Clone()
question.Header.Set(proxy.ClientHeader, rq.client.String())
question.Header.Set(proxy.PeerHeader, rq.peer.String())
question.Header.Del(proxy.ContentLengthHeader)
announced := in.Header.Get("Content-Length")
if announced != "" {
question.Header.Set(proxy.ContentLengthHeader, announced)
}
question.Header.Set(requestIDHeader, rq.id)
question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted))
// Go's client adds a User-Agent of its own to a request without the key.
_, sent := question.Header["User-Agent"]
if !sent {
question.Header["User-Agent"] = nil
}
return question, nil
}
// answerWith gives the visitor servewaf's answer, less its disposition.
func (rq *request) answerWith(answer *servewafAnswer) {
rq.prepareOwnAnswer(rq.requestDeadline())
header := rq.out.Header()
maps.Copy(header, answer.header)
header.Del(proxy.DispositionHeader)
rq.out.WriteHeader(answer.status)
_, _ = rq.out.Write(answer.body)
}
// sendReport sends servewaf report on a goroutine of its own, so that the
// visitor does not wait for it. The report goes on after ctx, the request's own
// context, has ended.
func (h *handler) sendReport(ctx context.Context, report *proxy.Report) {
h.reportsMu.Lock()
defer h.reportsMu.Unlock()
if h.reportsStopped {
return
}
h.reports.Go(func() {
err := h.postReport(context.WithoutCancel(ctx), report)
if err != nil {
h.processLog.Warn("a report is dropped", "error", err.Error())
}
})
}
// postReport posts report to servewaf, and waits for its answer for up to
// SWRP_TIMEOUT_MS.
func (h *handler) postReport(ctx context.Context, report *proxy.Report) error {
body, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("encode the report: %w", err)
}
ctx, cancel := context.WithTimeout(ctx, h.config.Timeout)
defer cancel()
req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL
ctx, http.MethodPost, h.reportURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("make the request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
res, err := h.transport.RoundTrip(req)
if err != nil {
return fmt.Errorf("send the report: %w", err)
}
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes))
_ = res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return fmt.Errorf("%w %s", errReportRefused, res.Status)
}
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package smallwebwaf
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
"os"
"sneak.berlin/go/smallwebwaf/internal/config"
"sneak.berlin/go/smallwebwaf/internal/requestlog"
"sneak.berlin/go/smallwebwaf/internal/rproxy"
)
// RunRProxy is `smallwebwaf rproxy`: it reads rproxy's settings, then serves
// requests until ctx is done. Then it gives the requests in progress
// shutdownTimeout to finish, and the reports being sent SWRP_TIMEOUT_MS. It
// returns the process's exit status, 1 when rproxy cannot start. Its process
// messages name the host's name as instance, and it writes those at info or
// more severe: rproxy has no setting for either.
func RunRProxy(ctx context.Context, params Params) int {
hostname, _ := os.Hostname() // "" when the host has no name to give
processLog := requestlog.NewProcessLogger(params.Stdout, hostname, slog.LevelInfo)
cfg, err := config.RProxyFromEnvironment(params.LookupEnv)
if err != nil {
processLog.Error("invalid setting", "error", err.Error())
return 1
}
server := rproxy.New(rproxy.Params{
Config: cfg,
RequestLog: params.Stdout,
ProcessLog: processLog,
})
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", cfg.ListenAddr)
if err != nil {
processLog.Error("cannot listen on SWRP_LISTEN_ADDR", "error", err.Error())
return 1
}
processLog.Info("starting",
"version", params.Version,
"address", listener.Addr().String(),
"settings", cfg)
return serveRProxy(ctx, server, listener, processLog)
}
// serveRProxy serves requests on listener until ctx is done, and then stops as
// RunRProxy says.
func serveRProxy(
ctx context.Context, server *rproxy.Server, listener net.Listener,
processLog *slog.Logger,
) int {
served := make(chan error, 1)
go func() {
served <- server.Serve(listener)
}()
select {
case err := <-served:
processLog.Error("serving failed", "error", err.Error())
return 1
case <-ctx.Done():
}
processLog.Info("stopping")
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx),
shutdownTimeout)
defer cancel()
err := server.Shutdown(shutdownCtx)
if err != nil {
processLog.Warn("requests still in progress were cut off",
"error", err.Error())
_ = server.Close()
}
err = <-served
if !errors.Is(err, http.ErrServerClosed) {
processLog.Error("serving failed", "error", err.Error())
return 1
}
server.WaitForReports()
processLog.Info("stopped")
return 0
}
+384
View File
@@ -0,0 +1,384 @@
package smallwebwaf_test
import (
"context"
"io"
"maps"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
"sneak.berlin/go/smallwebwaf/internal/proxy"
"sneak.berlin/go/smallwebwaf/internal/smallwebwaf"
)
const (
// wafBackend is the setting that names servewaf to rproxy.
wafBackend = "SWRP_WAF_BACKEND"
// bannedProbe is a path the default rule file bans a client for.
bannedProbe = "/.env"
// otherClient is a client the tests do not ban.
otherClient = "198.51.100.7"
)
// runRProxy runs `smallwebwaf rproxy` with the settings in env until ctx is
// done, and returns its exit status.
func runRProxy(ctx context.Context, env map[string]string, out *output) int {
return smallwebwaf.RunRProxy(ctx, smallwebwaf.Params{
Version: testVersion,
LookupEnv: func(name string) (string, bool) {
value, ok := env[name]
return value, ok
},
Stdout: out,
})
}
// startRProxy runs rproxy with servewaf at backend and the app at appURL, and
// returns its URL and what it writes. It believes X-Forwarded-For from the
// tests. It stops as the test ends, and that it stopped in order is checked.
func startRProxy(t *testing.T, backend, appURL string) (string, *output) {
t.Helper()
ctx, stop := context.WithCancel(t.Context())
out := &output{}
exited := make(chan int, 1)
go func() {
exited <- runRProxy(ctx, map[string]string{
"SWRP_LISTEN_ADDR": localhost + ":0",
"SWRP_UPSTREAM_URL": appURL,
"SWRP_TRUSTED_PROXIES": localhost + "/32",
wafBackend: backend,
}, out)
}()
t.Cleanup(func() {
stop()
select {
case status := <-exited:
if status != 0 {
t.Errorf("rproxy's exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Error("rproxy still running after being told to stop")
}
out.line(t, "msg", "stopped")
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
return "http://" + addr, out
}
// startServeWAF runs servewaf with the settings in env, and returns its address
// and URL, what it writes, and what stops it, which checks that it stopped in
// order.
func startServeWAF(t *testing.T, env map[string]string) (string, *output, func()) {
t.Helper()
ctx, stop := context.WithCancel(t.Context())
out := &output{}
exited := make(chan int, 1)
go func() {
exited <- runAs(ctx, true, env, out)
}()
addr, _ := out.line(t, "msg", "starting")["address"].(string)
return addr, out, func() {
stop()
select {
case status := <-exited:
if status != 0 {
t.Fatalf("servewaf's exit status %d, want 0; output:\n%s", status, out.text())
}
case <-time.After(waitLimit):
t.Fatal("servewaf still running after being told to stop")
}
}
}
// serveWAFSettings are servewaf's settings with the default rule file, to listen
// at addr.
func serveWAFSettings(t *testing.T, addr string) map[string]string {
t.Helper()
return map[string]string{
listenAddr: addr,
stateDir: t.TempDir(),
rulesDir: filepath.Join("..", "..", "share", "rules.d"),
}
}
func TestABanThroughOneSiteRefusesTheClientOnTheOther(t *testing.T) {
t.Parallel()
addr, serveWAFOut, stop := startServeWAF(t, serveWAFSettings(t, localhost+":0"))
defer stop()
backend := "http://" + addr
first, firstOut := startRProxy(t, backend, startApp(t))
second, _ := startRProxy(t, backend, startApp(t))
wantStatus(t, first+"/", placed, http.StatusOK)
wantStatus(t, second+"/", placed, http.StatusOK)
// The default rule file bans a probe for this.
wantStatus(t, first+bannedProbe, placed, http.StatusForbidden)
wantStatus(t, second+"/", placed, http.StatusForbidden)
wantStatus(t, second+"/", otherClient, http.StatusOK)
// One request's three lines carry one id, rproxy's with servewaf's answer.
line := firstOut.line(t, "path", bannedProbe)
if line["disposition"] != proxy.DispositionDeny || line["client_ip"] != placed {
t.Errorf("rproxy's line is %v", line)
}
id, _ := line["request_id"].(string)
decision := serveWAFLine(t, serveWAFOut, id, "decision")
report := serveWAFLine(t, serveWAFOut, id, "report")
if decision["host"] != line["host"] || decision["client_ip"] != placed ||
report["status"] != float64(http.StatusForbidden) {
t.Errorf("servewaf's lines are %v and %v", decision, report)
}
}
// serveWAFLine waits for servewaf's line with request id and servewaf_line kind.
func serveWAFLine(t *testing.T, out *output, id, kind string) map[string]any {
t.Helper()
deadline := time.Now().Add(waitLimit)
for time.Now().Before(deadline) {
line := out.findLine(t, func(fields map[string]any) bool {
return fields["request_id"] == id && fields["servewaf_line"] == kind
})
if line != nil {
return line
}
time.Sleep(pollInterval)
}
t.Fatalf("no %s line for %s in:\n%s", kind, id, out.text())
return nil
}
func TestTheSiteAnswersWhileServeWAFIsStoppedAndStartedAgain(t *testing.T) {
t.Parallel()
env := serveWAFSettings(t, localhost+":0")
addr, _, stop := startServeWAF(t, env)
site, out := startRProxy(t, "http://"+addr, startApp(t))
// A visitor who keeps asking throughout, and is never refused.
visitorDone := make(chan struct{})
visitorStop := make(chan struct{})
var refused atomic.Int64
go func() {
defer close(visitorDone)
for {
select {
case <-visitorStop:
return
case <-time.After(pollInterval):
}
if !answersNormally(t.Context(), site+"/", otherClient) {
refused.Add(1)
}
}
}()
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
wantStatus(t, site+"/", placed, http.StatusForbidden)
stop()
// With servewaf down, the site answers, and rproxy says the default did it.
wantStatus(t, site+"/after-stop", placed, http.StatusOK)
line := out.line(t, "path", "/after-stop")
if line["disposition"] != proxy.DispositionAllow ||
line["default_disposition"] != true {
t.Errorf("rproxy's line is %v", line)
}
// Back at its address, with its state files, it applies the ban again.
restarted := maps.Clone(env)
restarted[listenAddr] = addr
_, _, stop = startServeWAF(t, restarted)
defer stop()
wantStatus(t, site+"/", placed, http.StatusForbidden)
close(visitorStop)
<-visitorDone
if refused.Load() != 0 {
t.Errorf("the site refused its visitor %d times", refused.Load())
}
}
// answersNormally reports whether a request to url from the client at from, as
// X-Forwarded-For names it, gets the app's answer.
func answersNormally(ctx context.Context, url, from string) bool {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return false
}
req.Header.Set("X-Forwarded-For", from)
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := transport.RoundTrip(req)
if err != nil {
return false
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
return err == nil && res.StatusCode == http.StatusOK && string(body) == greeting
}
func TestRProxyInvalidSettingStopsTheStart(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, setting string
env map[string]string
}{
{"no backend", wafBackend, nil},
{
"another invalid", "SWRP_TIMEOUT_MS",
map[string]string{wafBackend: "http://servewaf:8080", "SWRP_TIMEOUT_MS": "soon"},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
out := &output{}
if status := runRProxy(t.Context(), tc.env, out); status != 1 {
t.Errorf("exit status %d, want 1", status)
}
line := out.line(t, "msg", "invalid setting")
message, _ := line["error"].(string)
if !strings.HasPrefix(message, tc.setting+": ") {
t.Errorf("start refused with %v, want it to name %s", line, tc.setting)
}
})
}
}
func TestRProxyAddressInUseStopsTheStart(t *testing.T) {
t.Parallel()
taken, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", localhost+":0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer func() {
_ = taken.Close()
}()
out := &output{}
status := runRProxy(t.Context(), map[string]string{
wafBackend: "http://servewaf:8080",
"SWRP_LISTEN_ADDR": taken.Addr().String(),
}, out)
if status != 1 {
t.Errorf("exit status %d, want 1", status)
}
out.line(t, "msg", "cannot listen on SWRP_LISTEN_ADDR")
}
func TestMainRunsRProxy(t *testing.T) {
t.Parallel()
binary, err := os.Executable()
if err != nil {
t.Fatalf("find the test binary: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 3*waitLimit)
defer cancel()
out := &output{}
cmd := exec.CommandContext(ctx, binary) //nolint:gosec // this test binary
cmd.Stdout = out
cmd.Env = append(os.Environ(),
commandForMain+"=rproxy",
"SWRP_LISTEN_ADDR="+localhost+":0",
// Nothing listens there; the health check does not ask.
wafBackend+"=http://127.0.0.1:9")
err = cmd.Start()
if err != nil {
t.Fatalf("start: %v", err)
}
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
addr, _ := out.line(t, "msg", "starting")["address"].(string)
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet,
"http://"+addr+proxy.HealthPath, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
transport := &http.Transport{}
defer transport.CloseIdleConnections()
res, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("health check: %v", err)
}
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
if err != nil || string(body) != "ok\n" {
t.Errorf("health check answered %q (%v)", body, err)
}
err = cmd.Process.Signal(syscall.SIGTERM)
if err != nil {
t.Fatalf("signal: %v", err)
}
err = cmd.Wait()
if err != nil {
t.Errorf("exit: %v; output:\n%s", err, out.text())
}
}
+14 -4
View File
@@ -2,7 +2,9 @@
// the rule files, the lookup database and the state files, serves requests
// until it is told to stop, and then stops in an orderly way, writing the
// state files. It serves them in front of an app, or, as `smallwebwaf
// servewaf`, by answering rproxy's questions about them.
// servewaf`, by answering rproxy's questions about them. As `smallwebwaf
// rproxy`, it instead reads rproxy's settings alone, and runs the reverse proxy
// in front of one app that asks servewaf about each request.
package smallwebwaf
import (
@@ -54,7 +56,9 @@ type Params struct {
// Main runs smallwebwaf until SIGTERM or SIGINT, and returns the
// process's exit status. Run as `smallwebwaf servewaf`, it answers
// rproxy's questions rather than pass requests to an app. Run as
// `smallwebwaf healthcheck`, it is the container's health check instead.
// `smallwebwaf rproxy`, it is the reverse proxy that asks those questions.
// Run as `smallwebwaf healthcheck`, it is the container's health check
// instead.
func Main(version string) int {
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
return HealthCheck(context.Background(), os.Args[2:], os.LookupEnv, os.Stderr)
@@ -64,12 +68,18 @@ func Main(version string) int {
syscall.SIGTERM, os.Interrupt)
defer stop()
return Run(ctx, Params{
params := Params{
Version: version,
LookupEnv: os.LookupEnv,
Stdout: os.Stdout,
ServeWAF: len(os.Args) > 1 && os.Args[1] == "servewaf",
})
}
if len(os.Args) > 1 && os.Args[1] == "rproxy" {
return RunRProxy(ctx, params)
}
return Run(ctx, params)
}
// Run reads the settings, the rule files, the lookup database and the
+28 -15
View File
@@ -76,21 +76,11 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
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[key] == value {
return fields
}
fields := o.findLine(t, func(fields map[string]any) bool {
return fields[key] == value
})
if fields != nil {
return fields
}
time.Sleep(pollInterval)
@@ -101,6 +91,29 @@ func (o *output) line(t *testing.T, key, value string) map[string]any {
return nil
}
// findLine returns the first line written so far for which matches is true, or
// nil.
func (o *output) findLine(
t *testing.T, matches func(map[string]any) bool,
) map[string]any {
t.Helper()
for line := range strings.Lines(o.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 matches(fields) {
return fields
}
}
return nil
}
// text returns everything written so far.
func (o *output) text() string {
o.mu.Lock()