Core Rule Set: Nextcloud's calendar and contacts sync pass (closes #280)
check / check (push) Failing after 8m1s

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 also 932125 and 932230, for its
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
This commit is contained in:
clawbot committed 2026-11-10 09:28:37 +00:00
1 parent 463a19ef2a
commit e06a16370f
6 files changed
+266 -7

No files matched your search

+133
View File
@@ -6,6 +6,7 @@ import (
"maps"
"net/http"
"net/url"
"slices"
"strings"
"testing"
@@ -126,8 +127,46 @@ var nextcloudBodies = []nextcloudBody{
`<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>`
// 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.
//
@@ -375,6 +414,100 @@ func TestNextcloudWebDAVMethodsAreAllowedOnlyThere(t *testing.T) {
}
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 TestNextcloudCalendarDescriptionPassesAndAttacksAreRefused(t *testing.T) {
t.Parallel()
crs := readingBodies(t)
sent := nextcloudBody{
nextcloudMkcalendar, nextcloudCalendars + "work/", xmlBody,
nextcloudCalendarDescription,
}
request, score := nextcloudBodyScore(t, crs, sent, nextcloudDescription)
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 skips only the rules for names.
sent.method = nextcloudMkcol
request, score = nextcloudBodyScore(t, crs, sent, nextcloudDescription)
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