rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 1m14s
check / check (push) Failing after 1m14s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf over HTTP/1.1, and passes what servewaf allows to the app within its own size and time limits. It reports each request servewaf answered. SWRP_DEFAULT_DISPOSITION decides when servewaf gives no answer. A request it cannot put to servewaf as sent, it refuses itself. Duplication: internal/rproxy copies passing-on code from internal/proxy until #172 removes the originals. Rule suppressed: gosec G704 and G705, on servewaf's URL and the visitor's answer. Rule suppressed: paralleltest, on the test of a switch rproxy refuses. Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not. Judgement call: every target not starting with one / gets 400. Model: opus-5-5
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user