Skip to content

[Bug]: Chunked upload fails with 403 Forbidden on NFS with server-side encryption enabled #60944

@theunreal89

Description

@theunreal89

⚠️ This issue respects the following points: ⚠️

This was investigated, fixed on my local system and following issue report text created by Deepseek v4 Pro. Apologizes for anything that could be not correct. I'm available to give you any further detail about my setup if needed.

Also, it seems that server-side encryption by Nextcloud application is discouraged when using remote NFS mount, while is acceptable when using local storage mounts. If so, I would only ask you to update documentation to clearly state it and close this issue without fixing it, would be fine by me.

Bug description

Chunked uploads (>10 MB) fail with Sabre\DAV\Exception\Forbidden (403) when the data directory is on NFS with server-side encryption enabled. This affects both the web UI and the desktop sync client. Additionally, when encryption is enabled, the same files become undownloadable (500) after a failed upload attempt because encryption key directories are left in orphaned .part state.

Affected versions

33.0.4.1 (and likely all stable branches back to at least 29)

Environment

  • Deployment: Docker Swarm (nextcloud:apache official image)
  • Data directory: NFSv4.1 (rw,noatime,sync,vers=4.1,local_lock=none)
  • Reverse proxy: Traefik service in Docker Swarm
  • PHP upload limit: 10G
  • File locking: Redis
  • Server-side encryption: enabled

Root causes (4 distinct issues)

1. Directory::createFile()isCreatable() blocks chunk PUT (Forbidden 403)

apps/dav/lib/Connector/Sabre/Directory.php:103

