[Bug]: CalDAV Expand Behavior Issue in Nextcloud
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 36.9k
- Forks
- 5.2k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 713
Description
⚠️ This issue respects the following points: ⚠️
- This is a bug, not a question or a configuration/webserver/proxy issue.
- This issue is not already reported on Github OR Nextcloud Community Forum (I've searched it).
- Nextcloud Server is up to date. See Maintenance and Release Schedule for supported versions.
- I agree to follow Nextcloud's Code of Conduct.
Bug description
CalDAV Expand Behavior Issue in Nextcloud
Summary
We have observed unexpected behavior when using the CALDAV:expand element with recurring events in Nextcloud's CalDAV implementation. This report presents our findings for expert review.
Observed Behavior
When querying a recurring event with expand="1", Nextcloud returns multiple event instances, but these instances appear to have characteristics that may not align with expected CalDAV expansion behavior.
Test Case
We created a simple recurring event with:
- RRULE:
FREQ=WEEKLY;COUNT=3(3 weekly occurrences) - UID:
caldav-expand-test-[timestamp] - Start time:
2025-07-27 01:00:00+02:00
Results
Without expand=False (baseline)
- Events returned: 1
- UID:
caldav-expand-test-[timestamp] - Start time:
2025-07-27 01:00:00+02:00 - RRULE: Present
- RECURRENCE-ID: None
With expand=True
- Events returned: 3 (expected count)
- All events have:
- Same UID:
caldav-expand-test-[timestamp] - Same URL: Points to identical
.icsfile - Same start time:
2025-07-27 01:00:00+02:00 - RRULE: Still present
- RECURRENCE-ID: None (all instances)
- Same UID:
Concerns Identified
Based on our analysis and comparison with RFC 4791 Section 7.8.3 examples, we note the following concerns:
-
Identical start times: All expanded instances show the same start time rather than the actual occurrence times (should be weekly intervals)
-
Missing RECURRENCE-ID: None of the expanded instances have
RECURRENCE-IDproperties -
RRULE persistence: The original
RRULEproperty remains in all expanded instances -
Same resource reference: All expanded instances reference the same calendar resource URL
RFC 4791 Section 7.8.3 Reference
The RFC example shows expanded recurring events with:
- Same UID for instances of the same recurring event
- Different
DTSTARTtimes for each occurrence RECURRENCE-IDproperties on each expanded instance- No
RRULEproperties in the expanded instances
Expected vs. Observed
Expected behavior (based on RFC example):
Instance 1: DTSTART=2025-07-27T01:00:00, RECURRENCE-ID=2025-07-27T01:00:00
Instance 2: DTSTART=2025-08-03T01:00:00, RECURRENCE-ID=2025-08-03T01:00:00
Instance 3: DTSTART=2025-08-10T01:00:00, RECURRENCE-ID=2025-08-10T01:00:00
Observed behavior:
Instance 1: DTSTART=2025-07-27T01:00:00, RECURRENCE-ID=None, RRULE=Present
Instance 2: DTSTART=2025-07-27T01:00:00, RECURRENCE-ID=None, RRULE=Present
Instance 3: DTSTART=2025-07-27T01:00:00, RECURRENCE-ID=None, RRULE=Present
Impact
This behavior affects CalDAV clients that:
- Expect proper
RECURRENCE-IDproperties on expanded instances - Need actual occurrence times for each instance
- Implement duplicate detection based on start times
- Assume expanded instances don't contain RRULE properties
Reproduction
- Create a recurring event with
FREQ=WEEKLY;COUNT=3 - Query with
CALDAV:expandelement and time range covering all occurrences - Observe that returned instances have identical start times and missing RECURRENCE-ID
Request for Review
We present these findings for expert review to determine:
- Whether this behavior is compliant with CalDAV specifications
- If this represents a deviation from expected CalDAV expansion behavior
- What the correct implementation should look like
We acknowledge that our analysis may be incomplete and welcome clarification from CalDAV experts and Nextcloud developers.
Files Included
nextcloud_caldav_expansion_issue.py- Minimal reproduction script- Test output logs showing the observed behavior
Note: This report is based on our analysis and testing. We are not CalDAV specification experts and welcome corrections or clarifications about expected behavior.
Server configuration detail
Operating system: Linux 6.1.0-37-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.140-1 (2025-05-22) x86_64
Webserver: Apache/2.4.62 (Debian) (fpm-fcgi)
Database: mysql 10.11.11
PHP version: 8.2.28
Modules loaded: Core, date, libxml, openssl, pcre, zlib, filter, hash, json, random, Reflection, SPL, session, standard, sodium, cgi-fcgi, mysqlnd, PDO, xml, apcu, bcmath, bz2, calendar, ctype, curl, dom, mbstring, FFI, fileinfo, ftp, gd, gettext, gmp, iconv, igbinary, imagick, intl, ldap, exif, mysqli, pdo_mysql, Phar, posix, readline, redis, shmop, SimpleXML, sockets, sysvmsg, sysvsem, sysvshm, tokenizer, xmlreader, xmlwriter, xsl, zip, Zend OPcache
Nextcloud version: 31.0.7 - 31.0.7.1
Updated from an older Nextcloud/ownCloud or fresh install:
Where did you install Nextcloud from: unknown
Signing status
[]
List of activated apps
Enabled:
- activity: 4.0.0
- app_api: 5.0.2
- appointments: 2.4.5
- bruteforcesettings: 4.0.0
- calendar: 5.3.7
- comments: 1.21.0
- contacts: 7.2.2
- contactsinteraction: 1.12.0
- dashboard: 7.11.0
- deck: 1.15.1
- external: 6.0.2
- federation: 1.21.0
- files_downloadlimit: 4.0.0
- files_external: 1.23.0
- files_pdfviewer: 4.0.0
- files_reminders: 1.4.0
- files_sharing: 1.23.1
- files_trashbin: 1.21.0
- files_versions: 1.24.0
- firstrunwizard: 4.0.0
- flow_notifications: 2.0.1
- forms: 5.1.2
- groupfolders: 19.1.2
- impersonate: 2.0.0
- integration_mattermost: 2.1.1
- integration_openproject: 2.9.1
- integration_zammad: 3.0.2
- logreader: 4.0.0
- nextcloud_announcements: 3.0.0
- notes: 4.12.2
- notifications: 4.0.0
- oidc: 1.8.3
- password_policy: 3.0.0
- passwords: 2025.7.10
- photos: 4.0.0
- polls: 8.1.4
- privacy: 3.0.0
- recommendations: 4.0.0
- related_resources: 2.0.0
- richdocuments: 8.7.2
- serverinfo: 3.0.0
- sharebymail: 1.21.0
- spreed: 21.1.2
- support: 3.0.0
- survey_client: 3.0.0
- systemtags: 1.21.1
- tasks: 0.16.1
- text: 5.0.0
- updatenotification: 1.21.0
- user_ldap: 1.22.0
- user_oidc: 7.2.0
- user_status: 1.11.0
- weather_status: 1.11.0
- webhook_listeners: 1.2.0
- workflow_script: 2.0.0
Disabled:
- admin_audit
- circles: 0.16.2
- encryption: 2.8.1
- files_texteditor: 2.15.1
- suspicious_login
- twofactor_nextcloud_notification
- twofactor_totp
Configuration (config/config.php)
{
"passwordsalt": "***REMOVED SENSITIVE VALUE***",
"secret": "***REMOVED SENSITIVE VALUE***",
"trusted_domains": [
"nextcloud.example.com"
],
"datadirectory": "***REMOVED SENSITIVE VALUE***",
"overwrite.cli.url": "https:\/\/nextcloud.example.com",
"htaccess.RewriteBase": "\/",
"dbtype": "mysql",
"version": "31.0.7.1",
"dbname": "***REMOVED SENSITIVE VALUE***",
"dbhost": "***REMOVED SENSITIVE VALUE***",
"dbport": "",
"dbtableprefix": "oc_",
"mysql.utf8mb4": true,
"dbuser": "***REMOVED SENSITIVE VALUE***",
"dbpassword": "***REMOVED SENSITIVE VALUE***",
"installed": true,
"instanceid": "***REMOVED SENSITIVE VALUE***",
"ldapIgnoreNamingRules": false,
"ldapProviderFactory": "\\OCA\\User_LDAP\\LDAPProviderFactory",
"mail_from_address": "***REMOVED SENSITIVE VALUE***",
"mail_smtpmode": "sendmail",
"mail_smtpauthtype": "LOGIN",
"mail_domain": "***REMOVED SENSITIVE VALUE***",
"maintenance": false,
"theme": "",
"loglevel": 2,
"auth.bruteforce.protection.enabled": true,
"memcache.local": "\\OC\\Memcache\\APCu",
"memcache.locking": "\\OC\\Memcache\\Redis",
"redis": {
"host": "***REMOVED SENSITIVE VALUE***",
"port": 6379
},
"updater.release.channel": "stable",
"mail_smtphost": "***REMOVED SENSITIVE VALUE***",
"enable_previews": true,
"encryption.legacy_format_support": false,
"encryption.key_storage_migrated": false,
"default_phone_region": "DE",
"simpleSignUpLink.shown": false,
"app_install_overwrite": [
"socialsharing_email",
"files_texteditor"
],
"maintenance_window_start": 22,
"defaultapp": "",
"trashbin_retention_obligation": "auto, 2"
}
Cron Configuration:
Mode: cron
Last: 2025-07-26T22:25:01+00:00 (232 seconds ago)
External storages: yes
External storage configuration
No mounts configured
Encryption: no
User-backends:
- OCA\OIDCIdentityProvider\BasicAuthBackend
- OCA\UserOIDC\User\Backend
- OC\User\Database
- OCA\User_LDAP\User_Proxy
Subscription:
- No valid subscription key set
Browser: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:141.0) Gecko/20100101 Firefox/141.0
Setup checks
system
- Errors in the log: 450 errors in the logs since 20. Juli 2025, 00:28:53
talk
- High-performance backend: No High-performance backend configured - Running Nextcloud Talk without the High-performance backend only scales for very small calls (max. 2-3 participants). Please set up the High-performance backend to ensure calls with multiple participants work seamlessly.
Talk
Talk configuration:
STUN servers
- exklave.example.com:3478
TURN servers
- turn:exklave.example.com - udp,tcp
Signaling servers (mode: default):
- SIP dialin is disabled
- SIP dialout is disabled
- no custom server configured
Recording servers:
- Recording is enabled
- Recording consent is set to "default"
- no recording server configured
LDAP
LDAP configuration
(***redacted***)
Steps to reproduce
- Run
nextcloud_caldav_expansion_issue.pyagainst a test calendar.
Expected behavior
In the expanded case we get three VEVENTS as shown in RFC 4791 section 7.8.3. "Example: Expanded Retrieval of Recurring Events".
Nextcloud Server version
31
Operating system
Debian/Ubuntu
PHP engine version
PHP 8.2
Web server
Apache (supported)
Database engine version
MariaDB
Is this bug present after an update or on a fresh install?
Upgraded to a MAJOR version (ex. 31 to 32)
Are you using the Nextcloud Server Encryption module?
Encryption is Disabled
What user-backends are you using?
- Default user-backend (database)
- LDAP/ Active Directory
- SSO - SAML
- Other
Configuration report
see above
List of activated Apps
see above
Nextcloud Signing status
No errors have been found.
Nextcloud Logs
Additional info
nextcloud_caldav_expansion_issue.py to reproduce the behaviour:
#!/usr/bin/env python3
"""
Minimal test case demonstrating unexpected behavior in Nextcloud CalDAV expansion
This script demonstrates what appears to be unexpected behavior when using
the CALDAV:expand element with recurring events in Nextcloud/SabreDAV.
The behavior observed differs from what might be expected based on
RFC 4791 Section 7.8.3 examples.
# CalDAV Test Configuration
CALDAV_URL=https://nextcloud.example.com/remote.php/dav/calendars/testaccount/calendar/
CALDAV_CALENDAR="Test Calendar Name"
CALDAV_USERNAME=testusername
CALDAV_PASSWORD="T3stUs3rP@ssword"
"""
import os
import sys
import logging
import uuid
from datetime import datetime, timedelta
import pytz
from dotenv import load_dotenv
import caldav
from icalendar import Calendar, Event, vRecur
def setup_logging():
"""Configure logging"""
logging.basicConfig(
level=logging.INFO,
format='%(levelname)s: %(message)s',
handlers=[logging.StreamHandler(sys.stdout)]
)
def create_test_event(calendar):
"""Create a simple recurring test event"""
timezone = pytz.timezone('Europe/Berlin')
start_time = datetime.now(timezone).replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
end_time = start_time + timedelta(hours=1)
# Use unique identifier to find all related events
test_id = str(uuid.uuid4())[:8]
uid = f"caldav-expand-test-{int(datetime.now().timestamp())}"
summary = f"CalDAV Expand Test {test_id}"
# Create iCalendar data
cal = Calendar()
cal.add('version', '2.0')
cal.add('prodid', '-//Test//CalDAV Expand//EN')
event = Event()
event.add('uid', uid)
event.add('summary', summary)
event.add('dtstart', start_time)
event.add('dtend', end_time)
event.add('dtstamp', datetime.now(pytz.UTC))
# Add RRULE for 3 weekly occurrences
rrule = vRecur({'FREQ': 'WEEKLY', 'COUNT': 3})
event.add('rrule', rrule)
event.add('categories', ['CALDAV-EXPAND-TEST'])
cal.add_component(event)
# Save to server
ical_data = cal.to_ical()
calendar.save_event(ical_data)
print("=" * 60)
print("CREATED TEST EVENT")
print("=" * 60)
print(f"UID: {uid}")
print(f"Summary: {summary}")
print(f"Test ID: {test_id}")
print(f"Start: {start_time}")
print(f"RRULE: FREQ=WEEKLY;COUNT=3")
print(f"Expected: 3 weekly occurrences")
print("\nGenerated iCal data:")
print(ical_data.decode('utf-8'))
return uid, test_id, start_time
def query_events(calendar, test_id, expand=False):
"""Query events with or without expand"""
timezone = pytz.timezone('Europe/Berlin')
# Use wide search range
query_start = datetime.now(timezone) - timedelta(days=1)
query_end = datetime.now(timezone) + timedelta(weeks=5)
print(f"\nQuerying events with expand={expand}")
print(f"Query range: {query_start} to {query_end}")
# Search with appropriate expand setting
if expand:
events = calendar.search(
start=query_start,
end=query_end,
event=True,
expand=True
)
else:
events = calendar.search(
start=query_start,
end=query_end,
event=True
)
# Filter to only our test events by summary
our_events = []
for event in events:
try:
event.load()
cal_obj = Calendar.from_ical(event.data)
for component in cal_obj.walk('VEVENT'):
summary = str(component.get('SUMMARY', ''))
if test_id in summary:
our_events.append((event, component))
break
except Exception as e:
print(f"Error loading event: {e}")
continue
return our_events
def analyze_results(events, label):
"""Analyze and display results"""
print(f"\n{'-' * 50}")
print(f"{label}")
print(f"{'-' * 50}")
print(f"Events returned: {len(events)}")
if not events:
return
# Collect data for analysis
unique_uids = set()
unique_urls = set()
start_times = []
for i, (event, component) in enumerate(events, 1):
uid = str(component.get('UID', ''))
summary = str(component.get('SUMMARY', ''))
start_dt = component.get('DTSTART').dt
rrule = component.get('RRULE')
recurrence_id = component.get('RECURRENCE-ID')
unique_uids.add(uid)
unique_urls.add(str(event.url))
start_times.append(start_dt)
print(f"\nEvent {i}:")
print(f" URL: {event.url}")
print(f" UID: {uid}")
print(f" Summary: {summary}")
print(f" DTSTART: {start_dt}")
print(f" RRULE: {'Present' if rrule else 'None'}")
print(f" RECURRENCE-ID: {recurrence_id if recurrence_id else 'None'}")
# Analysis summary
print(f"\nSUMMARY:")
print(f" Unique UIDs: {len(unique_uids)}")
print(f" Unique URLs: {len(unique_urls)}")
print(f" Unique start times: {len(set(dt.isoformat() for dt in start_times))}")
if len(set(dt.isoformat() for dt in start_times)) > 1:
print(f" Start times:")
for dt in sorted(set(start_times)):
print(f" {dt}")
def cleanup_test_event(calendar, test_id):
"""Clean up test events"""
try:
events = query_events(calendar, test_id, expand=False)
for event, component in events:
event.delete()
uid = str(component.get('UID', 'unknown'))
print(f"✅ Cleaned up: {uid}")
except Exception as e:
print(f"⚠ Cleanup failed: {e}")
def main():
"""Main test function"""
setup_logging()
# Load test environment
load_dotenv('.env.test')
url = os.getenv('CALDAV_URL')
username = os.getenv('CALDAV_USERNAME')
password = os.getenv('CALDAV_PASSWORD')
if not all([url, username, password]):
print("Error: Missing CalDAV credentials in .env.test")
sys.exit(1)
print("CalDAV Expand Behavior Test")
print(f"Server: {url}")
print(f"Date: {datetime.now()}")
try:
# Connect to server
client = caldav.DAVClient(url, username=username, password=password)
calendar = client.calendar(url=url)
# Create test event
uid, test_id, start_time = create_test_event(calendar)
# Brief pause for server processing
import time
time.sleep(2)
# Test without expand
events_no_expand = query_events(calendar, test_id, expand=False)
analyze_results(events_no_expand, "WITHOUT EXPAND")
# Test with expand=True
events_expand = query_events(calendar, test_id, expand=True)
analyze_results(events_expand, "WITH EXPAND=TRUE")
# Analysis
print(f"\n{'=' * 60}")
print("BEHAVIOR ANALYSIS")
print(f"{'=' * 60}")
if len(events_expand) > len(events_no_expand):
print("✓ Server returns more events with expand=True")
else:
print("⚠ Server returns same number of events with/without expand")
if len(events_expand) > 0:
# Check if all expanded events have same start time
expand_start_times = []
expand_has_rrule = 0
expand_has_recurrence_id = 0
for event, component in events_expand:
start_dt = component.get('DTSTART').dt
expand_start_times.append(start_dt)
if component.get('RRULE'):
expand_has_rrule += 1
if component.get('RECURRENCE-ID'):
expand_has_recurrence_id += 1
unique_expand_times = len(set(dt.isoformat() for dt in expand_start_times))
print(f"\nExpanded events analysis:")
print(f" Total expanded events: {len(events_expand)}")
print(f" Unique start times: {unique_expand_times}")
print(f" Events with RRULE: {expand_has_rrule}")
print(f" Events with RECURRENCE-ID: {expand_has_recurrence_id}")
if unique_expand_times == 1 and len(events_expand) > 1:
print(" → All expanded events have identical start times")
if expand_has_recurrence_id == 0 and len(events_expand) > 1:
print(" → No expanded events have RECURRENCE-ID properties")
if expand_has_rrule > 0:
print(" → Expanded events still contain RRULE properties")
# Cleanup
print(f"\n{'-' * 30}")
cleanup_test_event(calendar, test_id)
except Exception as e:
print(f"\nTest failed: {e}")
logging.exception("Error details:")
sys.exit(1)
if __name__ == "__main__":
main()
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the mentioned nextcloud_caldav_expansion_issue.py reproduction script and compare its expanded response with RFC 4791 section 7.8.3. Trace the CalDAV expansion entry point in the Nextcloud server and determine whether the returned DTSTART, RECURRENCE-ID, RRULE, and resource references match the intended behavior. Done means the behavior is confirmed against the specification and covered by a regression test or a documented explanation if it is compliant.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php, python
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100