Admin endpoints for bans and clients on the single listener (closes #27)
check / check (push) Successful in 4m1s
check / check (push) Successful in 4m1s
SWWAF_ADMIN_TOKEN, or its _FILE form, opens GET and POST /_smallwebwaf/bans, DELETE /_smallwebwaf/bans/<client> and GET /_smallwebwaf/clients/<ip>. Unset, they answer 404; a missing or wrong token gets 401, in observe mode too. They go through every check, as the metrics do. POST takes a netblock, not IPv4-mapped and without a zone, or a client's address, a duration or permanent, and a reason, and makes an admin ban even while another lasts. DELETE lifts every active ban covering the address, kept and marked lifted. Bans come back as bans.json entries; a client as clients.json holds it, with its bans. Judgement call: answers leave out bans.json's version field. Judgement call: DELETE takes an address, not a netblock. Rule suppressed: gosec G304 on a test reading bans.json. Model: opus-5-5
This commit is contained in:
@@ -129,6 +129,10 @@ type Config struct {
|
||||
// LogRequestHeaders are the request headers whose values the request
|
||||
// log gives, in lower case (SWWAF_LOG_REQUEST_HEADERS).
|
||||
LogRequestHeaders []string
|
||||
// AdminToken is the bearer token an admin sends for the ban endpoints
|
||||
// and /_smallwebwaf/clients/<ip> (SWWAF_ADMIN_TOKEN), "" while it is
|
||||
// unset and they are off.
|
||||
AdminToken string
|
||||
// MetricsToken is the bearer token a scraper sends for the metrics
|
||||
// (SWWAF_METRICS_TOKEN), "" while it is unset and the metrics are off.
|
||||
// MetricsTopN is how many countries get series of their own in the
|
||||
@@ -274,6 +278,7 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
StateCounterInterval: env.durationNotOff("SWWAF_STATE_COUNTER_INTERVAL", "15m"),
|
||||
LogRequestHeaders: env.headerNames("SWWAF_LOG_REQUEST_HEADERS",
|
||||
"accept,accept-language,accept-encoding,content-type,origin,range"),
|
||||
AdminToken: env.token("SWWAF_ADMIN_TOKEN"),
|
||||
MetricsToken: env.token("SWWAF_METRICS_TOKEN"),
|
||||
MetricsTopN: env.numberNotOff("SWWAF_METRICS_TOP_N", "50"),
|
||||
RulesDir: env.value("SWWAF_RULES_DIR", "/etc/smallwebwaf/rules.d"),
|
||||
@@ -496,7 +501,7 @@ func (e *environment) headerNames(name, defaultValue string) []string {
|
||||
// durationNotOff reads a setting that is a duration and, unlike a
|
||||
// timeout, cannot be off.
|
||||
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
||||
duration, err := parseDurationNotOff(e.value(name, defaultValue))
|
||||
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
||||
e.check(name, err)
|
||||
|
||||
return duration
|
||||
@@ -732,9 +737,9 @@ func parseCount(value string) (int64, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseDurationNotOff reads a duration above zero, as parseDuration does,
|
||||
// but not off.
|
||||
func parseDurationNotOff(value string) (time.Duration, error) {
|
||||
// ParseDurationNotOff reads a duration above zero, as parseDuration does,
|
||||
// but not off. The ban endpoint reads the duration of a ban with it too.
|
||||
func ParseDurationNotOff(value string) (time.Duration, error) {
|
||||
duration, err := parseDuration(value)
|
||||
if err != nil || duration == 0 {
|
||||
return 0, fmt.Errorf("%q %w", value, errNotDurationAboveZero)
|
||||
|
||||
@@ -50,6 +50,7 @@ const (
|
||||
stateDir = "SWWAF_STATE_DIR"
|
||||
stateWriteDelay = "SWWAF_STATE_WRITE_DELAY"
|
||||
stateCounterInterval = "SWWAF_STATE_COUNTER_INTERVAL"
|
||||
adminToken = "SWWAF_ADMIN_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsToken = "SWWAF_METRICS_TOKEN" //nolint:gosec // the setting's name
|
||||
metricsTopN = "SWWAF_METRICS_TOP_N"
|
||||
instanceName = "SWWAF_INSTANCE_NAME"
|
||||
@@ -81,8 +82,12 @@ rlG9y/jrJb6ORy3kTLWo2EA0BA67vuI=
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
// token is a token of 32 characters, the shortest allowed.
|
||||
const token = "0123456789abcdef0123456789abcdef"
|
||||
// token is a token of 32 characters, the shortest allowed, and
|
||||
// otherToken another.
|
||||
const (
|
||||
token = "0123456789abcdef0123456789abcdef"
|
||||
otherToken = "fedcba9876543210fedcba9876543210"
|
||||
)
|
||||
|
||||
// instance is an SWWAF_INSTANCE_NAME that is a valid app name too, and
|
||||
// remoteURL an SWWAF_LOG_REMOTE_URL, for the tests that send the lines.
|
||||
@@ -693,32 +698,40 @@ func TestShortTokenStopsTheStartWithoutShowingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Characters are counted, not bytes: each é takes two.
|
||||
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, name := range []string{adminToken, metricsToken} {
|
||||
for _, value := range []string{"", token[1:], strings.Repeat("é", 31)} {
|
||||
t.Run(name+"="+value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{metricsToken: value}.lookupEnv)
|
||||
_, err := config.FromEnvironment(environment{name: value}.lookupEnv)
|
||||
|
||||
want := metricsToken + ": is shorter than 32 characters"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
want := name + ": is shorter than 32 characters"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenIsLoggedMasked(t *testing.T) {
|
||||
func TestTokensAreReadAndLoggedMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := fromEnvironment(t, environment{metricsToken: token})
|
||||
cfg := fromEnvironment(t, environment{adminToken: otherToken, metricsToken: token})
|
||||
if cfg.AdminToken != otherToken || cfg.MetricsToken != token {
|
||||
t.Errorf("admin token %q and metrics token %q, want %q and %q",
|
||||
cfg.AdminToken, cfg.MetricsToken, otherToken, token)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).Info("starting", "settings", cfg)
|
||||
|
||||
if strings.Contains(out.String(), token) ||
|
||||
!strings.Contains(out.String(), `"`+metricsToken+`":"********"`) {
|
||||
t.Errorf("the token is not logged masked: %s", out.String())
|
||||
logged := out.String()
|
||||
if strings.Contains(logged, token) || strings.Contains(logged, otherToken) ||
|
||||
!strings.Contains(logged, `"`+adminToken+`":"********"`) ||
|
||||
!strings.Contains(logged, `"`+metricsToken+`":"********"`) {
|
||||
t.Errorf("the tokens are not logged masked: %s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,6 +914,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
stateDir: "/var/lib/smallwebwaf",
|
||||
stateWriteDelay: "10s",
|
||||
stateCounterInterval: "15m",
|
||||
adminToken: "",
|
||||
metricsToken: "",
|
||||
metricsTopN: "50",
|
||||
instanceName: hostname,
|
||||
|
||||
Reference in New Issue
Block a user