Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd04ec0b35 |
@@ -361,7 +361,10 @@ effective settings are logged at start.
|
||||
to all can read it.
|
||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent to ntfy
|
||||
with each alert as `Authorization: Bearer <token>`, for a topic that needs
|
||||
one. The settings logged at start show `********` in its place.
|
||||
one. The settings logged at start show `********` in its place. A control
|
||||
character in it, such as the carriage return of a file saved with Windows line
|
||||
ends, stops the start, and while `SWWAF_ALERT_NTFY_URL` is set, so does one in
|
||||
`SWWAF_INSTANCE_NAME`, which ntfy is sent in the title.
|
||||
- `SWWAF_ALERT_EVENTS` (default
|
||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||
the events alerts are sent for. `waf_block`, `anomaly` and `reputation_hit`
|
||||
@@ -731,7 +734,9 @@ entries by client address, but for the alerts waiting, with times in UTC.
|
||||
`webhook`, `slack` or `ntfy`, the alerts still waiting to be sent to it, the
|
||||
oldest first, each as the webhook is sent it. As an hour ends, the cooldowns
|
||||
that have run out with no repeat held back are dropped. As the file is read,
|
||||
the alerts waiting for a destination you no longer name are dropped.
|
||||
the alerts waiting for a destination you no longer name are dropped. A file
|
||||
whose `waiting` is a list, as it was before alerts went to Slack and ntfy too,
|
||||
stops the start: put the list under `"webhook"`, or remove the file.
|
||||
|
||||
`bans.json` is written `SWWAF_STATE_WRITE_DELAY` after a ban is made, lifted
|
||||
through `DELETE /_smallwebwaf/bans/<client>`, or made permanent, with every such
|
||||
|
||||
@@ -172,6 +172,27 @@ func TestWouldSendNothingWithoutADestination(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWouldSendWithOnlySlackOrOnlyNtfySet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
onlySlack := newParams()
|
||||
onlySlack.WebhookURL = nil
|
||||
onlySlack.SlackURL = parseURL(slackURL)
|
||||
|
||||
onlyNtfy := newParams()
|
||||
onlyNtfy.WebhookURL = nil
|
||||
onlyNtfy.NtfyURL = parseURL(ntfyURL)
|
||||
|
||||
for setting, params := range map[string]alerts.Params{
|
||||
"SWWAF_ALERT_SLACK_WEBHOOK_URL": onlySlack,
|
||||
"SWWAF_ALERT_NTFY_URL": onlyNtfy,
|
||||
} {
|
||||
if !alerts.New(params).WouldSend(alerts.EventBan, netblock(1)) {
|
||||
t.Errorf("a ban alert would not be sent with only %s set", setting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
@@ -260,6 +261,8 @@ var (
|
||||
errNotWebhookHeader = errors.New(
|
||||
"is not a header name followed by : and the header's value, " +
|
||||
"such as Authorization:Bearer <token>")
|
||||
errControlCharacter = errors.New(
|
||||
"holds a control character, such as the carriage return of a Windows line end")
|
||||
errNotAlertEvent = errors.New(
|
||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||
"source_failure or file_error")
|
||||
@@ -334,6 +337,8 @@ func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
||||
cfg.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
|
||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||
|
||||
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||
|
||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||
if slices.Contains(cfg.DeniedCountries, country) {
|
||||
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||
@@ -678,6 +683,17 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
||||
return value
|
||||
}
|
||||
|
||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||
// character while ntfySet, SWWAF_ALERT_NTFY_URL being set: ntfy is sent
|
||||
// the instance name in a header, which cannot hold one.
|
||||
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||
e.check("SWWAF_INSTANCE_NAME", fmt.Errorf(
|
||||
"%q %w, and is sent to ntfy in a header while SWWAF_ALERT_NTFY_URL is set",
|
||||
instanceName, errControlCharacter))
|
||||
}
|
||||
}
|
||||
|
||||
// webhookURL reads a setting that is a URL each alert is posted to:
|
||||
// SWWAF_ALERT_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or
|
||||
// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
||||
@@ -706,8 +722,10 @@ func (e *environment) webhookHeaders(name string) http.Header {
|
||||
}
|
||||
|
||||
// secret reads a setting that is a secret another service gave, such as
|
||||
// an ntfy token, "" while it is unset. The log shows ******** in place of
|
||||
// a value that is not empty.
|
||||
// an ntfy token, "" while it is unset. It is sent in a header, which
|
||||
// cannot hold a control character, so one in it is an error. The log
|
||||
// shows ******** in place of a value that is not empty, and an error
|
||||
// shows none of it.
|
||||
func (e *environment) secret(name string) string {
|
||||
value, _ := e.lookup(name)
|
||||
|
||||
@@ -718,6 +736,10 @@ func (e *environment) secret(name string) string {
|
||||
|
||||
e.settings = append(e.settings, slog.String(name, logged))
|
||||
|
||||
if strings.ContainsFunc(value, unicode.IsControl) {
|
||||
e.check(name, errControlCharacter)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -697,6 +697,46 @@ func TestSlackAndNtfySettingsAreLoggedWithoutTheirSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfyTokenWithAControlCharacterStopsTheStartWithoutShowingIt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A file saved with Windows line ends keeps the carriage return.
|
||||
for name, env := range map[string]environment{
|
||||
"set": {alertNtfyToken: token + "\r"},
|
||||
"in a file": {alertNtfyToken + "_FILE": writeFile(t, token+"\r\n")},
|
||||
} {
|
||||
_, err := config.FromEnvironment(env.lookupEnv)
|
||||
|
||||
want := alertNtfyToken + ": holds a control character, such as the " +
|
||||
"carriage return of a Windows line end"
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("%s: error %v, want %s", name, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceNameWithAControlCharacterStopsTheStartOnlyWithNtfySet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const name = "fsn1app1\r"
|
||||
|
||||
_, err := config.FromEnvironment(environment{
|
||||
instanceName: name, alertNtfyURL: "https://ntfy.example/smallwebwaf-alerts",
|
||||
}.lookupEnv)
|
||||
|
||||
want := instanceName + `: "fsn1app1\r" holds a control character, such as the ` +
|
||||
`carriage return of a Windows line end, and is sent to ntfy in a header ` +
|
||||
`while ` + alertNtfyURL + ` is set`
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
|
||||
cfg := fromEnvironment(t, environment{instanceName: name})
|
||||
if cfg.InstanceName != name {
|
||||
t.Errorf("not sending to ntfy, %s is %q", instanceName, cfg.InstanceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ var (
|
||||
errMissing = errors.New("has no")
|
||||
errCause = errors.New("is not limit, attack or admin")
|
||||
errDestination = errors.New("is not webhook, slack or ntfy")
|
||||
errWaitingList = errors.New(`waiting is a list, but now lists the alerts by ` +
|
||||
`destination: put the list under "webhook", as "waiting": {"webhook": [...]}, ` +
|
||||
`or remove the file`)
|
||||
)
|
||||
|
||||
// Params are what Load needs.
|
||||
@@ -396,6 +399,17 @@ func (f *Files) takeIn(name string, data []byte, edit bool) (int, error) {
|
||||
f.params.GeoJS.Load(file.Lookups)
|
||||
entries = len(file.Lookups)
|
||||
case alertsJSON:
|
||||
// waiting was a list, of the alerts waiting for the webhook, before
|
||||
// alerts went to Slack and ntfy too.
|
||||
var written struct {
|
||||
Waiting json.RawMessage `json:"waiting"`
|
||||
}
|
||||
|
||||
if json.Unmarshal(data, &written) == nil &&
|
||||
bytes.HasPrefix(written.Waiting, []byte("[")) {
|
||||
return 0, fmt.Errorf("%s: %w", path, errWaitingList)
|
||||
}
|
||||
|
||||
var file alertsFile
|
||||
|
||||
err := parse(path, data, &file)
|
||||
|
||||
@@ -450,6 +450,23 @@ func TestAlertsJSONEntryWithoutAFieldItNeedsStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsJSONWithWaitingAsAListStopsTheStartSayingWhatToChange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// alerts.json as it was written before alerts went to Slack and ntfy too,
|
||||
// with no alert waiting, or one.
|
||||
for _, waiting := range []string{
|
||||
`[]`,
|
||||
`[{"event": "ban", "time": "2026-10-06T00:00:00Z"}]`,
|
||||
} {
|
||||
wantRefused(t, alertsJSON, `{"version": 1, "cooldowns": [], `+
|
||||
`"hour": {"start": "2026-10-06T00:00:00Z", "sent": 0, "held_back": {}}, `+
|
||||
`"waiting": `+waiting+`}`,
|
||||
`: waiting is a list, but now lists the alerts by destination: put the `+
|
||||
`list under "webhook", as "waiting": {"webhook": [...]}, or remove the file`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user