1 Commits
Author SHA1 Message Date
sneak 365e1a3e01 SWW_APPS: an app's Core Rule Set fixes apply only to the hostnames that serve it (closes #220)
check / check (push) Failing after 16m21s
gitea's fixes (its parameters, its cookies and gitea.conf) now apply
only to a hostname SWW_APPS lists gitea for, or to every hostname while
it is unset. Each app's fixes are one .conf file, whose first rule skips
the rest unless Inspect sets tx.app_<name>, and one entry in apps.

A hostname in SWW_APPS that is not a DNS name, such as *.example.com,
stops the start.

Model: opus-5-5
2026-11-07 13:14:29 +00:00
5 changed files with 53 additions and 9 deletions

No files matched your search

+6 -2
View File
@@ -681,8 +681,12 @@ through. An app's fixes hold for the hostnames `SWW_APPS` lists the app for:
Set empty, `SWW_APPS` lists no hostname. Set empty, `SWW_APPS` lists no hostname.
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken - Unset, every app's fixes hold for every hostname. An app's fixes then weaken
the check of any other app that serves the same paths or fields. the check of any other app that serves the same paths or fields.
- The start stops on an unknown app, a hostname listed twice, an app listed - Any of these mistakes stops the start:
twice for one hostname, or an entry without a colon. - an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
`gitea` is the only app supported. Each app's fixes are in one file, such as `gitea` is the only app supported. Each app's fixes are in one file, such as
`internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`. `internal/waf/gitea.conf`, and one entry of `apps` in `internal/waf/waf.go`.
+6 -2
View File
@@ -210,8 +210,12 @@ between the two (see "The image's command" below).
- A hostname not listed gets no app's fixes, so every rule checks it in - A hostname not listed gets no app's fixes, so every rule checks it in
full. Set empty, it lists no hostname. full. Set empty, it lists no hostname.
- Unset, every app's fixes apply to every hostname. - Unset, every app's fixes apply to every hostname.
- An unknown app, a hostname listed twice, an app listed twice for one - Any of these mistakes stops the start:
hostname, or an entry without a colon stops the start. - an unknown app;
- a hostname that is not a DNS name, such as `*.example.com`;
- a hostname listed twice;
- an app listed twice for one hostname;
- an entry without a colon.
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in - `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
a minute (see "Attack detection" in `SPEC.md`). A count or `off`. a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
+11 -4
View File
@@ -406,6 +406,7 @@ var (
"is not a path starting with / and without a ?, such as /wp-login.php") "is not a path starting with / and without a ?, such as /wp-login.php")
errNotAppsEntry = errors.New( errNotAppsEntry = errors.New(
"is not a hostname, : and a list of apps, such as git.example.com:gitea") "is not a hostname, : and a list of apps, such as git.example.com:gitea")
errNotHostname = errors.New("is not a DNS name such as git.example.com")
errNotApp = errors.New("is not an app sww has fixes for; the apps are") errNotApp = errors.New("is not an app sww has fixes for; the apps are")
errNotWAFMode = errors.New("is not off, detect or block") errNotWAFMode = errors.New("is not off, detect or block")
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
@@ -1675,9 +1676,10 @@ func parseTrapPaths(value string) ([]string, error) {
// parseApps reads the apps each hostname serves: entries separated by // parseApps reads the apps each hostname serves: entries separated by
// semicolons, each a hostname, a colon, and a comma-separated list of the // semicolons, each a hostname, a colon, and a comma-separated list of the
// names waf.AppNames returns, such as // names waf.AppNames returns, such as
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. It returns // git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
// them by hostname in lowercase, since a hostname is matched without // hostname is a DNS name, without a wildcard. It returns them by hostname
// regard to case. An empty value lists no hostname. // in lowercase, since a hostname is matched without regard to case. An
// empty value lists no hostname.
func parseApps(value string) (map[string][]string, error) { func parseApps(value string) (map[string][]string, error) {
apps := map[string][]string{} apps := map[string][]string{}
@@ -1696,6 +1698,10 @@ func parseApps(value string) (map[string][]string, error) {
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry) return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
} }
if !isZone(hostname) {
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
}
if _, listed := apps[hostname]; listed { if _, listed := apps[hostname]; listed {
return nil, fmt.Errorf("%q %w", hostname, errListedTwice) return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
} }
@@ -2152,7 +2158,8 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest return masked + "." + rest
} }
// isZone reports whether each label of zone is as parseZones takes it. // isZone reports whether each label of zone is as parseZones takes it. It
// also checks the hostnames of SWW_APPS, which are DNS names too.
func isZone(zone string) bool { func isZone(zone string) bool {
for label := range strings.SplitSeq(zone, ".") { for label := range strings.SplitSeq(zone, ".") {
badChar := strings.ContainsFunc(label, func(char rune) bool { badChar := strings.ContainsFunc(label, func(char rune) bool {
+7 -1
View File
@@ -603,14 +603,20 @@ func TestApps(t *testing.T) {
func TestInvalidAppsStopsTheStart(t *testing.T) { func TestInvalidAppsStopsTheStart(t *testing.T) {
t.Parallel() t.Parallel()
const notEntry = " is not a hostname, : and a list of apps, " + const (
notEntry = " is not a hostname, : and a list of apps, " +
"such as git.example.com:gitea" "such as git.example.com:gitea"
notHostname = " is not a DNS name such as git.example.com"
)
for _, tc := range []struct{ value, want string }{ for _, tc := range []struct{ value, want string }{
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry}, {"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
{"git.example.com:gitea;", `""` + notEntry}, {"git.example.com:gitea;", `""` + notEntry},
{":gitea", `":gitea"` + notEntry}, {":gitea", `":gitea"` + notEntry},
{"git.example.com:", `"git.example.com:"` + notEntry}, {"git.example.com:", `"git.example.com:"` + notEntry},
{"*.example.com:gitea", `"*.example.com"` + notHostname},
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
{"git example.com:gitea", `"git example.com"` + notHostname},
{ {
"git.example.com:gitea,nextcloud", "git.example.com:gitea,nextcloud",
`"nextcloud" is not an app sww has fixes for; the apps are gitea`, `"nextcloud" is not an app sww has fixes for; the apps are gitea`,
@@ -10,6 +10,7 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -21,6 +22,7 @@ const (
wafDisabledRules = "SWW_WAF_DISABLED_RULES" wafDisabledRules = "SWW_WAF_DISABLED_RULES"
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS" wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
wafBodyLimit = "SWW_WAF_BODY_LIMIT" wafBodyLimit = "SWW_WAF_BODY_LIMIT"
apps = "SWW_APPS"
block = "block" block = "block"
detect = "detect" detect = "detect"
) )
@@ -237,6 +239,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionWAFBlocked)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()