nextcloud / nextcloud/server

[Bug]: CalDAV Expand Behavior Issue in Nextcloud

Open
#54,095 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

0. Needs triage 31-feedback bug feature: caldav
Dominant language
PHP
Stars
36.9k
Forks
5.2k
Avg merge
2d 3h
Merged PRs (30d)
713

Description

⚠️ This issue respects the following points: ⚠️
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 .ics file
    • Same start time: 2025-07-27 01:00:00+02:00
    • RRULE: Still present
    • RECURRENCE-ID: None (all instances)

Concerns Identified

Based on our analysis and comparison with RFC 4791 Section 7.8.3 examples, we note the following concerns:

  1. Identical start times: All expanded instances show the same start time rather than the actual occurrence times (should be weekly intervals)

  2. Missing RECURRENCE-ID: None of the expanded instances have RECURRENCE-ID properties

  3. RRULE persistence: The original RRULE property remains in all expanded instances

  4. 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 DTSTART times for each occurrence
  • RECURRENCE-ID properties on each expanded instance
  • No RRULE properties 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-ID properties 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

  1. Create a recurring event with FREQ=WEEKLY;COUNT=3
  2. Query with CALDAV:expand element and time range covering all occurrences
  3. Observe that returned instances have identical start times and missing RECURRENCE-ID

Request for Review

We present these findings for expert review to determine:

  1. Whether this behavior is compliant with CalDAV specifications
  2. If this represents a deviation from expected CalDAV expansion behavior
  3. 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
  1. Run nextcloud_caldav_expansion_issue.py against 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.