Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd04ec0b35 |
@@ -361,7 +361,10 @@ effective settings are logged at start.
|
|||||||
to all can read it.
|
to all can read it.
|
||||||
- `SWWAF_ALERT_NTFY_TOKEN` (default unset): an ntfy access token, sent to ntfy
|
- `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
|
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
|
- `SWWAF_ALERT_EVENTS` (default
|
||||||
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
`ban,permanent_ban,waf_block,anomaly,reputation_hit,source_failure,file_error`):
|
||||||
the events alerts are sent for. `waf_block`, `anomaly` and `reputation_hit`
|
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
|
`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
|
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,
|
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
|
`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
|
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) {
|
func TestRepeatWithinTheCooldownIsHeldBackAndCountedInTheNext(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
@@ -260,6 +261,8 @@ var (
|
|||||||
errNotWebhookHeader = errors.New(
|
errNotWebhookHeader = errors.New(
|
||||||
"is not a header name followed by : and the header's value, " +
|
"is not a header name followed by : and the header's value, " +
|
||||||
"such as Authorization:Bearer <token>")
|
"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(
|
errNotAlertEvent = errors.New(
|
||||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||||
"source_failure or file_error")
|
"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.LogRemoteAppName = env.appName("SWWAF_LOG_REMOTE_APP_NAME",
|
||||||
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
cfg.InstanceName, cfg.LogRemoteURL != nil)
|
||||||
|
|
||||||
|
env.checkInstanceNameForNtfy(cfg.InstanceName, cfg.AlertNtfyURL != nil)
|
||||||
|
|
||||||
for _, country := range cfg.ExclusivelyAllowedCountries {
|
for _, country := range cfg.ExclusivelyAllowedCountries {
|
||||||
if slices.Contains(cfg.DeniedCountries, country) {
|
if slices.Contains(cfg.DeniedCountries, country) {
|
||||||
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
env.check("SWWAF_EXCLUSIVELY_ALLOWED_COUNTRIES",
|
||||||
@@ -678,6 +683,17 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
|||||||
return value
|
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:
|
// 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_WEBHOOK_URL, SWWAF_ALERT_SLACK_WEBHOOK_URL or
|
||||||
// SWWAF_ALERT_NTFY_URL. Unset or empty, it is nil, and no alert is posted
|
// 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
|
// 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
|
// an ntfy token, "" while it is unset. It is sent in a header, which
|
||||||
// a value that is not empty.
|
// 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 {
|
func (e *environment) secret(name string) string {
|
||||||
value, _ := e.lookup(name)
|
value, _ := e.lookup(name)
|
||||||
|
|
||||||
@@ -718,6 +736,10 @@ func (e *environment) secret(name string) string {
|
|||||||
|
|
||||||
e.settings = append(e.settings, slog.String(name, logged))
|
e.settings = append(e.settings, slog.String(name, logged))
|
||||||
|
|
||||||
|
if strings.ContainsFunc(value, unicode.IsControl) {
|
||||||
|
e.check(name, errControlCharacter)
|
||||||
|
}
|
||||||
|
|
||||||
return value
|
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) {
|
func TestCodeOnBothCountryListsStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ var (
|
|||||||
errMissing = errors.New("has no")
|
errMissing = errors.New("has no")
|
||||||
errCause = errors.New("is not limit, attack or admin")
|
errCause = errors.New("is not limit, attack or admin")
|
||||||
errDestination = errors.New("is not webhook, slack or ntfy")
|
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.
|
// 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)
|
f.params.GeoJS.Load(file.Lookups)
|
||||||
entries = len(file.Lookups)
|
entries = len(file.Lookups)
|
||||||
case alertsJSON:
|
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
|
var file alertsFile
|
||||||
|
|
||||||
err := parse(path, data, &file)
|
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) {
|
func TestBanWithAnotherCauseStopsTheStart(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user