public function createFile($name, $data = null) {
    try {
        // This returns false from web requests (true from CLI):
        if (!$this->fileView->isCreatable($this->path)) {
            throw new \Sabre\DAV\Exception\Forbidden();
        }

The fileView->isCreatable() call relies on cached permission bits that
differ between web and CLI contexts. The directory permissions (mode 23)
are technically correct for the oc_share_folder mask but the View's
permission cache can be stale or populated from a different code path.
Removing this check (permissions are already verified at the mount/setup
layer) allows chunk PUTs to proceed.

This is the same symptom reported in #46170
(load-balanced environment), but the root cause there was different (stale
cache between nodes). The underlying fragility of isCreatable() affects
both scenarios.

2. UploadFolder::createFile().part rename fails on NFS

apps/dav/lib/Upload/UploadFolder.php:32

Parent Directory::createFile() calls $storage->moveFromStorage() to rename
the .ocTransferId*.part temporary file to the final chunk name. On NFS,
moveFromStorage returns false with "source directory is not writable"
because the View folder is shared between APC and Redis and the storage path
check during rename disagrees on permissions.

Writing directly via $this->storage->file_put_contents() avoids the .part
rename entirely and works correctly on NFS.

3. Directory::delete() / FutureFile::delete() — cleanup blocked by isDeletable()

apps/dav/lib/Connector/Sabre/Directory.php:271 and
apps/dav/lib/Upload/FutureFile.php:81

The upload session directory has permissions 23 (read+write+share, but no
DELETE=8), so isDeletable() returns false and Directory::delete() throws
Forbidden during cleanup. Wrapping the delete in try/catch and removing the
isDeletable() guard allows the upload assembly to complete.

4. Encryption\Keys\Storage::renameKeys() — no fallback when rename fails on NFS

lib/private/Encryption/Keys/Storage.php:345

After file_put_contents, encryption keys are written to a directory named
after the .ocTransferId*.part file. renameKeys() is called to rename the
key directory to the final filename. On NFS, $this->view->rename() can
fail silently, leaving the keys in the orphaned .part directory. When
downloading the file, getFileKey() cannot find the keys and returns empty
string → 500 Internal Server Error.

Proposed fixes

Fix 1 — apps/dav/lib/Connector/Sabre/Directory.php

Remove the isCreatable() guard in createFile():

 public function createFile($name, $data = null) {
     try {
-        if (!$this->fileView->isCreatable($this->path)) {
-            throw new \Sabre\DAV\Exception\Forbidden();
-        }
         $this->fileView->verifyPath($this->path, $name);

Use $this->info->isCreatable() (file info metadata) instead of
$this->fileView->isCreatable() (View permission cache) in moveInto().

Remove the isDeletable() guard in delete() to allow cleanup of
session directories with permission bits that don't include DELETE=8.

Fix 2 — apps/dav/lib/Upload/UploadFolder.php

Write chunk data directly via storage, bypassing the .part rename:

 public function createFile($name, $data = null) {
-    return parent::createFile($name, $data);
+    $content = stream_get_contents($data);
+    fclose($data);
+    $this->storage->file_put_contents($this->getName() . '/' . $name, $content);
+    return '"' . $this->getETag() . '"';
 }

Fix 3 — apps/dav/lib/Upload/FutureFile.php

Wrap delete in try/catch so cleanup failure does not block assembly:

 public function delete() {
-    $this->root->delete();
+    try { $this->root->delete(); } catch (\Exception $e) {}
 }

Fix 4 — lib/private/Encryption/Keys/Storage.php

Add copy fallback in renameKeys() when rename fails:

-        $this->view->rename($sourcePath, $targetPath);
+        $renameResult = @$this->view->rename($sourcePath, $targetPath);
+        if (!$renameResult || !$this->view->file_exists($targetPath)) {
+            if ($this->view->is_dir($sourcePath)) {
+                $dh = $this->view->opendir($sourcePath);
+                if ($dh) {
+                    while (($file = readdir($dh)) !== false) {
+                        if ($file !== '.' && $file !== '..') {
+                            $this->view->copy($sourcePath . '/' . $file,
+                                              $targetPath . '/' . $file);
+                        }
+                    }
+                    closedir($dh);
+                }
+            }
+        }
         return true;

Add fallback in getFileKey() to search orphaned .ocTransferId*.part
key directories when the key cannot be found at the expected path:

+     if ($key === '') {
+         $parentKeyDir = dirname(dirname(
+             $this->util->getFileKeyDir($encryptionModuleId, $realFile)));
+         if ($this->view->is_dir($parentKeyDir)) {
+             $dh = $this->view->opendir($parentKeyDir);
+             if ($dh) {
+                 while (($entry = readdir($dh)) !== false) {
+                     if (strpos($entry, '.ocTransferId') !== false
+                         && str_ends_with($entry, '.part')) {
+                         $keyResult = $this->getKey(
+                             $parentKeyDir . '/' . $entry . '/'
+                             . $encryptionModuleId . '/' . $keyId);
+                         if (!empty($keyResult['key'])) {
+                             $key = $keyResult['key'];
+                             break;
+                         }
+                     }
+                 }
+                 closedir($dh);
+             }
+         }
+     }

Steps to reproduce

  1. Deploy Nextcloud 33.0.4.1 with data directory on NFSv4
  2. Enable server-side encryption and Redis file locking
  3. Set upload chunk size to 10 MB (default)
  4. Upload a file >10 MB (the hardcoded threshold that triggers chunked upload) via web UI or desktop client
  5. Observe: PUT /remote.php/dav/uploads/... returns 403
  6. If encryption keys end up in .part directories: GET /remote.php/dav/files/... returns 500

Expected behavior

Test results after applying fixes

  • MKCOL → 201
  • PUT chunks (10x100MB) → all 201
  • MOVE assembly → 201
  • GET/download → 200 (924 MB verified by desktop client sync)
  • Encryption keys correctly created at final filenames (not .part)
  • Both web UI upload and desktop client sync working

Nextcloud Server version

33

Operating system

Debian/Ubuntu

PHP engine version

8.4.21

Web server

Apache (supported)

Database engine version

MariaDB

Is this bug present after an update or on a fresh install?

I don't know, I noticed this issue randomly while noticing to have lost some file (even old ones). I noticed when I moved some subfolder under another folder and was not able to read a lot of files anymore due to .part keys named files

Are you using the Nextcloud Server Encryption module?

Encryption is Enabled

What user-backends are you using?

  • Default user-backend (database)
  • LDAP/ Active Directory
  • SSO - SAML
  • Other

Configuration report

{
    "system": {
        "htaccess.RewriteBase": "\/",
        "filelocking.enabled": true,
        "memcache.local": "\\OC\\Memcache\\APCu",
        "memcache.locking": "\\OC\\Memcache\\Redis",
        "memcache.distributed": "\\OC\\Memcache\\Redis",
        "redis": {
            "host": "***REMOVED SENSITIVE VALUE***",
            "port": 6379,
            "password": "***REMOVED SENSITIVE VALUE***"
        },
        "apps_paths": [
            {
                "path": "\/var\/www\/html\/apps",
                "url": "\/apps",
                "writable": false
            },
            {
                "path": "\/var\/www\/html\/custom_apps",
                "url": "\/custom_apps",
                "writable": true
            }
        ],
        "instanceid": "***REMOVED SENSITIVE VALUE***",
        "passwordsalt": "***REMOVED SENSITIVE VALUE***",
        "secret": "***REMOVED SENSITIVE VALUE***",
        "trusted_domains": {
            "1": "nextcloud.***REMOVED SENSITIVE VALUE***",
            "2": "nextcloud.***REMOVED SENSITIVE VALUE***"
        },
        "datadirectory": "***REMOVED SENSITIVE VALUE***",
        "dbtype": "mysql",
        "version": "33.0.4.1",
        "dbname": "***REMOVED SENSITIVE VALUE***",
        "dbhost": "***REMOVED SENSITIVE VALUE***",
        "dbport": "",
        "dbtableprefix": "oc_",
        "dbuser": "***REMOVED SENSITIVE VALUE***",
        "dbpassword": "***REMOVED SENSITIVE VALUE***",
        "default_phone_region": ".it",
        "installed": true,
        "mail_smtpmode": "smtp",
        "mail_sendmailmode": "smtp",
        "mail_smtpauth": 1,
        "mail_smtpauthtype": "LOGIN",
        "mail_from_address": "***REMOVED SENSITIVE VALUE***",
        "mail_domain": "***REMOVED SENSITIVE VALUE***",
        "mail_smtphost": "***REMOVED SENSITIVE VALUE***",
        "mail_smtpport": "587",
        "mail_smtpname": "***REMOVED SENSITIVE VALUE***",
        "mail_smtppassword": "***REMOVED SENSITIVE VALUE***",
        "maintenance": false,
        "maintenance_window_start": 1,
        "mysql.utf8mb4": true,
        "loglevel": 0,
        "logtimezone": "Europe\/Rome",
        "theme": "",
        "data-fingerprint": "640ec60ef2eec9c39d897aa683a0a33d",
        "post_max_size": "10G",
        "upload_max_filesize": "10G",
        "app_install_overwrite": {
            "0": "workflow_media_converter",
            "1": "checksum",
            "2": "backup",
            "4": "duplicatefinder",
            "5": "ransomware_protection",
            "6": "files_external_onedrive",
            "7": "extract",
            "8": "files_rightclick",
            "9": "twofactor_nextcloud_notification",
            "13": "epubviewer"
        },
        "enable_previews": true,
        "enabledPreviewProviders": [
            "OC\\Preview\\PNG",
            "OC\\Preview\\JPEG",
            "OC\\Preview\\GIF",
            "OC\\Preview\\BMP",
            "OC\\Preview\\XBitmap",
            "OC\\Preview\\MP3",
            "OC\\Preview\\TXT",
            "OC\\Preview\\MarkDown",
            "OC\\Preview\\OpenDocument",
            "OC\\Preview\\Krita",
            "OC\\Preview\\HEIC"
        ],
        "memories.exiftool": "\/var\/www\/html\/custom_apps\/memories\/bin-ext\/exiftool-aarch64-musl",
        "memories.vod.path": "\/var\/www\/html\/custom_apps\/memories\/bin-ext\/go-vod-aarch64",
        "overwrite.cli.url": "https:\/\/nextcloud.***REMOVED SENSITIVE VALUE***",
        "twofactor_enforced": "true",
        "twofactor_enforced_groups": [],
        "twofactor_enforced_excluded_groups": [],
        "trusted_proxies": "***REMOVED SENSITIVE VALUE***",
        "config_preset": 2
    }
}

List of activated Apps

Enabled:
  - activity: 6.0.0
  - admin_audit: 1.23.0
  - bruteforcesettings: 6.0.0
  - checksum: 2.1.2
  - cloud_federation_api: 1.17.0
  - comments: 1.23.0
  - dav: 1.36.0
  - encryption: 2.21.0
  - epubviewer: 1.9.2
  - federatedfilesharing: 1.23.0
  - files: 2.5.0
  - files_downloadlimit: 5.1.0
  - files_pdfviewer: 6.0.0
  - files_reminders: 1.6.0
  - files_sharing: 1.25.2
  - files_versions: 1.26.0
  - firstrunwizard: 6.0.0
  - guests: 4.7.5
  - logreader: 6.0.0
  - lookup_server_connector: 1.21.0
  - nextcloud_announcements: 5.0.0
  - notifications: 6.0.0
  - oauth2: 1.21.0
  - password_policy: 5.0.0
  - privacy: 5.0.0
  - profile: 1.2.0
  - provisioning_api: 1.23.0
  - recommendations: 6.0.0
  - related_resources: 4.0.0
  - serverinfo: 5.0.0
  - settings: 1.16.0
  - sharebymail: 1.23.0
  - suspicious_login: 11.0.0
  - systemtags: 1.23.0
  - text: 7.0.1
  - theming: 2.8.0
  - twofactor_backupcodes: 1.22.0
  - twofactor_totp: 15.0.0
  - updatenotification: 1.23.0
  - user_status: 1.13.0
  - viewer: 6.0.0
  - webhook_listeners: 1.5.0
  - workflowengine: 2.15.0
Disabled:
  - app_api: 33.0.0 (installed 32.0.0)
  - circles: 33.0.0 (installed 31.0.0)
  - contactsinteraction: 1.14.1 (installed 1.12.0)
  - dashboard: 7.13.0 (installed 7.11.0)
  - federation: 1.23.0 (installed 1.21.0)
  - files_external: 1.25.1 (installed 1.23.0)
  - files_trashbin: 1.23.0 (installed 1.23.0)
  - files_zip: 2.3.0 (installed 2.3.0)
  - imageconverter: 2.2.0 (installed 2.2.0)
  - photos: 6.0.0 (installed 4.0.0-dev.1)
  - ransomware_protection: 1.14.0 (installed 1.14.0)
  - richdocuments: 10.1.3 (installed 10.1.3)
  - support: 5.0.0 (installed 1.5.0)
  - survey_client: 5.0.0 (installed 1.10.0)
  - twofactor_nextcloud_notification: 7.0.0 (installed 5.0.0)
  - user_ldap: 1.24.0
  - weather_status: 1.13.0 (installed 1.1.0)

Nextcloud Signing status

No errors have been found.

Nextcloud Logs

`nextcloud.log` — download fails because keys are not at expected path:

{"reqId":"Zf3W...","level":3,"time":"2026-06-02T16:10:00+02:00","user":"stefano","app":"webdav","method":"GET","url":"/remote.php/dav/files/stefano/Documents/test.pdf","message":"Cannot decrypt this file, probably this is a shared file. Please ask the file owner to reshare the file with you.","exception":{"Exception":"OC\\Encryption\\Exceptions\\DecryptionFailedException","File":"/var/www/html/apps/encryption/lib/Crypto/Encryption.php","Line":398}}


Orphaned key directory found on filesystem (valid key, wrong path):

files_encryption/keys/files/Documents/test.pdf/OC_DEFAULT_MODULE/          ← getFileKey looks here → empty
files_encryption/keys/files/Documents/.ocTransferId123456789.part/OC_DEFAULT_MODULE/master_f9b1621e.shareKey  ← actual key is here


cURL verification — GET returns 500 when keys are in `.part` dir:

$ curl -u user:pass -X GET https://cloud.example.com/remote.php/dav/files/user/Documents/test.pdf
HTTP/1.1 500 Internal Server Error


After manually moving the `.part` key directory to the correct name, the
same cURL returns 200 — confirming the root cause is orphaned key paths.

Additional info

  • Issue #46170 reports
    the same 403 symptom but from a different trigger (load-balanced
    environment). Both share the fragile isCreatable() check.
  • Issue #40193 reports
    decryption failures on older files — the getFileKey() fallback proposed
    here may help with that class of problems as well.
  • ChunkingV2Plugin was not modified; the V1 fallback path works correctly
    with these patches (V2 requires S3-compatible object storage).
  • The fixes are intended as one-time patches for Docker-based deployments
    and do not survive Nextcloud upgrades without reapplication.

Metadata

Metadata

Assignees

No one assigned

    Labels

    0. Needs triagePending check for reproducibility or if it fits our roadmap33-feedbackbug

    Type

    No fields configured for Bug.

    Projects

    Status
    To triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions