Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
828af271a6 |
No files matched your search
@@ -221,7 +221,7 @@ account, a key, a token or an alert destination stays off until you give one.
|
||||
Every setting the code reads, with its default:
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||
@@ -280,6 +280,7 @@ Every setting the code reads, with its default:
|
||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||
| `SWW_APPS` | unset | The apps each hostname serves, `drupal`, `gitea`, `mastodon`, `mediawiki`, `nextcloud`, `vaultwarden` or `wordpress`, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
|
||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||
@@ -581,27 +582,31 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||
front of a code forge, bodies carry what people write and publish, which the
|
||||
rules take for attacks.
|
||||
- `smallwebwaf` changes the Core Rule Set in fourteen ways so that ordinary
|
||||
requests get through to the apps listed below. No setting undoes the changes;
|
||||
"Attack detection" in [`SPEC.md`](SPEC.md) lists them.
|
||||
- These apps work out of the box at the default settings, and their recorded
|
||||
ordinary requests pass the Core Rule Set:
|
||||
- gitea: git over HTTP and sign-in through Git Credential Manager get
|
||||
through. On gitea's own paths, its searches, file names and branch names
|
||||
do too.
|
||||
- WordPress: saving a post from the block editor gets through. On
|
||||
WordPress's own paths, its searches, names and file names do too.
|
||||
- Drupal: on Drupal's own paths, the page to return to, its searches and its
|
||||
names get through.
|
||||
- MediaWiki: on MediaWiki's own paths, its searches, page titles, names and
|
||||
wikitext get through.
|
||||
- Mastodon: on Mastodon's own paths, its searches, names, email addresses,
|
||||
posts and deliveries from other servers get through.
|
||||
- Vaultwarden: on Vaultwarden's own paths, its names, password hints, email
|
||||
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.
|
||||
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
||||
requests to a code forge through, such as git over HTTP and sign-in through
|
||||
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
||||
[`SPEC.md`](SPEC.md) lists them.
|
||||
- Each supported app also has its own fixes, which let its ordinary requests
|
||||
through. Its recorded ordinary requests pass the Core Rule Set at the default
|
||||
settings. The supported apps, by their names in `SWW_APPS`, are:
|
||||
- `gitea`, whose fixes let its searches, file names and branch names
|
||||
through;
|
||||
- `wordpress`, whose fixes let through its searches, its names, its file
|
||||
names and a post saved from the block editor;
|
||||
- `drupal`, whose fixes let through the page to return to, its searches and
|
||||
its names;
|
||||
- `mediawiki`, whose fixes let through its searches, its page titles, its
|
||||
names and its wikitext;
|
||||
- `mastodon`, whose fixes let through its searches, its names, its email
|
||||
addresses, its posts and deliveries from other servers;
|
||||
- `vaultwarden`, whose fixes let through its names, its password hints, its
|
||||
email addresses and its encrypted file names;
|
||||
- `nextcloud`, whose fixes let through its sync client, its WebDAV methods,
|
||||
its searches, its file names, its account names, its comments and its
|
||||
documents.
|
||||
- `SWW_APPS` names the apps each hostname serves. A hostname gets only its own
|
||||
apps' fixes, and a hostname it does not list gets none. Unset, every app's
|
||||
fixes apply to every hostname.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||
fields or paths.
|
||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||
|
||||
@@ -81,8 +81,8 @@ to them.
|
||||
of bans is full.
|
||||
- `internal/rules`: reads the rule files at start and again when they
|
||||
change, and tells which rules a request matches.
|
||||
- `internal/coreruleset`: the Core Rule Set, with the fourteen changes
|
||||
listed under "Attack detection", as Coraza directives.
|
||||
- `internal/coreruleset`: the Core Rule Set, with the six changes and the
|
||||
apps' fixes listed under "Attack detection", as Coraza directives.
|
||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||
database.
|
||||
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
||||
@@ -676,8 +676,8 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
||||
Coraza directives. An exclusion switches one rule off for some fields or paths
|
||||
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||
holds an app's own exclusions. The eighth to fourteenth changes below hold
|
||||
those of the apps they name.
|
||||
holds an app's own exclusions; the apps' fixes below hold those of each app
|
||||
`SWW_APPS` takes.
|
||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
||||
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
||||
@@ -723,23 +723,14 @@ files, the limits and the bans still apply to the client that sends it.
|
||||
scanner from an ordinary client. In front of a code forge, registry and git
|
||||
clients get `404` and `401` by design, hundreds of times a minute.
|
||||
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in fourteen ways. Without them,
|
||||
it would refuse ordinary requests to these apps:
|
||||
|
||||
- gitea;
|
||||
- WordPress;
|
||||
- Drupal;
|
||||
- MediaWiki;
|
||||
- Mastodon;
|
||||
- Vaultwarden;
|
||||
- Nextcloud.
|
||||
|
||||
The changes hold in front of every app, and no setting undoes them:
|
||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
||||
every app, and no setting undoes them:
|
||||
|
||||
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
|
||||
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
||||
Other methods stay refused, except on Nextcloud's WebDAV paths, where the
|
||||
fourteenth change allows the WebDAV methods Nextcloud uses.
|
||||
Other methods stay refused, except on Nextcloud's WebDAV paths, where
|
||||
Nextcloud's fixes allow the WebDAV methods Nextcloud uses.
|
||||
2. The request header `Expect` is allowed, since curl and git send it before
|
||||
some large uploads. `Content-Encoding` is allowed on a request whose body the
|
||||
Core Rule Set does not read, which by default is every request. git
|
||||
@@ -747,19 +738,74 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
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
|
||||
too on a `PUT` of JSON, XML or form data to Nextcloud's WebDAV paths, whose
|
||||
body the fourteenth change leaves unread. The other headers the Core Rule Set
|
||||
body Nextcloud's fixes leave 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
|
||||
`localhost` (931100, 934110). Git Credential Manager, git-credential-oauth
|
||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
||||
address.
|
||||
4. Ten query parameters are not checked against the Core Rule Set's lists of
|
||||
system files (930120), shell paths (932160) and command names (932260):
|
||||
4. `Referer` is not checked for a Unix command given without arguments (932340),
|
||||
for Java starting a process (944110) or for a Java class name (944130).
|
||||
`Referer` names the page a request comes from, on everything that page loads
|
||||
and on every link followed from it. The Core Rule Set takes some page
|
||||
addresses for attacks:
|
||||
- a search for `env`;
|
||||
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
||||
- a file named `java.lang.String.md`.
|
||||
|
||||
Every request from such a page would be refused. `Referer` keeps the other
|
||||
rules, except after some gitea searches (gitea's fixes). An app that passes
|
||||
`Referer` to a shell must check it itself.
|
||||
|
||||
5. Responses are not inspected. A raw file from a repository, such as a shell
|
||||
script, looks to the response rules like source code leaking from the server.
|
||||
6. The `from` parameter of a container registry's upload path,
|
||||
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
||||
a push reuses a layer, `from` names the image the layer comes from. An image
|
||||
such as `owner/python3-app` would have that push refused.
|
||||
|
||||
Each supported app also has its own fixes, which let its ordinary requests
|
||||
through. `SWW_APPS` says which hostnames get which app's fixes:
|
||||
|
||||
- `SWW_APPS` holds entries separated by semicolons. Each is a hostname, a colon,
|
||||
then one or more apps separated by commas, for example
|
||||
`git.example.com:gitea;blog.example.com:wordpress`.
|
||||
- The request's `Host` is matched by exact name, without its port and without
|
||||
regard to case. There are no wildcards.
|
||||
- A hostname not listed gets no app's fixes, so every rule checks it in full.
|
||||
Set empty, `SWW_APPS` lists no hostname.
|
||||
- Unset, every app's fixes hold for every hostname. An app's fixes then weaken
|
||||
the check of any other app that serves the same paths or fields.
|
||||
- Any of these mistakes stops the start:
|
||||
- an unknown app;
|
||||
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||
- a hostname listed twice;
|
||||
- an app listed twice for one hostname;
|
||||
- an entry without a colon.
|
||||
|
||||
The supported apps are:
|
||||
|
||||
- `drupal`;
|
||||
- `gitea`;
|
||||
- `mastodon`;
|
||||
- `mediawiki`;
|
||||
- `nextcloud`;
|
||||
- `vaultwarden`;
|
||||
- `wordpress`.
|
||||
|
||||
Each app's fixes are in one file, such as `internal/coreruleset/gitea.conf`, and
|
||||
one entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
||||
|
||||
1. Ten parameters are not checked against the Core Rule Set's lists of system
|
||||
files (930120), shell paths (932160) and command names (932260), on any path:
|
||||
- `path`, `files`, `skip-to` and `sub_path`;
|
||||
- `ref`, `sha`, `branch` and `workflow`;
|
||||
- `artifactName` and `redirect_to`.
|
||||
|
||||
They skip these rules in the query, and in a form data or multipart body
|
||||
while `SWW_WAF_BODY_LIMIT` is set.
|
||||
|
||||
gitea puts into them file paths, branch and workflow names, and the page to
|
||||
return to after signing in. It reads these values as names in a repository
|
||||
or in its own records, or as a page of its own site, never as a command. Any
|
||||
@@ -767,43 +813,19 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
or `docker-build`. In these parameters, what only these three rules would
|
||||
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||
still refused there. Other parameters keep all three rules. The eighth to
|
||||
fourteenth 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
|
||||
the server, or passes it to a shell, must check the value itself. A rule
|
||||
file can refuse it instead.
|
||||
still refused there. Other parameters keep all three rules, except on the
|
||||
paths named in gitea's third fix and in the other apps' fixes.
|
||||
|
||||
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
||||
- The Core Rule Set reads the request without the `gitea_flash` and
|
||||
2. The Core Rule Set reads the request without the `gitea_flash` and
|
||||
`redirect_to` cookies. gitea puts a message naming what was just done into
|
||||
`gitea_flash`. It puts into `redirect_to` the page to return to after
|
||||
signing in. The Core Rule Set takes file names in them, such as
|
||||
`package.json`, for attacks. A browser sends such a cookie with every
|
||||
request until gitea replaces it. gitea cannot replace it while those
|
||||
requests are refused, so one refusal would keep the browser out of the
|
||||
whole site. gitea shows the message with any script removed and returns
|
||||
only to its own pages. Every other cookie is read in full. An app that
|
||||
reads a cookie of either name must check it itself.
|
||||
- `Referer` is not checked for a Unix command given without arguments
|
||||
(932340), for Java starting a process (944110) or for a Java class name
|
||||
(944130). `Referer` names the page a request comes from, on everything
|
||||
that page loads and on every link followed from it. The Core Rule Set
|
||||
takes some page addresses for attacks:
|
||||
- a search for `env`;
|
||||
- OpenJDK's `Runtime.java`, whose path holds both `runtime` and `java.`;
|
||||
- a file named `java.lang.String.md`.
|
||||
|
||||
Every request from such a page would be refused. `Referer` keeps the
|
||||
other rules, except after some gitea searches (the eighth change). An
|
||||
app that passes `Referer` to a shell must check it itself.
|
||||
|
||||
6. Responses are not inspected. A raw file from a repository, such as a shell
|
||||
script, looks to the response rules like source code leaking from the server.
|
||||
7. The `from` parameter of a container registry's upload path,
|
||||
`/v2/<image>/blobs/uploads/`, is not checked for command names (932260). When
|
||||
a push reuses a layer, `from` names the image the layer comes from. An image
|
||||
such as `owner/python3-app` would have that push refused.
|
||||
8. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
||||
`gitea_flash`. It puts into `redirect_to` the page to return to after signing
|
||||
in. The Core Rule Set takes file names in them, such as `package.json`, for
|
||||
attacks. A browser sends such a cookie with every request until gitea
|
||||
replaces it. gitea cannot replace it while those requests are refused, so one
|
||||
refusal would keep the browser out of the whole site. gitea shows the message
|
||||
with any script removed and returns only to its own pages. Every other cookie
|
||||
is read in full.
|
||||
3. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
||||
requests there. They are the fields where gitea sends a search, a file name
|
||||
or a branch name. `internal/coreruleset/gitea.conf` lists each rule and the
|
||||
request it refused. The fields are:
|
||||
@@ -821,8 +843,7 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- packages.
|
||||
|
||||
A user's or an organisation's page is any path of one segment, so on
|
||||
every such path, on any app, `q` skips these three rules, for example on
|
||||
`/search`.
|
||||
every such path `q` skips these three rules, for example on `/search`.
|
||||
|
||||
Some of these searches also skip the rule for Java class names (944130),
|
||||
which refused `org.apache.commons.logging`.
|
||||
@@ -860,43 +881,41 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
`actions/upload-artifact@v3` (920450).
|
||||
|
||||
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
||||
site root. An app other than gitea that serves one of them must check these
|
||||
fields itself.
|
||||
site root.
|
||||
|
||||
WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
||||
ordinary WordPress requests there. `internal/coreruleset/wordpress.conf` lists
|
||||
each rule and the request it refused. The REST API has four addresses:
|
||||
|
||||
9. On WordPress's paths, some fields skip the rules that refused ordinary
|
||||
WordPress requests there. `internal/coreruleset/wordpress.conf` lists each
|
||||
rule and the request it refused. The REST API has four addresses:
|
||||
- `/wp-json/...`
|
||||
- `/index.php/wp-json/...`
|
||||
- `/index.php?rest_route=/...`
|
||||
- `/?rest_route=/...`
|
||||
|
||||
The fields are:
|
||||
- The header `X-HTTP-Method-Override` on any address of the REST API
|
||||
(920450). The block editor saves a post with `POST` and names `PUT` in
|
||||
this header.
|
||||
- Three groups of fields skip the rules on system files and commands
|
||||
(930120, 932120, 932250, 932260). The fields hold searches, names and
|
||||
addresses of WordPress's own pages, and any of them can hold any word. The
|
||||
groups are:
|
||||
|
||||
- The header `X-HTTP-Method-Override` on any address of the REST API (920450).
|
||||
The block editor saves a post with `POST` and names `PUT` in this header.
|
||||
- Three groups of fields skip the rules on system files and commands (930120,
|
||||
932120, 932250, 932260). The fields hold searches, names and addresses of
|
||||
WordPress's own pages, and any of them can hold any word. The groups are:
|
||||
- On all of `/wp-admin/` and on `/wp-login.php`:
|
||||
- the searches `s`, `search`, `q` and `term`;
|
||||
- the names `tag`, `category_name`, `plugin`, `checked[]`, `theme`
|
||||
and `stylesheet`;
|
||||
- the names `tag`, `category_name`, `plugin`, `checked[]`, `theme` and
|
||||
`stylesheet`;
|
||||
- the name of a plugin's settings page in `page`;
|
||||
- the addresses `url`, `return`, `redirect_to`, `wp_http_referer`
|
||||
and `_wp_http_referer`.
|
||||
- the addresses `url`, `return`, `redirect_to`, `wp_http_referer` and
|
||||
`_wp_http_referer`.
|
||||
- On every REST API route under `/wp/v2/` and on the route
|
||||
`/oembed/1.0/embed`: `search`, `term`, `slug` and `url`.
|
||||
- On `/`, `/index.php`, `/page/N/` and `/index.php/page/N/`: `s` and
|
||||
`tag`.
|
||||
- On `/`, `/index.php`, `/page/N/` and `/index.php/page/N/`: `s` and `tag`.
|
||||
|
||||
The addresses of the first group also skip the rule on Unix commands
|
||||
(932235), because an address repeats the search it came from. The other
|
||||
fields of the groups keep that rule.
|
||||
|
||||
- On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists
|
||||
of PowerShell commands and command names (932120, 932260):
|
||||
- On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists of
|
||||
PowerShell commands and command names (932120, 932260):
|
||||
- the name of a template in `p` and `postId` of the site editor;
|
||||
- the name of a plugin to install in `slug`.
|
||||
|
||||
@@ -904,42 +923,40 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
|
||||
- `url` on the REST API routes `/wp-block-editor/v1/url-details` and
|
||||
`/oembed/1.0/proxy`, for the lists of system files and PowerShell commands
|
||||
(930120, 932120). The block editor's link previews and embeds send an
|
||||
address in this field.
|
||||
(930120, 932120). The block editor's link previews and embeds send an address
|
||||
in this field.
|
||||
|
||||
- `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given
|
||||
without arguments (932340). The media list's own address ends in `&ps`.
|
||||
- `file` on `/wp-admin/plugin-editor.php` and `/wp-admin/theme-editor.php`,
|
||||
for the lists of system files, shell paths, command names and JavaScript
|
||||
names (930120, 932120, 932160, 932260, 941180).
|
||||
- These fields of a body. Each applies only when the query has no parameter
|
||||
of that name, so it matters only while `SWW_WAF_BODY_LIMIT` is set:
|
||||
- `content` of a post, a page, a template or a template part saved
|
||||
through the REST API. It holds HTML and any code it shows.
|
||||
- `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given without
|
||||
arguments (932340). The media list's own address ends in `&ps`.
|
||||
- `file` on `/wp-admin/plugin-editor.php` and `/wp-admin/theme-editor.php`, for
|
||||
the lists of system files, shell paths, command names and JavaScript names
|
||||
(930120, 932120, 932160, 932260, 941180).
|
||||
- These fields of a body. Each applies only when the query has no parameter of
|
||||
that name, so it matters only while `SWW_WAF_BODY_LIMIT` is set:
|
||||
- `content` of a post, a page, a template or a template part saved through
|
||||
the REST API. It holds HTML and any code it shows.
|
||||
- `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,
|
||||
which is PHP.
|
||||
- `newcontent` of a plugin's or a theme's file saved from its editor, which
|
||||
is PHP.
|
||||
- `pwd` when signing in, for the list of system files (930120).
|
||||
- `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
|
||||
(930120, 932120, 932250, 932260).
|
||||
- `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for
|
||||
the lists of PowerShell commands and command names (932120, 932260).
|
||||
- `url` on `/wp-comments-post.php`, the address of a commenter's site,
|
||||
for the lists of system files and PowerShell commands (930120,
|
||||
932120).
|
||||
- `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 (930120,
|
||||
932120, 932250, 932260).
|
||||
- `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for the
|
||||
lists of PowerShell commands and command names (932120, 932260).
|
||||
- `url` on `/wp-comments-post.php`, the address of a commenter's site, for
|
||||
the lists of system files and PowerShell commands (930120, 932120).
|
||||
|
||||
Elsewhere this change does not apply. The paths are those of WordPress at
|
||||
the site root. An app other than WordPress that serves one of them must
|
||||
check these fields itself.
|
||||
Elsewhere WordPress's fixes do not apply. The paths are those of WordPress at
|
||||
the site root.
|
||||
|
||||
10. On Drupal's paths, some fields skip the rules that refused ordinary Drupal
|
||||
requests there. `internal/coreruleset/drupal.conf` lists each rule and the
|
||||
request it refused. These query fields skip the lists of system files and
|
||||
command names (930120, 932120, 932250, 932260):
|
||||
- On every page under `/admin`: `title`, `name`, `user`, `filename`,
|
||||
`filemime`, `info`, `subject`, `author_name`, `search`, `q` and
|
||||
`destination`.
|
||||
Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
||||
ordinary Drupal requests there. `internal/coreruleset/drupal.conf` lists each
|
||||
rule and the request it refused. These query fields skip the lists of system
|
||||
files and command names (930120, 932120, 932250, 932260):
|
||||
|
||||
- On every page under `/admin`: `title`, `name`, `user`, `filename`, `filemime`,
|
||||
`info`, `subject`, `author_name`, `search`, `q` and `destination`.
|
||||
- `destination` on the pages Drupal links to with one, such as these:
|
||||
- `/node/1/edit`;
|
||||
- `/user/login`;
|
||||
@@ -952,16 +969,16 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `/ckeditor5/{editor}/entity-link-suggestions`;
|
||||
- `/block-category/autocomplete`.
|
||||
- Every `filter[...]` field on `/jsonapi/`.
|
||||
- `name` on `/user/password`, `/media-library` and `/views/ajax`, in the
|
||||
query or a body.
|
||||
- `name` on `/user/password`, `/media-library` and `/views/ajax`, in the query
|
||||
or a body.
|
||||
|
||||
`destination` also skips 932230 and 932235, because it holds the address of
|
||||
a page of results. `url` on `/media/oembed`, the address of a video, skips
|
||||
only 930120 and 932120.
|
||||
`destination` also skips 932230 and 932235, because it holds the address of a
|
||||
page of results. `url` on `/media/oembed`, the address of a video, skips only
|
||||
930120 and 932120.
|
||||
|
||||
These fields of a body skip the rules that refused them:
|
||||
- On the node, comment and media forms, for 930120, 932120, 932250 and
|
||||
932260:
|
||||
|
||||
- On the node, comment and media forms, for 930120, 932120, 932250 and 932260:
|
||||
- the title;
|
||||
- the summary;
|
||||
- the tags;
|
||||
@@ -984,32 +1001,32 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `description.value`.
|
||||
- The title saved through Drupal's JSON format on `/node/{id}`, for the same
|
||||
four rules.
|
||||
- The body and comment body on the node and comment forms and through
|
||||
JSON:API, which hold HTML and any code it shows.
|
||||
- The body and comment body on the node and comment forms and through JSON:API,
|
||||
which hold HTML and any code it shows.
|
||||
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
||||
- `name` when resetting a password through Drupal's JSON format on
|
||||
`/user/password`, for the same four rules.
|
||||
- `mail` in that same reset, for 932120 and 932260 only.
|
||||
- `name` when signing up, editing an account and commenting without an
|
||||
account, for the same four rules.
|
||||
- `name` when signing up, editing an account and commenting without an account,
|
||||
for the same four rules.
|
||||
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
||||
|
||||
These exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies
|
||||
when the query has a parameter of the same name.
|
||||
|
||||
Elsewhere these fields keep every rule. The paths are those of Drupal at the
|
||||
site root, with or without a language prefix such as `/fr`. Such a prefix is
|
||||
any first path segment of two or three lowercase letters, optionally
|
||||
followed by a hyphen and more letters, so `/api` counts too. An app other
|
||||
than Drupal that serves one of them must check these fields itself.
|
||||
site root, with or without a language prefix such as `/fr`. Such a prefix is any
|
||||
first path segment of two or three lowercase letters, optionally followed by a
|
||||
hyphen and more letters, so `/api` counts too.
|
||||
|
||||
11. On MediaWiki's paths, some fields skip the rules that refused ordinary
|
||||
MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists each
|
||||
rule and the request it refused. These fields skip the lists of system files
|
||||
and command names (930120, 932120, 932250, 932260), in the query or the
|
||||
MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that refused
|
||||
ordinary MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists
|
||||
each rule and the request it refused. These fields skip the lists of system
|
||||
files and command names (930120, 932120, 932250, 932260), in the query or the
|
||||
body:
|
||||
- On MediaWiki's pages, which are `/index.php` and the paths under
|
||||
`/index.php/` and `/wiki/`:
|
||||
|
||||
- On MediaWiki's pages, which are `/index.php` and the paths under `/index.php/`
|
||||
and `/wiki/`:
|
||||
- the searches `search` and `ilsearch`;
|
||||
- the titles `title`, `page` and `target`;
|
||||
- `from`, `to` and `prefix`, which limit a list of pages;
|
||||
@@ -1021,12 +1038,12 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- the searches `search`, `srsearch` and `pssearch`;
|
||||
- the titles `titles`, `title`, `page` and `bltitle`;
|
||||
- `from` and `to`, the titles of a page being moved;
|
||||
- the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`,
|
||||
`aiprefix`, `aufrom` and `auprefix`;
|
||||
- the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`, `aiprefix`,
|
||||
`aufrom` and `auprefix`;
|
||||
- `letitle`, the page whose log is listed;
|
||||
- `euquery`, an address;
|
||||
- the fields a generator sends in place of the fields above, each with a
|
||||
`g` in front:
|
||||
- the fields a generator sends in place of the fields above, each with a `g`
|
||||
in front:
|
||||
- `gsrsearch` and `gpssearch`;
|
||||
- `gapfrom`, `gapto` and `gapprefix`;
|
||||
- `gaifrom` and `gaiprefix`;
|
||||
@@ -1037,32 +1054,32 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `lgname`, `lgpassword`, `username` and `password`, for signing in.
|
||||
- `q` on `/rest.php/` and the paths under it.
|
||||
|
||||
`modules` on `/load.php` skips 930120 alone, because it lists the scripts
|
||||
and styles a page loads.
|
||||
`modules` on `/load.php` skips 930120 alone, because it lists the scripts and
|
||||
styles a page loads.
|
||||
|
||||
These fields of a body skip the rules that refused them:
|
||||
|
||||
- On MediaWiki's pages, for the same four rules:
|
||||
- `wpSummary`, the summary of an edit;
|
||||
- `wpUploadDescription`, the description of an uploaded file;
|
||||
- `wpName` and `wpPassword`, for signing in;
|
||||
- `wpOldTitle`, `wpNewTitleMain` and `wpReason`, for moving a page.
|
||||
- `comment` in the JSON body of `/rest.php/`, for the same four rules.
|
||||
- On MediaWiki's pages, for 930120 and 932120 alone, the reasons on the
|
||||
admin pages:
|
||||
- On MediaWiki's pages, for 930120 and 932120 alone, the reasons on the admin
|
||||
pages:
|
||||
- `wpReason-other`, for blocking a user;
|
||||
- `user-reason`, for changing a user's groups;
|
||||
- `mwProtect-reason`, for protecting a page.
|
||||
- On MediaWiki's pages, `wpTitles`, the list of watched pages that
|
||||
`Special:EditWatchlist/raw` sends, one title per line. It always skips
|
||||
930120, 932120 and 932250. It skips 932125, 932230, 932235, 932260 and
|
||||
932380 only when it holds no `;`, `|`, `&`, backtick or `{`. A title
|
||||
starting with a command name, such as `Python3 notes`, passes. So does a
|
||||
command alone on a line, such as `wget http://example.com/x.sh`. A command
|
||||
such as `; wget http://example.com/x.sh` is still refused. So is a list
|
||||
that mixes a title holding one of these characters with a title starting
|
||||
with a command name.
|
||||
- The wikitext of a page, which holds templates, tables and any code it
|
||||
shows:
|
||||
`Special:EditWatchlist/raw` sends, one title per line. It always skips 930120,
|
||||
932120 and 932250. It skips 932125, 932230, 932235, 932260 and 932380 only
|
||||
when it holds no `;`, `|`, `&`, backtick or `{`. A title starting with a
|
||||
command name, such as `Python3 notes`, passes. So does a command alone on a
|
||||
line, such as `wget http://example.com/x.sh`. A command such as
|
||||
`; wget http://example.com/x.sh` is still refused. So is a list that mixes a
|
||||
title holding one of these characters with a title starting with a command
|
||||
name.
|
||||
- The wikitext of a page, which holds templates, tables and any code it shows:
|
||||
- `wpTextbox1` on MediaWiki's pages;
|
||||
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
||||
- `source` in the JSON body of `/rest.php/`.
|
||||
@@ -1070,34 +1087,35 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||
applies when the query has a parameter of the same name.
|
||||
|
||||
The names of uploaded files skip 932180 on MediaWiki's pages and on
|
||||
`/api.php`, so a picture named `Notes on .htaccess.png` can be uploaded.
|
||||
MediaWiki refuses a file whose extension it does not allow.
|
||||
The names of uploaded files skip 932180 on MediaWiki's pages and on `/api.php`,
|
||||
so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a
|
||||
file whose extension it does not allow.
|
||||
|
||||
`/index.php` itself counts as MediaWiki's only when its query has a `title`,
|
||||
as in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A
|
||||
wiki without short URLs serves its pages under `/index.php/`, as in
|
||||
`/index.php` itself counts as MediaWiki's only when its query has a `title`, as
|
||||
in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A wiki
|
||||
without short URLs serves its pages under `/index.php/`, as in
|
||||
`/index.php/Main_Page`, and its sign-in and upload forms post there.
|
||||
|
||||
Elsewhere these fields keep every rule. The paths are those of MediaWiki at
|
||||
the site root or under `/w/`. An app other than MediaWiki that serves one of
|
||||
them must check these fields itself.
|
||||
Elsewhere these fields keep every rule. The paths are those of MediaWiki at the
|
||||
site root or under `/w/`.
|
||||
|
||||
Mastodon's fixes: on Mastodon's paths, some fields skip the rules that refused
|
||||
ordinary Mastodon requests there. `internal/coreruleset/mastodon.conf` lists
|
||||
each rule and the request it refused. These fields skip the lists of system
|
||||
files and command names (930120, 932120, 932250, 932260), in the query:
|
||||
|
||||
12. On Mastodon's paths, some fields skip the rules that refused ordinary
|
||||
Mastodon requests there. `internal/coreruleset/mastodon.conf` lists each
|
||||
rule and the request it refused. These fields skip the lists of system files
|
||||
and command names (930120, 932120, 932250, 932260), in the query:
|
||||
- On the API's search, accounts and admin accounts, under `/api/v1/` and
|
||||
`/api/v2/`, the searches and names `q`, `acct`, `username`,
|
||||
`display_name`, `email` and `by_domain`.
|
||||
`/api/v2/`, the searches and names `q`, `acct`, `username`, `display_name`,
|
||||
`email` and `by_domain`.
|
||||
- On `/admin/accounts`, `/admin/instances`, `/admin/custom_emojis` and
|
||||
`/admin/tags`, the searches `username`, `display_name`, `email`,
|
||||
`by_domain`, `shortcode` and `name`.
|
||||
`/admin/tags`, the searches `username`, `display_name`, `email`, `by_domain`,
|
||||
`shortcode` and `name`.
|
||||
- `q` on `/search`, the web app's search page.
|
||||
|
||||
These fields of a body skip the same four rules:
|
||||
- On these API resources, under `/api/v1/` or `/api/v2/`, and the paths
|
||||
under them:
|
||||
|
||||
- On these API resources, under `/api/v1/` or `/api/v2/`, and the paths under
|
||||
them:
|
||||
- `statuses`, `media` and `reports`;
|
||||
- `accounts` and `profile`;
|
||||
- `lists`, `filters` and `featured_tags`;
|
||||
@@ -1105,12 +1123,12 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- On those resources, these fields:
|
||||
- `spoiler_text` and `poll[options][]`, for posting;
|
||||
- `description`, of an uploaded file;
|
||||
- `display_name`, `note` and the names and values of
|
||||
`fields_attributes`, for a profile;
|
||||
- `title`, `keyword`, the keywords of `keywords_attributes` and `name`,
|
||||
for lists, filters and hashtags;
|
||||
- `comment`, `text`, `private_comment` and `public_comment`, for
|
||||
reports, notes, warnings and blocks;
|
||||
- `display_name`, `note` and the names and values of `fields_attributes`,
|
||||
for a profile;
|
||||
- `title`, `keyword`, the keywords of `keywords_attributes` and `name`, for
|
||||
lists, filters and hashtags;
|
||||
- `comment`, `text`, `private_comment` and `public_comment`, for reports,
|
||||
notes, warnings and blocks;
|
||||
- in JSON, as the web app sends them:
|
||||
- `spoiler_text`, `title` and the options of `poll`;
|
||||
- `description`, and the descriptions in `media_attributes`;
|
||||
@@ -1149,14 +1167,13 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `user[password]`, `user[password_confirmation]` and
|
||||
`user[current_password]`;
|
||||
- `user[invite_request_attributes][text]`, why someone asks to sign up;
|
||||
- `account_migration[current_password]`,
|
||||
`form_redirect[current_password]` and
|
||||
`form_delete_confirmation[password]`, for moving or deleting an
|
||||
- `account_migration[current_password]`, `form_redirect[current_password]`
|
||||
and `form_delete_confirmation[password]`, for moving or deleting an
|
||||
account.
|
||||
- On `/api/v1/accounts`, signing up through the API, `password` and
|
||||
`reason`, in form data or JSON.
|
||||
- On `/inbox`, `/users/<name>/inbox` and `/ap/users/<id>/inbox`, the fields
|
||||
of the object another server delivers in JSON:
|
||||
- On `/api/v1/accounts`, signing up through the API, `password` and `reason`, in
|
||||
form data or JSON.
|
||||
- On `/inbox`, `/users/<name>/inbox` and `/ap/users/<id>/inbox`, the fields of
|
||||
the object another server delivers in JSON:
|
||||
- `content` and `contentMap`, a post;
|
||||
- `summary` and `summaryMap`, a content warning or a profile's text;
|
||||
- `name`, a profile's name;
|
||||
@@ -1168,34 +1185,36 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
commands in code. A shell command such as `; wget http://example.com/x.sh`
|
||||
reaches Mastodon there.
|
||||
|
||||
Two fields of a body skip only 932120 and 932260. A hashtag or a username
|
||||
holds only letters, digits and underscores, so the other two rules do not
|
||||
misfire there:
|
||||
- `name` in JSON on `/api/v1/featured_tags`, a hashtag the web app features
|
||||
on a profile;
|
||||
- `user[account_attributes][username]` on `/auth`, the sign-up form's
|
||||
username.
|
||||
Two fields of a body skip only 932120 and 932260. A hashtag or a username holds
|
||||
only letters, digits and underscores, so the other two rules do not misfire
|
||||
there:
|
||||
|
||||
`user[email]` on `/auth` and the paths under it skips 930120, 932120
|
||||
and 932260. The sign-up, sign-in, password reset and account settings forms
|
||||
send an email address there, such as `python3@example.com`. An address holds
|
||||
no spaces, so it keeps 932250.
|
||||
- `name` in JSON on `/api/v1/featured_tags`, a hashtag the web app features on a
|
||||
profile;
|
||||
- `user[account_attributes][username]` on `/auth`, the sign-up form's username.
|
||||
|
||||
`user[email]` on `/auth` and the paths under it skips 930120, 932120 and 932260.
|
||||
The sign-up, sign-in, password reset and account settings forms send an email
|
||||
address there, such as `python3@example.com`. An address holds no spaces, so it
|
||||
keeps 932250.
|
||||
|
||||
On `/api/v1/reports`, the JSON keys `forward` and `forward_to_domains` skip
|
||||
930120 by their names. A report forwarded to another server lists that
|
||||
server in `forward_to_domains`. In the Core Rule Set the keys are named:
|
||||
930120 by their names. A report forwarded to another server lists that server in
|
||||
`forward_to_domains`. In the Core Rule Set the keys are named:
|
||||
|
||||
- `json.forward`;
|
||||
- `json.forward_to_domains`;
|
||||
- `json.forward_to_domains.0`, `json.forward_to_domains.1` and so on, one
|
||||
per server.
|
||||
- `json.forward_to_domains.0`, `json.forward_to_domains.1` and so on, one per
|
||||
server.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||
applies when the query has a parameter of the same name.
|
||||
|
||||
The names of uploaded files skip 932180 on these paths:
|
||||
|
||||
- `/api/v1/media` and `/api/v2/media`;
|
||||
- `/api/v1/accounts/update_credentials` and `/api/v1/profile`.
|
||||
|
||||
@@ -1203,35 +1222,34 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
file of a type it does not allow.
|
||||
|
||||
The `Accept` header skips 920600 on links to profiles and posts, such as
|
||||
`/@admin` and `/@admin/117589811962372311`. It skips it on `/actor` and on
|
||||
the paths under `/users/` and `/ap/users/` too. When someone on another
|
||||
server looks up such a link, that server fetches it with this header:
|
||||
`/@admin` and `/@admin/117589811962372311`. It skips it on `/actor` and on the
|
||||
paths under `/users/` and `/ap/users/` too. When someone on another server looks
|
||||
up such a link, that server fetches it with this header:
|
||||
`Accept: application/ld+json; profile="https://www.w3.org/ns/activitystreams", application/activity+json, text/html;q=0.1`.
|
||||
It sends the same header to the account or post the link leads to.
|
||||
|
||||
Gitea serves `/api/v1/` and Drupal serves `/admin/`. So only Mastodon's own
|
||||
resources under them count as Mastodon's paths. Elsewhere these fields keep
|
||||
every rule. An app other than Mastodon that serves one of these paths must
|
||||
check these fields itself.
|
||||
every rule.
|
||||
|
||||
13. On Vaultwarden's paths, some fields skip the rules that refused ordinary
|
||||
Vaultwarden requests there. `internal/coreruleset/vaultwarden.conf` lists
|
||||
each rule and the request it refused. The apps encrypt vault items, folders,
|
||||
collections and sends before sending them, so those fields skip no rules.
|
||||
The fields below hold plain text.
|
||||
Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that
|
||||
refused ordinary Vaultwarden requests there.
|
||||
`internal/coreruleset/vaultwarden.conf` lists each rule and the request it
|
||||
refused. The apps encrypt vault items, folders, collections and sends before
|
||||
sending them, so those fields skip no rules. The fields below hold plain text.
|
||||
|
||||
Names and password hints skip the lists of system files and command names
|
||||
(930120, 932120, 932250, 932260):
|
||||
- `name`, the name of the account, an organization, a group or a security
|
||||
key;
|
||||
|
||||
- `name`, the name of the account, an organization, a group or a security key;
|
||||
- `masterPasswordHint`, when signing up or changing the master password;
|
||||
- `invitation_org_name` and `smtp_from_name`, on the admin page's settings.
|
||||
|
||||
These fields hold email addresses and skip 930120, 932120 and 932260:
|
||||
|
||||
- `username`, when signing in at `/identity/connect/token`;
|
||||
- `email`, `newEmail`, `billingEmail` and the invited `emails`;
|
||||
- the salt fields, such as `masterPasswordUnlock.salt`, which hold the
|
||||
address;
|
||||
- the salt fields, such as `masterPasswordUnlock.salt`, which hold the address;
|
||||
- `accountUnlockData.masterPasswordUnlockData.email`, when rotating the
|
||||
account's encryption key;
|
||||
- `smtp_from`, `smtp_username` and `org_creation_users`, on the admin page's
|
||||
@@ -1240,19 +1258,21 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
So `python3@example.com` and `powershell@example.com` pass.
|
||||
|
||||
These fields skip the rules on these paths:
|
||||
|
||||
- the paths under `/identity/accounts/`;
|
||||
- `/api/accounts/`, `/api/emergency-access/` and `/api/organizations`, and
|
||||
the paths under them;
|
||||
- `/api/accounts/`, `/api/emergency-access/` and `/api/organizations`, and the
|
||||
paths under them;
|
||||
- `/admin/config`.
|
||||
|
||||
Names and password hints skip them on `/api/two-factor/webauthn` too, where
|
||||
a security key gets its name.
|
||||
Names and password hints skip them on `/api/two-factor/webauthn` too, where a
|
||||
security key gets its name.
|
||||
|
||||
The addresses skip them on these paths too:
|
||||
|
||||
- `/identity/connect/token` and `/admin/invite`;
|
||||
- `/admin/test/smtp`, where the admin page sends a test email;
|
||||
- the paths under `/api/two-factor/`, where sign-in codes by email are set
|
||||
up and sent;
|
||||
- the paths under `/api/two-factor/`, where sign-in codes by email are set up
|
||||
and sent;
|
||||
- `/api/auth-requests` and the paths under it, for signing in with another
|
||||
device.
|
||||
|
||||
@@ -1262,47 +1282,50 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
`username` in the query of `/api/hibp/breach`, the data breach report, skips
|
||||
930120, 932120 and 932260 too.
|
||||
|
||||
The names of uploaded files skip 920120 on the paths that take attachments
|
||||
and file sends:
|
||||
The names of uploaded files skip 920120 on the paths that take attachments and
|
||||
file sends:
|
||||
|
||||
- `/api/ciphers/<id>/attachment` and the paths under it;
|
||||
- `/api/ciphers/<id>/attachment-admin`;
|
||||
- `/api/sends/<id>/file/<file id>`.
|
||||
|
||||
The apps name such a file by its encrypted name, which holds `=`. Path
|
||||
traversal and PHP scripts in the names are still refused.
|
||||
The apps name such a file by its encrypted name, which holds `=`. Path traversal
|
||||
and PHP scripts in the names are still refused.
|
||||
|
||||
Elsewhere these fields keep every rule. An app other than Vaultwarden that
|
||||
serves one of these paths must check these fields itself.
|
||||
Elsewhere these fields keep every rule.
|
||||
|
||||
14. On Nextcloud's paths, some fields skip the rules that refused ordinary
|
||||
Nextcloud requests there, and its WebDAV methods are allowed.
|
||||
Nextcloud's fixes: 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.
|
||||
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.
|
||||
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.
|
||||
@@ -1317,72 +1340,74 @@ The changes hold in front of every app, and no setting undoes them:
|
||||
- `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.
|
||||
- `value` on `/apps/theming/ajax/updateStylesheet`, the name and slogan in the
|
||||
theming settings.
|
||||
- `filePath` on `/apps/text/session/<id>/save`, the document the editor saves.
|
||||
- `name` on `/remote.php/dav/systemtags/`, a new tag.
|
||||
|
||||
Some fields hold 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;
|
||||
|
||||
- `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.
|
||||
- `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.
|
||||
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.
|
||||
The XML body of a `SEARCH` on `/remote.php/dav/`, the Files app's search, skips
|
||||
the four rules.
|
||||
|
||||
The Calendar and Contacts apps, and CalDAV and CardDAV clients, use these paths:
|
||||
|
||||
The Calendar and Contacts apps, and CalDAV and CardDAV clients, use these
|
||||
paths:
|
||||
- `/remote.php/dav/calendars/`, which holds the calendars;
|
||||
- `/remote.php/dav/addressbooks/`, which holds the address books.
|
||||
|
||||
There:
|
||||
- `MKCALENDAR`, with which a CalDAV client makes a calendar, is allowed on
|
||||
the calendars' paths only.
|
||||
- The XML body of an `MKCOL` names a new calendar or address book. A new
|
||||
address book's body may also hold its description. The body skips the four
|
||||
rules. On the address books' paths, it also skips two rules that refused a
|
||||
description such as `Powershell and python3 contacts; ls -la first`
|
||||
(932125, 932230).
|
||||
- The XML body of an `MKCALENDAR` holds the new calendar's name and
|
||||
description. It skips the four rules, and two rules that refused a
|
||||
description such as `Powershell and python3 meetings; ls -la first`
|
||||
(932125, 932230).
|
||||
- The body of a `PUT` of an event or a contact is not read, as for any `PUT`
|
||||
on the WebDAV paths. So the Core Rule Set does not check an event's title,
|
||||
|
||||
- `MKCALENDAR`, with which a CalDAV client makes a calendar, is allowed on the
|
||||
calendars' paths only.
|
||||
- The XML body of an `MKCOL` names a new calendar or address book. A new address
|
||||
book's body may also hold its description. The body skips the four rules. On
|
||||
the address books' paths, it also skips two rules that refused a description
|
||||
such as `Powershell and python3 contacts; ls -la first` (932125, 932230).
|
||||
- The XML body of an `MKCALENDAR` holds the new calendar's name and description.
|
||||
It skips the four rules, and two rules that refused a description such as
|
||||
`Powershell and python3 meetings; ls -la first` (932125, 932230).
|
||||
- The body of a `PUT` of an event or a contact is not read, as for any `PUT` on
|
||||
the WebDAV paths. So the Core Rule Set does not check an event's title,
|
||||
location or description, or a contact's names.
|
||||
- The Calendar and Contacts apps name the charset twice in the
|
||||
`Content-Type` of the `PUT` that changes an event or a contact. A `PUT`
|
||||
typed `text/calendar` or `text/vcard` skips 920530, the rule against two
|
||||
charsets.
|
||||
- The Calendar and Contacts apps name the charset twice in the `Content-Type` of
|
||||
the `PUT` that changes an event or a contact. A `PUT` typed `text/calendar` or
|
||||
`text/vcard` skips 920530, the rule against two charsets.
|
||||
|
||||
A CalDAV or CardDAV client finds these paths with a `PROPFIND` on
|
||||
`/.well-known/caldav` or `/.well-known/carddav`. That method is allowed on
|
||||
those two paths.
|
||||
`/.well-known/caldav` or `/.well-known/carddav`. That method is allowed on those
|
||||
two paths.
|
||||
|
||||
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.
|
||||
`/index.php/`. Elsewhere these fields keep every rule.
|
||||
|
||||
The parameter and cookie names in these changes are matched without regard to
|
||||
case, as Coraza matches them.
|
||||
Each app's fixes switch rules off for some of its fields and cookies. Any other
|
||||
app on a hostname that gets those fixes must check those fields and cookies
|
||||
itself.
|
||||
|
||||
The parameter names in these changes and fixes, and Nextcloud's cookie
|
||||
`nc_username`, are matched without regard to case. gitea's two cookies are
|
||||
matched exactly.
|
||||
|
||||
### Ban lengths, response and scope
|
||||
|
||||
|
||||
@@ -255,6 +255,31 @@ the request first.
|
||||
a range. So does a directive that removes or changes rules as the file loads,
|
||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||
yet. It has no `_FILE` form.
|
||||
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
||||
fixes of its hostname's apps only (see "Attack detection" in
|
||||
[`SPEC.md`](../SPEC.md)).
|
||||
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
||||
or more apps separated by commas, for example
|
||||
`git.example.com:gitea;blog.example.com:wordpress`.
|
||||
- The supported apps are:
|
||||
- `drupal`;
|
||||
- `gitea`;
|
||||
- `mastodon`;
|
||||
- `mediawiki`;
|
||||
- `nextcloud`;
|
||||
- `vaultwarden`;
|
||||
- `wordpress`.
|
||||
- The request's `Host` is matched by exact name, without its port and
|
||||
without regard to case. There are no wildcards.
|
||||
- A hostname not listed gets no app's fixes, so every rule checks it in
|
||||
full. Set empty, `SWW_APPS` lists no hostname.
|
||||
- Unset, every app's fixes apply to every hostname.
|
||||
- Any of these mistakes stops the start:
|
||||
- an unknown app;
|
||||
- a hostname that is not a DNS name, such as `*.example.com`;
|
||||
- a hostname listed twice;
|
||||
- an app listed twice for one hostname;
|
||||
- an entry without a colon.
|
||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||
)
|
||||
|
||||
@@ -235,6 +236,11 @@ type Config struct {
|
||||
WAFExemptPaths []string
|
||||
WAFBodyLimit int64
|
||||
CoreRuleSetExclusionsFile string
|
||||
// Apps are the names of the apps each hostname serves, by hostname in
|
||||
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
||||
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
||||
// request gets every app's fixes.
|
||||
Apps map[string][]string
|
||||
// TrapPaths are the paths a request for which is a clear sign of
|
||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||
TrapPaths []string
|
||||
@@ -404,6 +410,11 @@ var (
|
||||
errNotTrapPath = errors.New(
|
||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||
)
|
||||
errNotAppsEntry = errors.New(
|
||||
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
|
||||
)
|
||||
errNotHostname = errors.New("is not a DNS name such as git.example.com")
|
||||
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
|
||||
errNotWAFMode = errors.New("is not off, detect or block")
|
||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||
errNotRuleID = errors.New(
|
||||
@@ -577,6 +588,7 @@ func FromEnvironment(
|
||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||
CoreRuleSetExclusionsFile: env.filePath(
|
||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||
Apps: env.apps("SWW_APPS"),
|
||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||
@@ -832,6 +844,25 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
||||
return prefixes
|
||||
}
|
||||
|
||||
// apps reads the setting that is the apps each hostname serves. It is nil
|
||||
// while the setting is unset, and logged as null, unlike a setting set
|
||||
// empty.
|
||||
func (e *environment) apps(name string) map[string][]string {
|
||||
value, set := e.lookup(name)
|
||||
if !set {
|
||||
e.settings = append(e.settings, slog.Any(name, nil))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
e.settings = append(e.settings, slog.String(name, value))
|
||||
|
||||
apps, err := parseApps(value)
|
||||
e.check(name, err)
|
||||
|
||||
return apps
|
||||
}
|
||||
|
||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||
// by default.
|
||||
func (e *environment) trapPaths(name string) []string {
|
||||
@@ -1849,6 +1880,65 @@ func parseTrapPaths(value string) ([]string, error) {
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
// parseApps reads the apps each hostname serves: entries separated by
|
||||
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
||||
// names coreruleset.AppNames returns, such as
|
||||
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
||||
// hostname is a DNS name, without a wildcard. It returns them by hostname
|
||||
// in lowercase, since a hostname is matched without regard to case. An
|
||||
// empty value lists no hostname.
|
||||
func parseApps(value string) (map[string][]string, error) {
|
||||
apps := map[string][]string{}
|
||||
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
for entry := range strings.SplitSeq(value, ";") {
|
||||
hostname, list, found := strings.Cut(entry, ":")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
if hostname == "" {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
if !isZone(hostname) {
|
||||
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
||||
}
|
||||
|
||||
if _, listed := apps[hostname]; listed {
|
||||
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
||||
}
|
||||
|
||||
names, err := parseList(list)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
||||
}
|
||||
|
||||
for i, name := range names {
|
||||
if !slices.Contains(coreruleset.AppNames(), name) {
|
||||
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
||||
strings.Join(coreruleset.AppNames(), ", "))
|
||||
}
|
||||
|
||||
if slices.Contains(names[:i], name) {
|
||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||
}
|
||||
}
|
||||
|
||||
apps[hostname] = names
|
||||
}
|
||||
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||
// lastSetupRuleID.
|
||||
@@ -2455,7 +2545,8 @@ func MaskZoneKey(zone string) string {
|
||||
return masked + "." + rest
|
||||
}
|
||||
|
||||
// isZone reports whether each label of zone is as parseZones takes it.
|
||||
// isZone reports whether each label of zone is as parseZones takes it. It
|
||||
// also checks the hostnames of SWW_APPS, which are DNS names too.
|
||||
func isZone(zone string) bool {
|
||||
for label := range strings.SplitSeq(zone, ".") {
|
||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||
|
||||
@@ -89,6 +89,7 @@ const (
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||
apps = "SWW_APPS"
|
||||
trapPaths = "SWW_TRAP_PATHS"
|
||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||
@@ -582,6 +583,101 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// gitea is gitea's name in SWW_APPS.
|
||||
const gitea = "gitea"
|
||||
|
||||
func TestApps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
want map[string][]string
|
||||
}{
|
||||
{environment{}, nil},
|
||||
{environment{apps: ""}, map[string][]string{}},
|
||||
{
|
||||
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
||||
map[string][]string{
|
||||
"git.example.com": {gitea}, "cloud.example.com": {gitea},
|
||||
},
|
||||
},
|
||||
{
|
||||
environment{apps: "cloud.example.com:gitea,wordpress"},
|
||||
map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
|
||||
},
|
||||
} {
|
||||
cfg := fromEnvironment(t, tc.env)
|
||||
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
||||
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
||||
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
env environment
|
||||
want string
|
||||
}{
|
||||
{environment{}, `"` + apps + `":null`},
|
||||
{environment{apps: ""}, `"` + apps + `":""`},
|
||||
} {
|
||||
var out bytes.Buffer
|
||||
|
||||
slog.New(slog.NewJSONHandler(&out, nil)).
|
||||
Info("starting", "settings", fromEnvironment(t, tc.env))
|
||||
|
||||
if !strings.Contains(out.String(), tc.want) {
|
||||
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
notEntry = " is not a hostname, : and a list of apps, " +
|
||||
"such as git.example.com:gitea"
|
||||
notHostname = " is not a DNS name such as git.example.com"
|
||||
)
|
||||
|
||||
for _, tc := range []struct{ value, want string }{
|
||||
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
||||
{"git.example.com:gitea;", `""` + notEntry},
|
||||
{":gitea", `":gitea"` + notEntry},
|
||||
{"git.example.com:", `"git.example.com:"` + notEntry},
|
||||
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
||||
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
||||
{"git example.com:gitea", `"git example.com"` + notHostname},
|
||||
{
|
||||
"git.example.com:gitea,jellyfin",
|
||||
`"jellyfin" is not an app sww has fixes for; ` +
|
||||
`the apps are drupal, gitea, mastodon, mediawiki, nextcloud, ` +
|
||||
`vaultwarden, wordpress`,
|
||||
},
|
||||
{
|
||||
"git.example.com:gitea;GIT.example.com:gitea",
|
||||
`"git.example.com" is listed twice`,
|
||||
},
|
||||
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
||||
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
||||
} {
|
||||
t.Run(tc.value, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
||||
|
||||
want := apps + ": " + tc.want
|
||||
if err == nil || err.Error() != want {
|
||||
t.Errorf("error %v, want %s", err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2589,6 +2685,7 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
||||
wafExemptPaths: "",
|
||||
wafBodyLimit: off,
|
||||
exclusionsFile: "",
|
||||
apps: "", // logged as null, which decodes as ""
|
||||
trapPaths: "",
|
||||
errorBurstThreshold: "30",
|
||||
logRemoteURL: "",
|
||||
|
||||
+115
-110
@@ -1,9 +1,9 @@
|
||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||
// method, the URL with its query and the headers of a request, and on its
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the fourteen changes sww
|
||||
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||
// response.
|
||||
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
||||
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
||||
// in SPEC.md describes them. It reads no response.
|
||||
//
|
||||
// sww writes only to its state directory, so Coraza is built with
|
||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
@@ -32,11 +34,8 @@ import (
|
||||
|
||||
// directives are the Core Rule Set as sww runs it, with the
|
||||
// paranoia level for %d, bodyDirectives for the first %s while
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||
// drupalExclusions for the fifth, mediawikiExclusions for the sixth,
|
||||
// mastodonExclusions for the seventh, vaultwardenExclusions for the eighth,
|
||||
// and nextcloudExclusions for the ninth.
|
||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
||||
// directives of every app in apps for the third.
|
||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||
// Core Rule Set keeps for the rules that set it up, which
|
||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||
@@ -84,62 +83,28 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
||||
# before the rules of phase 1 too.
|
||||
%s
|
||||
|
||||
# The eighth change comes before the rules for the same reason.
|
||||
# The apps' fixes come before the rules for the same reason.
|
||||
%s
|
||||
|
||||
# The ninth change comes before the rules for the same reason.
|
||||
%s
|
||||
|
||||
# So does the tenth.
|
||||
%s
|
||||
|
||||
# So does the eleventh.
|
||||
%s
|
||||
|
||||
# So does the twelfth.
|
||||
%s
|
||||
|
||||
# So does the thirteenth.
|
||||
%s
|
||||
|
||||
# And the fourteenth.
|
||||
%s
|
||||
|
||||
# The sixth: only the rules for requests are loaded, and no response is
|
||||
# The fifth: only the rules for requests are loaded, and no response is
|
||||
# inspected.
|
||||
Include @owasp_crs/REQUEST-*.conf
|
||||
|
||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||
# localhost. Coraza matches a parameter name here, and in the fourth,
|
||||
# without regard to case. ARGS holds the fields of a form data or multipart
|
||||
# body Coraza reads as well as the query parameters, so a field of one of
|
||||
# these names is left out too.
|
||||
# localhost. Coraza matches a parameter name here without regard to case.
|
||||
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
||||
# well as the query parameters, so a field of this name is left out too.
|
||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||
|
||||
# The fourth: the query parameters in which gitea sends names within a
|
||||
# repository or its own records, or a page of its own site, are not
|
||||
# checked against the lists of system files, shell paths and command
|
||||
# names. Coraza takes one rule id per directive.
|
||||
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||
!ARGS:artifactName|!ARGS:redirect_to"
|
||||
|
||||
# The fifth, for Referer: it is not checked for a Unix command without
|
||||
# arguments, for Java starting a process, or for a Java class name. 944130
|
||||
# refused every page opened from the page of java.lang.String.md. The
|
||||
# cookies are left out in Inspect.
|
||||
# The fourth: Referer is not checked for a Unix command without arguments,
|
||||
# for Java starting a process, or for a Java class name. 944130 refused
|
||||
# every page opened from the page of java.lang.String.md.
|
||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||
|
||||
# The seventh: a container image push that reuses a layer names the image
|
||||
# The sixth: a container image push that reuses a layer names the image
|
||||
# it takes the layer from in the query parameter from, which is not
|
||||
# checked for command names. 932260 refused the push of a layer from
|
||||
# owner/python3-app.
|
||||
@@ -189,61 +154,61 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||
`
|
||||
|
||||
// giteaExclusions are the eighth change: the fields of gitea's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
||||
// with the fixes that let them pass.
|
||||
type app struct {
|
||||
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
||||
// rules. Their first rule skips to the marker they end with, unless
|
||||
// Inspect sets the transaction variable named for the app, such as
|
||||
// app_gitea. The skip reaches only phase 1, so a rule of a later phase
|
||||
// must check that variable itself.
|
||||
directives string
|
||||
// cookies are the cookies the Core Rule Set reads a request without.
|
||||
cookies []string
|
||||
}
|
||||
|
||||
//go:embed gitea.conf
|
||||
var giteaExclusions string
|
||||
var giteaDirectives string
|
||||
|
||||
// wordpressExclusions are the ninth change: the fields of WordPress's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed wordpress.conf
|
||||
var wordpressExclusions string
|
||||
var wordpressDirectives string
|
||||
|
||||
// drupalExclusions are the tenth change: the fields of Drupal's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed drupal.conf
|
||||
var drupalExclusions string
|
||||
var drupalDirectives string
|
||||
|
||||
// mediawikiExclusions are the eleventh change: the fields of MediaWiki's
|
||||
// own requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed mediawiki.conf
|
||||
var mediawikiExclusions string
|
||||
var mediawikiDirectives string
|
||||
|
||||
// mastodonExclusions are the twelfth change: the fields of Mastodon's own
|
||||
// requests that skip the rules that refused them there, as Coraza
|
||||
// directives.
|
||||
//
|
||||
//go:embed mastodon.conf
|
||||
var mastodonExclusions string
|
||||
var mastodonDirectives string
|
||||
|
||||
// vaultwardenExclusions are the thirteenth change: the fields of
|
||||
// Vaultwarden's own requests that skip the rules that refused them there, as
|
||||
// Coraza directives.
|
||||
//
|
||||
//go:embed vaultwarden.conf
|
||||
var vaultwardenExclusions string
|
||||
var vaultwardenDirectives string
|
||||
|
||||
// nextcloudExclusions are the fourteenth change: the fields of Nextcloud's
|
||||
// own requests that skip the rules that refused them there, the WebDAV
|
||||
// methods it uses, its cookie of the account's name, and its uploads,
|
||||
// whose bodies are not read, as Coraza directives.
|
||||
//
|
||||
//go:embed nextcloud.conf
|
||||
var nextcloudExclusions string
|
||||
var nextcloudDirectives string
|
||||
|
||||
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||
// without, the rest of the fifth change.
|
||||
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
||||
//nolint:gochecknoglobals // a constant cannot be a map
|
||||
var apps = map[string]app{
|
||||
"drupal": {directives: drupalDirectives},
|
||||
"gitea": {
|
||||
directives: giteaDirectives,
|
||||
cookies: []string{"gitea_flash", "redirect_to"},
|
||||
},
|
||||
"mastodon": {directives: mastodonDirectives},
|
||||
"mediawiki": {directives: mediawikiDirectives},
|
||||
"nextcloud": {directives: nextcloudDirectives},
|
||||
"vaultwarden": {directives: vaultwardenDirectives},
|
||||
"wordpress": {directives: wordpressDirectives},
|
||||
}
|
||||
|
||||
// AppNames returns the names of the apps whose fixes sww has, in
|
||||
// alphabetical order.
|
||||
func AppNames() []string {
|
||||
return slices.Sorted(maps.Keys(apps))
|
||||
}
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
@@ -259,6 +224,10 @@ type Params struct {
|
||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||
// there is none.
|
||||
ExclusionsFile string
|
||||
// Apps are the names of the apps each hostname serves, by hostname in
|
||||
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
||||
// apps only, or of every app while Apps is nil.
|
||||
Apps map[string][]string
|
||||
}
|
||||
|
||||
// The ids of sww's own rules, which an exclusions file may not
|
||||
@@ -350,13 +319,15 @@ func checkExclusions(exclusions string) error {
|
||||
type CoreRuleSet struct {
|
||||
engine coraza.WAF
|
||||
bodyLimit int64
|
||||
apps map[string][]string
|
||||
}
|
||||
|
||||
// New returns the Core Rule Set with the fourteen changes, at params'
|
||||
// paranoia level, without the rules it switches off, reading request
|
||||
// bodies up to params' limit, and with params' exclusions file loaded
|
||||
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||
// cannot be read or does not parse, or if checkExclusions refuses it.
|
||||
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
||||
// at params' paranoia level, without the rules it switches off, reading
|
||||
// request bodies up to params' limit, and with params' exclusions file
|
||||
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
||||
// if it cannot be read or does not parse, or if checkExclusions refuses
|
||||
// it.
|
||||
func New(params Params) (*CoreRuleSet, error) {
|
||||
body := ""
|
||||
if params.BodyLimit > 0 {
|
||||
@@ -379,10 +350,13 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var fixes strings.Builder
|
||||
for _, name := range AppNames() {
|
||||
fixes.WriteString(apps[name].directives + "\n")
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||
mediawikiExclusions, mastodonExclusions, vaultwardenExclusions,
|
||||
nextcloudExclusions)
|
||||
fixes.String())
|
||||
|
||||
if len(params.DisabledRules) > 0 {
|
||||
ids := make([]string, len(params.DisabledRules))
|
||||
@@ -400,7 +374,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||
}
|
||||
|
||||
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
||||
return &CoreRuleSet{
|
||||
engine: engine, bodyLimit: params.BodyLimit, apps: params.Apps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Result is what the Core Rule Set found in a request.
|
||||
@@ -412,11 +388,12 @@ type Result struct {
|
||||
Score int
|
||||
}
|
||||
|
||||
// Inspect runs the Core Rule Set on r, a request from client: on its
|
||||
// method, its URL with the query, and its headers, the Cookie header
|
||||
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||
// caller has it, as readBody reads it. It returns what it found, what it
|
||||
// read of body, and the error that ended the reading early, if one did.
|
||||
// Inspect runs the Core Rule Set on r, a request from client, with the
|
||||
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
||||
// with the query, and its headers, the Cookie header without those apps'
|
||||
// cookies, and on body, r's body as the caller has it, as readBody reads
|
||||
// it. It returns what it found, what it read of body, and the error that
|
||||
// ended the reading early, if one did.
|
||||
func (c *CoreRuleSet) Inspect(
|
||||
r *http.Request, client netip.Addr, body io.Reader,
|
||||
) (Result, []byte, error) {
|
||||
@@ -424,13 +401,24 @@ func (c *CoreRuleSet) Inspect(
|
||||
// Closing would remove the files Coraza wrote, and it writes none.
|
||||
defer func() { _ = tx.Close() }()
|
||||
|
||||
// The transaction's variables are set only through Coraza's interface
|
||||
// for plugins.
|
||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
||||
|
||||
var cookiesNotRead []string
|
||||
|
||||
for _, name := range c.appsFor(r.Host) {
|
||||
state.Variables().TX().Set("app_"+name, []string{"1"})
|
||||
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
||||
}
|
||||
|
||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
if name == "Cookie" {
|
||||
value = withoutCookiesNotRead(value)
|
||||
value = withoutCookies(value, cookiesNotRead)
|
||||
if value == "" {
|
||||
continue // it held those cookies alone
|
||||
}
|
||||
@@ -523,9 +511,26 @@ func score(tx types.Transaction) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
||||
// cookies in cookiesNotRead.
|
||||
func withoutCookiesNotRead(value string) string {
|
||||
// appsFor returns the names of the apps whose fixes a request for host,
|
||||
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
||||
// those SWW_APPS lists for host's name, without the port and in
|
||||
// lowercase.
|
||||
func (c *CoreRuleSet) appsFor(host string) []string {
|
||||
if c.apps == nil {
|
||||
return AppNames()
|
||||
}
|
||||
|
||||
hostname, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostname = host // it has no port
|
||||
}
|
||||
|
||||
return c.apps[strings.ToLower(hostname)]
|
||||
}
|
||||
|
||||
// withoutCookies returns value, a Cookie header's, without the cookies
|
||||
// named in cookiesNotRead.
|
||||
func withoutCookies(value string, cookiesNotRead []string) string {
|
||||
var kept []string
|
||||
|
||||
for cookie := range strings.SplitSeq(value, ";") {
|
||||
|
||||
@@ -36,12 +36,14 @@ var loaded = struct {
|
||||
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
||||
|
||||
// loaderFor returns the function that loads the Core Rule Set with params
|
||||
// once, making it the first time params are asked for.
|
||||
// once, making it the first time params are asked for. %#v writes a nil
|
||||
// Apps, which gives every app's fixes, apart from an empty one, which
|
||||
// gives none.
|
||||
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
|
||||
loaded.Lock()
|
||||
defer loaded.Unlock()
|
||||
|
||||
settings := fmt.Sprintf("%+v", params)
|
||||
settings := fmt.Sprintf("%#v", params)
|
||||
|
||||
load, found := loaded.loaders[settings]
|
||||
if !found {
|
||||
@@ -155,9 +157,10 @@ func inspectBody(
|
||||
}
|
||||
|
||||
for _, header := range r.headers {
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers.
|
||||
// Go's server keeps Host and Transfer-Encoding out of the headers,
|
||||
// whatever their case.
|
||||
name, value, _ := strings.Cut(header, ": ")
|
||||
switch name {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case "Host":
|
||||
req.Host = value
|
||||
case "Transfer-Encoding":
|
||||
@@ -202,8 +205,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
||||
}
|
||||
|
||||
// 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
|
||||
// it that the change is not for.
|
||||
// six changes or an app's fixes are for, and still finds result in
|
||||
// refused, a request like it that they are not for.
|
||||
func wantChange(
|
||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||
result coreruleset.Result,
|
||||
@@ -464,6 +467,123 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
||||
matched(930120, 932160))
|
||||
}
|
||||
|
||||
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
||||
// for each kind of fix, each with what the Core Rule Set finds in it
|
||||
// without them.
|
||||
//
|
||||
//nolint:gochecknoglobals // a constant cannot be a list
|
||||
var giteaFixed = []struct {
|
||||
request request
|
||||
result coreruleset.Result
|
||||
}{
|
||||
// A search of code, on the path of gitea's search.
|
||||
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
||||
// A file name, in a parameter gitea sends names in, on any path.
|
||||
{get("/?path=.gitignore"), matched(930120)},
|
||||
// A file name, in a cookie of gitea's.
|
||||
{
|
||||
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
||||
matched(930120),
|
||||
},
|
||||
}
|
||||
|
||||
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
||||
// one that serves another app.
|
||||
const (
|
||||
giteaHost = "git.example"
|
||||
otherHost = "blog.example"
|
||||
)
|
||||
|
||||
// gitea is gitea's name in SWW_APPS.
|
||||
const gitea = "gitea"
|
||||
|
||||
// wordpress is WordPress's name in SWW_APPS.
|
||||
const wordpress = "wordpress"
|
||||
|
||||
// withHost returns r sent to host.
|
||||
func withHost(r request, host string) request {
|
||||
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
||||
}
|
||||
|
||||
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
||||
// giving apps.
|
||||
func servingApps(t *testing.T, apps map[string][]string) *coreruleset.CoreRuleSet {
|
||||
t.Helper()
|
||||
|
||||
return shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, nil)
|
||||
|
||||
for _, fixed := range giteaFixed {
|
||||
for _, host := range []string{giteaHost, otherHost} {
|
||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
||||
|
||||
// The hostname is matched without the port and without regard to case.
|
||||
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
||||
for _, fixed := range giteaFixed {
|
||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, apps := range []map[string][]string{
|
||||
{giteaHost: {gitea}},
|
||||
// SWW_APPS set empty lists no hostname.
|
||||
{},
|
||||
} {
|
||||
crs := servingApps(t, apps)
|
||||
|
||||
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
||||
for _, fixed := range giteaFixed {
|
||||
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {wordpress},
|
||||
})
|
||||
|
||||
// A search, which only WordPress's fixes let through.
|
||||
search := get("/?s=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}})
|
||||
|
||||
// A file name that only gitea's fixes let through, and a search that only
|
||||
// WordPress's fixes let through.
|
||||
for _, target := range []string{"/?path=.gitignore", "/?s=python3"} {
|
||||
wantResult(t, crs, withHost(get(target), giteaHost), coreruleset.Result{})
|
||||
}
|
||||
}
|
||||
|
||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||
// exclusions file at path.
|
||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||
@@ -625,17 +745,24 @@ const passwordField = "password="
|
||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||
|
||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||
// requests in the file name in testdata is refused, at the defaults and
|
||||
// while bodies are read. A request with neither a body nor a Content-Type
|
||||
// header is checked only at the defaults: with neither, the set that reads
|
||||
// bodies has nothing more to check. Each request runs as its own parallel
|
||||
// subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||
// requests in the file name in testdata is refused on example.com, where
|
||||
// they were recorded, while SWW_APPS lists only app for it. They are
|
||||
// checked at the defaults and while bodies are read. With SWW_APPS unset,
|
||||
// every app's fixes apply. These only switch checks off, so the requests
|
||||
// pass then too. A request with neither a body nor a Content-Type header is
|
||||
// checked only at the defaults: with neither, the set that reads bodies has
|
||||
// nothing more to check. Each request runs as its own parallel subtest.
|
||||
func wantRecordedRequestsPass(t *testing.T, name, app string) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||
apps := map[string][]string{"example.com": {app}}
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range recordedRequests(t, name) {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
@@ -671,10 +798,13 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
// The recorded requests pass on giteaHost with gitea's fixes alone. The
|
||||
// attacks are refused at the defaults, where every app's fixes apply.
|
||||
giteaOnly := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
||||
crs := atDefaults(t)
|
||||
|
||||
for _, r := range giteaRequests(t) {
|
||||
if got := inspect(t, crs, r); got.Score >= threshold {
|
||||
if got := inspect(t, giteaOnly, withHost(r, giteaHost)); got.Score >= threshold {
|
||||
t.Errorf("%s %q refused: %+v", r.target, r.headers[1:], got)
|
||||
}
|
||||
}
|
||||
@@ -836,12 +966,50 @@ func wordpressAttackTargets(attack string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
|
||||
// requests pass on otherHost, where they were recorded, while SWW_APPS lists
|
||||
// only wordpress for it. They pass at the defaults, and those with a body
|
||||
// pass while bodies up to 64 KiB are read too. Every app's fixes only switch
|
||||
// checks off, so the requests pass with SWW_APPS unset too.
|
||||
func wantWordPressRequestsPass(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
apps := map[string][]string{otherHost: {wordpress}}
|
||||
defaults := servingApps(t, apps)
|
||||
bodies := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
||||
Apps: apps,
|
||||
})
|
||||
|
||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
r := request{recorded.Method, recorded.Target, recorded.Headers}
|
||||
|
||||
crsSets := []*coreruleset.CoreRuleSet{defaults}
|
||||
if recorded.Body != "" {
|
||||
crsSets = append(crsSets, bodies)
|
||||
}
|
||||
|
||||
for _, crs := range crsSets {
|
||||
got, _ := inspectBody(t, crs, r, recorded.Body)
|
||||
if got.Score >= threshold {
|
||||
t.Errorf("%s %s %.200q refused: %+v", r.method, r.target, recorded.Body, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||
|
||||
wantRecordedRequestsPass(t, "wordpress-requests.txt")
|
||||
wantWordPressRequestsPass(t)
|
||||
|
||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The tenth change: on Drupal's paths, some fields skip the rules that refused
|
||||
# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
||||
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
||||
# search, a name, a file name or an address. Each exclusion runs with the
|
||||
# request, before the rules it switches off, so this file is loaded before the
|
||||
@@ -25,6 +25,13 @@
|
||||
# 932230: ls -la
|
||||
# 932235: python3
|
||||
|
||||
# Unless SWW_APPS lists drupal for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_drupal when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_drupal itself.
|
||||
SecRule &TX:app_drupal "@eq 0" \
|
||||
"id:900790,phase:1,pass,nolog,skipAfter:END_DRUPAL"
|
||||
|
||||
# Every admin page's filters and autocompletes, and the page to return to:
|
||||
# title: /admin/content?title=python3
|
||||
# name: /admin/content/media?name=python3
|
||||
@@ -237,3 +244,5 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
||||
SecRule &ARGS_GET:json.mail "@eq 0" \
|
||||
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
||||
|
||||
SecMarker END_DRUPAL
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
@@ -34,7 +36,21 @@ const drupalName = "name="
|
||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||
wantRecordedRequestsPass(t, "drupal-requests.txt", "drupal")
|
||||
}
|
||||
|
||||
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"drupal"},
|
||||
})
|
||||
|
||||
// A search, which only Drupal's fixes let through.
|
||||
search := get("/search?keys=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
||||
# They are the fields where gitea sends a search, a file name or a branch name.
|
||||
# Each exclusion runs with the request, before the rules it switches off, those
|
||||
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
||||
# Each comment names a request the rule refused.
|
||||
|
||||
# Unless SWW_APPS lists gitea for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_gitea when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_gitea itself.
|
||||
SecRule &TX:app_gitea "@eq 0" \
|
||||
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
||||
|
||||
# The query parameters in which gitea sends names within a repository or its
|
||||
# own records, or a page of its own site, are not checked against the lists of
|
||||
# system files, shell paths and command names, on any path:
|
||||
# 930120: .gitignore
|
||||
# 932160: bin/docker-entrypoint
|
||||
# 932260: docker-build
|
||||
# ARGS holds the fields of a form data or multipart body Coraza reads as well
|
||||
# as the query parameters, so a field of these names is left out too.
|
||||
SecAction "id:900591,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
||||
|
||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||
# text searched for in q, which skips these rules there:
|
||||
@@ -163,8 +210,8 @@ SecRule REQUEST_FILENAME \
|
||||
# 942140: information_schema.sql
|
||||
# 944110: java/lang/Runtime.java
|
||||
# 944130: java.lang.String.md
|
||||
# Each request skips the rules that refused it. The fourth change already has
|
||||
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
||||
# Each request skips the rules that refused it. Rule 900591 already has path and
|
||||
# redirect_to skip 930120, 932160 and 932260 on every path.
|
||||
|
||||
# A file's commit graph, in file.
|
||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||
@@ -289,3 +336,5 @@ SecRule REQUEST_FILENAME \
|
||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||
"id:900640,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||
|
||||
SecMarker END_GITEA
|
||||
@@ -1,4 +1,4 @@
|
||||
# The twelfth change: on Mastodon's paths, some fields skip the rules that
|
||||
# Mastodon's fixes: on Mastodon's paths, some fields skip the rules that
|
||||
# refused ordinary Mastodon requests there. They are the fields where Mastodon
|
||||
# sends a search, a name, a title, a file name, an address or free text. Each
|
||||
# exclusion runs with the request, before the rules it switches off, so this
|
||||
@@ -17,6 +17,13 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists mastodon for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_mastodon when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_mastodon itself.
|
||||
SecRule &TX:app_mastodon "@eq 0" \
|
||||
"id:900940,phase:1,pass,nolog,skipAfter:END_MASTODON"
|
||||
|
||||
# The API's searches and names, in the query:
|
||||
# q: /api/v2/search?q=python3, /api/v1/accounts/search?q=ls+-la
|
||||
# acct: /api/v1/accounts/lookup?acct=python3
|
||||
@@ -216,3 +223,5 @@ SecRule REQUEST_FILENAME "@rx ^/auth(?:/|$)" \
|
||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^user\[email\]$/,\
|
||||
ctl:ruleRemoveTargetById=932120;ARGS:/^user\[email\]$/,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:/^user\[email\]$/"
|
||||
|
||||
SecMarker END_MASTODON
|
||||
@@ -57,7 +57,21 @@ const (
|
||||
func TestOrdinaryMastodonRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt")
|
||||
wantRecordedRequestsPass(t, "mastodon-requests.txt", "mastodon")
|
||||
}
|
||||
|
||||
func TestMastodonFixesApplyOnlyToAHostnameThatServesMastodon(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"mastodon"},
|
||||
})
|
||||
|
||||
// A search, which only Mastodon's fixes let through.
|
||||
search := get("/api/v2/search?q=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// mastodonQueryFields sends each field that skips 930120, 932120, 932250
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The eleventh change: on MediaWiki's paths, some fields skip the rules that
|
||||
# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that
|
||||
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
||||
# sends a search, a title, a name, a file name, an address, a summary or a
|
||||
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
||||
@@ -21,6 +21,13 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists mediawiki for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_mediawiki when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_mediawiki itself.
|
||||
SecRule &TX:app_mediawiki "@eq 0" \
|
||||
"id:900890,phase:1,pass,nolog,skipAfter:END_MEDIAWIKI"
|
||||
|
||||
# The pages and their titles, the searches, and the special pages' fields
|
||||
# for a page, a user, a file or an address:
|
||||
# title: /index.php?title=Python3_notes&action=history
|
||||
@@ -198,10 +205,12 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
# This rule is in phase 2, because a phase 1 rule runs before the body is read
|
||||
# and cannot see what wpTitles holds. It checks that the body sends wpTitles
|
||||
# once, since it would otherwise skip these rules for a second wpTitles
|
||||
# holding a command.
|
||||
# holding a command. It checks tx.app_mediawiki, since the skip above does not
|
||||
# reach phase 2.
|
||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
"id:900916,phase:2,pass,nolog,chain"
|
||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
||||
SecRule &TX:app_mediawiki "@eq 1" "chain"
|
||||
SecRule &ARGS_GET:wpTitles "@eq 0" "chain"
|
||||
SecRule &ARGS_POST:wpTitles "@eq 1" "chain"
|
||||
SecRule ARGS_POST:wpTitles "!@rx [;|&`{]" \
|
||||
@@ -210,3 +219,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
||||
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
||||
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
||||
|
||||
SecMarker END_MEDIAWIKI
|
||||
@@ -4,11 +4,16 @@ import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
const mediawikiThreshold = 5
|
||||
|
||||
// mediawiki is MediaWiki's name in SWW_APPS.
|
||||
const mediawiki = "mediawiki"
|
||||
|
||||
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
||||
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
||||
|
||||
@@ -45,7 +50,44 @@ const (
|
||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", mediawiki)
|
||||
}
|
||||
|
||||
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {mediawiki},
|
||||
})
|
||||
|
||||
// A search, which only MediaWiki's fixes let through.
|
||||
search := get("/index.php?title=Special:Search&search=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := shared(t, coreruleset.Params{
|
||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
||||
Apps: map[string][]string{giteaHost: {gitea}, otherHost: {mediawiki}},
|
||||
})
|
||||
|
||||
// A watchlist that only the phase 2 rule 900916 lets through.
|
||||
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
||||
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes"
|
||||
|
||||
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
|
||||
if got.Score >= mediawikiThreshold {
|
||||
t.Errorf("POST %s %s on %s refused: %+v", r.target, body, otherHost, got)
|
||||
}
|
||||
|
||||
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
|
||||
if got.Score < mediawikiThreshold {
|
||||
t.Errorf("POST %s %s on %s let through: %+v", r.target, body, giteaHost, got)
|
||||
}
|
||||
}
|
||||
|
||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The fourteenth change: on Nextcloud's paths, some fields skip the rules that
|
||||
# Nextcloud's fixes: on Nextcloud's paths, some fields skip the rules that
|
||||
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
|
||||
# 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
|
||||
@@ -29,6 +29,13 @@
|
||||
# "; wget http://example.com/x.sh" reaches Nextcloud in these fields. SQL
|
||||
# injection, path traversal and script injection are still refused there.
|
||||
|
||||
# Unless SWW_APPS lists nextcloud for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_nextcloud when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_nextcloud itself.
|
||||
SecRule &TX:app_nextcloud "@eq 0" \
|
||||
"id:900090,phase:1,pass,nolog,skipAfter:END_NEXTCLOUD"
|
||||
|
||||
# The sync client and the Files app use these WebDAV methods besides those
|
||||
# the 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:
|
||||
@@ -306,3 +313,5 @@ SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/addressbooks/" \
|
||||
SecRule REQUEST_METHOD "@streq MKCOL" \
|
||||
"ctl:ruleRemoveTargetById=932125;XML:/*,\
|
||||
ctl:ruleRemoveTargetById=932230;XML:/*"
|
||||
|
||||
SecMarker END_NEXTCLOUD
|
||||
@@ -48,10 +48,47 @@ const (
|
||||
// nextcloudOther is a JSON field no exclusion names.
|
||||
const nextcloudOther = `{"other":"python3"}`
|
||||
|
||||
// nextcloud is Nextcloud's name in SWW_APPS.
|
||||
const nextcloud = "nextcloud"
|
||||
|
||||
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
||||
wantRecordedRequestsPass(t, "nextcloud-requests.txt", nextcloud)
|
||||
}
|
||||
|
||||
func TestNextcloudFixesApplyOnlyToAHostnameThatServesNextcloud(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||
})
|
||||
|
||||
// A search, which only Nextcloud's fixes let through.
|
||||
search := get(nextcloudUsers + "?search=python3")
|
||||
|
||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
func TestNextcloudWebDAVMethodsPassOnlyOnHostnamesThatServeNextcloud(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {nextcloud},
|
||||
})
|
||||
|
||||
// Listing a folder, as the sync client does.
|
||||
list := request{nextcloudPropFind, nextcloudFiles, nil}
|
||||
|
||||
wantResult(t, crs, withHost(list, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(list, giteaHost), matched(911100))
|
||||
|
||||
// Making a calendar, as a CalDAV client does.
|
||||
calendar := request{nextcloudMkcalendar, nextcloudCalendars + "work/", nil}
|
||||
|
||||
wantResult(t, crs, withHost(calendar, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(calendar, giteaHost), matched(911100))
|
||||
}
|
||||
|
||||
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# The thirteenth change: on Vaultwarden's paths, some fields skip the rules that
|
||||
# Vaultwarden's fixes: on Vaultwarden's paths, some fields skip the rules that
|
||||
# refused ordinary Vaultwarden requests there. They are the fields where
|
||||
# Vaultwarden receives a name, a password hint or an email address in plain
|
||||
# text, and the names of uploaded files. Each exclusion runs with the request,
|
||||
@@ -17,6 +17,13 @@
|
||||
# 932250: ls -la
|
||||
# 932260: python3
|
||||
|
||||
# Unless SWW_APPS lists vaultwarden for the request's hostname, every rule below
|
||||
# is skipped. Inspect sets tx.app_vaultwarden when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_vaultwarden itself.
|
||||
SecRule &TX:app_vaultwarden "@eq 0" \
|
||||
"id:900140,phase:1,pass,nolog,skipAfter:END_VAULTWARDEN"
|
||||
|
||||
# Names and password hints, in JSON bodies, skip the four rules:
|
||||
# name: signing up, editing the account's profile, and creating or renaming
|
||||
# an organization or a group, "python3 .htaccess team"
|
||||
@@ -82,3 +89,5 @@ SecRule REQUEST_FILENAME "@rx ^/api/hibp/breach$" \
|
||||
SecRule REQUEST_FILENAME "@rx ^/api/ciphers/[0-9a-f-]{36}/attachment(?:-admin|/[0-9a-f]+(?:/share)?)?$|^/api/sends/[0-9a-f-]{36}/file/[0-9a-f]+$" \
|
||||
"id:900153,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveTargetById=920120;FILES"
|
||||
|
||||
SecMarker END_VAULTWARDEN
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
||||
)
|
||||
|
||||
// vaultwardenThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||
@@ -30,7 +32,22 @@ const vaultwardenEmail = `{"email":"VALUE"}`
|
||||
func TestOrdinaryVaultwardenRequestsPass(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt")
|
||||
wantRecordedRequestsPass(t, "vaultwarden-requests.txt", "vaultwarden")
|
||||
}
|
||||
|
||||
func TestVaultwardenFixesApplyOnlyToAHostnameThatServesVaultwarden(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
crs := servingApps(t, map[string][]string{
|
||||
giteaHost: {gitea}, otherHost: {"vaultwarden"},
|
||||
})
|
||||
|
||||
// An address for the data breach report, which only Vaultwarden's fixes
|
||||
// let through.
|
||||
report := get(vaultwardenBreachReport + "python3%40example.com")
|
||||
|
||||
wantResult(t, crs, withHost(report, otherHost), coreruleset.Result{})
|
||||
wantResult(t, crs, withHost(report, giteaHost), matched(932260))
|
||||
}
|
||||
|
||||
// vaultwardenField is a field Vaultwarden receives in plain text: the
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
# The ninth change: on WordPress's paths, some fields skip the rules that refused
|
||||
# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
||||
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||
# of a body applies only when the query has no parameter of that name, so it
|
||||
# matters only while SWW_WAF_BODY_LIMIT is set.
|
||||
|
||||
# Unless SWW_APPS lists wordpress for the request's hostname, every rule below is
|
||||
# skipped. Inspect sets tx.app_wordpress when it does.
|
||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
||||
# tx.app_wordpress itself.
|
||||
SecRule &TX:app_wordpress "@eq 0" \
|
||||
"id:900690,phase:1,pass,nolog,skipAfter:END_WORDPRESS"
|
||||
|
||||
# WordPress takes a call to its REST API at any of four addresses:
|
||||
# /wp-json/wp/v2/posts/1
|
||||
# /index.php/wp-json/wp/v2/posts/1
|
||||
@@ -195,3 +202,5 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$"
|
||||
# so skips 932340 there.
|
||||
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
||||
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
||||
|
||||
SecMarker END_WORDPRESS
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||
)
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||
apps = "SWW_APPS"
|
||||
block = "block"
|
||||
detect = "detect"
|
||||
)
|
||||
@@ -290,6 +292,27 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Only gitea's fixes let a file name through in path, a parameter
|
||||
// gitea sends file names in.
|
||||
const fileName = "/?path=.gitignore"
|
||||
|
||||
s := startDiscriminator(t, "", map[string]string{
|
||||
wafMode: block, apps: siteA + ":gitea",
|
||||
})
|
||||
|
||||
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
||||
http.StatusOK, discriminator.DispositionAllow)
|
||||
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
||||
http.StatusForbidden, discriminator.DispositionDeny)
|
||||
|
||||
lines := s.out.requestLines(t, 2)
|
||||
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
||||
}
|
||||
|
||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -221,7 +221,8 @@ func newReputation(
|
||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
||||
// or nil while SWW_WAF_MODE is off. It
|
||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||
@@ -236,6 +237,7 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||
Apps: cfg.Apps,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||
|
||||
Reference in new issue
Block a user