Skip to content

[Bug]: PROPFIND on /remote.php/webdav returns 207 instead of 404 for non-existent paths since 33.0.7 (streamed multistatus commits the status code before the node is resolved) #63415

Description

@ckbaker10

⚠️ This issue respects the following points: ⚠️

Bug description

PROPFIND against the legacy WebDAV endpoint /remote.php/webdav/<anything> returns
207 Multi-Status for every path, including paths that do not exist. It should return
404 Not Found. The modern endpoint /remote.php/dav/files/<uid>/... is unaffected and
still returns 404.

This breaks Windows mapped network drives. Explorer's "New Folder" flow probes for a free
name with PROPFIND and only sends MKCOL once it gets a 404. Since the legacy endpoint
never answers 404, Explorer concludes every candidate name is taken, never sends MKCOL,
and throws its own "Access denied" dialog. No 403 is ever returned, and — this is the fun
part — nothing whatsoever is written to nextcloud.log, because ExceptionLoggerPlugin
classifies NotFound as non-fatal. So from the server's point of view the request is a
complete success, and from the user's point of view the share is broken. Enjoy explaining
that one to your users.

I lost most of a day to this before giving up on the "obviously it's a permissions problem"
theory and reading the diff. Root cause, for whoever gets to fix it:

Server::$streamMultiStatus = true; in
apps/dav/lib/Connector/Sabre/ServerFactory.php:83.

Introduced by commit ab34c4c21884 "perf(dav): Stream PROPFIND output", backported to
stable33 as #62225, first shipped in 33.0.7. It is the only occurrence of that flag
in the entire tree, and it is set unconditionally.

The chain (sabre/dav 4.7.0, as vendored):

  1. CorePlugin::httpPropFind() line 327 calls Server::getPropertiesIteratorForPath().
    That method contains yield, so it is a generator — calling it executes none of its
    body. The $this->tree->getNodeForPath($path) inside it (Server.php:971) never runs at
    this point.
  2. Line 330, immediately after: $response->setStatus(207). Unconditionally, with nothing
    resolved and nothing validated.
  3. Line 346: generateMultiStatus(). With streaming enabled (Server.php:1641-1646) this
    returns a closure rather than a serialized body. Still nothing iterated.
  4. Server::invokeMethod() line 490 → Sapi::sendResponse() line 64 flushes the 207
    status line and headers
    , then line 81 invokes the closure.
  5. Now the generator finally runs, ObjectTree::getNodeForPath() throws
    Sabre\DAV\Exception\NotFound as it always has, and the catch block in
    OCA\DAV\Connector\Sabre\Server::start() dutifully sets the status to 404 and re-sends —
    into a response whose headers left the building one stack frame ago.

So the 404 logic is fine. It just runs after the status code has already been committed to
the wire. The body that comes back is a degenerate multistatus: writeMultiStatus() gets as
far as the XML prolog and the opening <d:multistatus>, then the first loop iteration
throws, $w->flush() never runs, and the d:error document from the exception handler is
appended on top. That is why the responses are a uniform ~1150–1350 bytes regardless of
which non-existent path you ask for.

Affected endpoints — everything routed through ServerFactory:

Endpoint Entry point Affected
/remote.php/webdav apps/dav/appinfo/v1/webdav.php ❌ yes
/remote.php/files same file (remote.php:87) ❌ yes
/public.php/webdav apps/dav/appinfo/v1/publicwebdav.php ❌ yes
/public.php/dav apps/dav/appinfo/v2/publicremote.php ❌ yes
/remote.php/dav apps/dav/appinfo/v2/remote.phpOCA\DAV\Server ✅ no

/remote.php/dav never touches the flag, which is exactly why the modern endpoint still
behaves. That asymmetry is the whole tell.

I also checked whether any plugin on the legacy server happens to resolve the node during
beforeMethod:PROPFIND and would incidentally save us. None does: LockPlugin returns
early unless the method is PUT, ViewOnlyPlugin only hooks GET/COPY/MOVE, and
FilesPlugin hooks propFind, which fires inside the generator and is therefore also too
late. Nothing catches this.

Version status:

Version streamMultiStatus present
32.0.13 no
33.0.5 no
33.0.6 no
33.0.7 yes ← regression enters
34.0.3 yes
master, stable33, stable34 (checked at time of writing) yes

Reproduced live on 33.0.7.1. I then pulled the 34.0.3 release tarball and audited it
line-by-line specifically to find out whether upgrading would save me. It would not.

Steps to reproduce

You need an app password — SAML/SSO accounts can't do basic auth against WebDAV.

  1. Create an app password for a test account (Settings → Security → Devices & sessions).
  2. PROPFIND a path that definitively does not exist, on both endpoints:
NCUSER='testuser'
NCPASS='xxxxx-xxxxx-xxxxx-xxxxx-xxxxx'
HOST='https://cloud.example.com'
MISSING="__nonexistent_$(date +%s)__"

# legacy endpoint  -> returns 207, should be 404
curl -s -o /dev/null -w 'legacy: %{http_code}\n' -u "$NCUSER:$NCPASS" \
     -X PROPFIND -H 'Depth: 0' "$HOST/remote.php/webdav/$MISSING"

# modern endpoint  -> correctly returns 404
curl -s -o /dev/null -w 'modern: %{http_code}\n' -u "$NCUSER:$NCPASS" \
     -X PROPFIND -H 'Depth: 0' "$HOST/remote.php/dav/files/$NCUSER/$MISSING"

Observed:

legacy: 207
modern: 404
  1. Inspect the legacy body and note that it is not a real multistatus:
curl -s -u "$NCUSER:$NCPASS" -X PROPFIND -H 'Depth: 0' \
     "$HOST/remote.php/webdav/$MISSING" | head -c 400
  1. Optional, the way real users hit it: map a network drive in Windows Explorer to
    https://cloud.example.com/remote.php/webdav, then right-click → New → Folder.
    The folder is never created; Explorer shows "Access denied". The access log shows a burst
    of PROPFIND … 207 for Neuer Ordner / New folder and desktop.ini, and no
    MKCOL is ever sent, because Explorer is still waiting for a 404 that will never come:
203.0.113.10 [.../...] "PROPFIND /remote.php/webdav/Foo/Bar"                  207 1334
203.0.113.10 [.../...] "PROPFIND /remote.php/webdav/Foo/Bar/Neuer%20Ordner"   207 1155   <- should be 404
203.0.113.10 [.../...] "PROPFIND /remote.php/webdav/Foo/Bar/desktop.ini"      207 1154   <- should be 404

For comparison, the same operation against /remote.php/dav/files/<uid>/ on the same server,
same minute, working exactly as intended:

198.51.100.20 [.../...] "PROPFIND .../Foo/Neuer%20Ordner"   404
198.51.100.20 [.../...] "MKCOL    .../Foo/Neuer%20Ordner"   201
198.51.100.20 [.../...] "PROPPATCH .../Foo/Neuer%20Ordner"  207
198.51.100.20 [.../...] "MOVE     .../Foo/Neuer%20Ordner"   201
  1. If you want the aggregate version: grep your access log for
    "PROPFIND /remote.php/webdav. On 33.0.6 you get a steady stream of 404s every single
    day. From the moment 33.0.7 is installed you get exactly zero, forever. In my case
    that was ~265 000 legacy PROPFINDs across twelve days without a single 404. The cutover
    is visible to the second in the log, matched against the updater timestamp.

Expected behavior

PROPFIND on a non-existent path returns 404 Not Found on the legacy endpoint, the same
way it does on /remote.php/dav and the same way it did in 33.0.6 and every release before
it. 207 Multi-Status should mean the resource exists and here are its properties, not
"I already sent the headers, sorry".

Concretely: the node must be resolved before the status code is committed. The streaming
optimisation is fine in principle, it just needs to prime the generator first. Priming with
a single ->current() on the iterator before setStatus(207) runs the generator up to its
first yield, which triggers getNodeForPath() and lets NotFound propagate while the
status is still mutable — and a subsequent foreach still works, since the generator has not
advanced past the first yield. That preserves the perf win for the normal case.

Failing that: make the flag opt-in via a config value, or revert the line on the stable
branches until it can be done safely. Silently converting every 404 into a 207 on an endpoint
that Windows depends on is not a great trade for saving some memory on PROPFIND.

Nextcloud Server version

34

Operating system

Debian/Ubuntu

PHP engine version

PHP 8.3

Web server

Apache (supported)

Database engine version

MariaDB

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

Fresh Nextcloud Server install

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

{
    "system": {
        "instanceid": "***REMOVED SENSITIVE VALUE***",
        "passwordsalt": "***REMOVED SENSITIVE VALUE***",
        "secret": "***REMOVED SENSITIVE VALUE***",
        "trusted_domains": [
            "domain.cloud.de",
        ],
        "trusted_proxies": "***REMOVED SENSITIVE VALUE***",
        "datadirectory": "***REMOVED SENSITIVE VALUE***",
        "overwrite.cli.url": "https:\/\/domain.cloud.de",
        "overwriteprotocol": "https",
        "dbtype": "mysql",
        "version": "33.0.7.1",
        "dbname": "***REMOVED SENSITIVE VALUE***",
        "dbhost": "***REMOVED SENSITIVE VALUE***",
        "dbport": "3306",
        "dbtableprefix": "oc_",
        "dbuser": "***REMOVED SENSITIVE VALUE***",
        "dbpassword": "***REMOVED SENSITIVE VALUE***",
        "installed": true,
        "appstore.experimental.enabled": true,
        "loglevel": 3,
        "log_rotate_size": 52428800,
        "updater.release.channel": "stable",
        "skeletondirectory": "",
        "maintenance": false,
        "memcache.local": "\\OC\\Memcache\\APCu",
        "mail_smtpmode": "smtp",
        "mail_smtpauthtype": "LOGIN",
        "mail_from_address": "***REMOVED SENSITIVE VALUE***",
        "mail_domain": "***REMOVED SENSITIVE VALUE***",
        "mail_smtphost": "***REMOVED SENSITIVE VALUE***",
        "mail_smtpport": "25",
        "ldapIgnoreNamingRules": false,
        "ldapProviderFactory": "\\OCA\\User_LDAP\\LDAPProviderFactory",
        "auth.bruteforce.protection.enabled": false,
        "trashbin_retention_obligation": "30,90",
        "mysql.utf8mb4": true,
        "activitiy_expire_days": 90,
        "mail_sendmailmode": "smtp",
        "default_phone_region": "DE",
        "filelocking.enabled": true,
        "memcache.distributed": "\\OC\\Memcache\\Redis",
        "memcache.locking": "\\OC\\Memcache\\Redis",
        "redis": {
            "host": "***REMOVED SENSITIVE VALUE***",
            "port": 6379,
            "timeout": 0,
            "password": "***REMOVED SENSITIVE VALUE***"
        },
        "maintenance_window_start": 1
    }
}


Relevant bits from mine, redacted:


Nextcloud       33.0.7.1  (versionstring 33.0.7), dav app 1.36.0
                also verified against 34.0.3 (dav 1.40.0) by source audit
PHP             8.2.33 (NTS)
Web server      Apache 2, TLS terminated locally, no reverse proxy in front
Database        MariaDB
Memcache.local  \OC\Memcache\APCu
Memcache.distributed / locking   \OC\Memcache\Redis
User backend    SAML (user_saml) plus local accounts


No reverse proxy, no WAF, no CDN — the web server is the edge, so nothing external is
rewriting status codes. Ruled that out early.

List of activated Apps

Enabled:
  - activity: 6.0.0
  - bruteforcesettings: 6.0.0
  - calendar: 6.5.2
  - circles: 33.0.0
  - cloud_federation_api: 1.17.0
  - comments: 1.23.0
  - contacts: 8.7.5
  - contactsinteraction: 1.14.1
  - dav: 1.36.0
  - federatedfilesharing: 1.23.0
  - federation: 1.23.0
  - files: 2.5.0
  - files_antivirus: 6.3.2
  - files_downloadlimit: 5.1.0
  - files_pdfviewer: 6.0.0
  - files_reminders: 1.6.0
  - files_sharing: 1.25.2
  - files_trashbin: 1.23.0
  - files_versions: 1.26.0
  - groupfolders: 21.0.13
  - impersonate: 4.0.0
  - logreader: 6.0.0
  - lookup_server_connector: 1.21.0
  - notifications: 6.0.0
  - oauth2: 1.21.0
  - onlyoffice: 10.1.2
  - password_policy: 5.0.0
  - photos: 6.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
  - survey_client: 5.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_saml: 8.2.0
  - viewer: 6.0.0
  - webhook_listeners: 1.5.0
  - workflowengine: 2.15.0
Disabled:
  - admin_audit: 1.23.0
  - app_api: 33.0.0 (installed 32.0.0)
  - dashboard: 7.13.0 (installed 7.0.0)
  - encryption: 2.21.0
  - files_external: 1.25.1
  - firstrunwizard: 6.0.0 (installed 2.2.1)
  - nextcloud_announcements: 5.0.0 (installed 1.5.0)
  - sharelisting: 1.3.0 (installed 1.3.0)
  - support: 5.0.0 (installed 4.0.0)
  - suspicious_login: 11.0.0
  - twofactor_nextcloud_notification: 7.0.0
  - user_ldap: 1.24.0 (installed 1.23.0)
  - user_status: 1.13.0 (installed 1.0.1)
  - weather_status: 1.13.0 (installed 1.0.0)

Worth noting from mine, since they're the usual suspects and none of them are involved:

- `groupfolders` is installed but **no group folders are configured**
- `files_accesscontrol` is **not** installed
- `files_external` is **not** enabled — the storage in question is a plain local home storage
- share permissions on the affected folder are `31` (full)

The bug reproduces on a brand-new empty path in the user's own home directory, so none of
this matters, but I'll save you the round trip of asking.

Nextcloud Signing status

No errors have been found.

Nextcloud Logs

(empty)


That is not laziness — there is genuinely nothing to paste. `NotFound` is on the
`nonFatalExceptions` list in
[`apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php`](https://github.com/nextcloud/server/blob/master/apps/dav/lib/Connector/Sabre/ExceptionLoggerPlugin.php),
so the exception that *should* have produced the 404 is logged at debug level at most and
the request is otherwise recorded as a success. Zero log entries for the affected client IP
across the entire incident window, at default log level. This is a large part of why the bug
is so annoying to track down: every observable signal says the server is healthy.

Additional info

  • Regression range: absent in 32.0.13, 33.0.5, 33.0.6 — present in 33.0.7. Bisected by
    diffing ServerFactory.php across release tags.
  • Culprit: ab34c4c21884 "perf(dav): Stream PROPFIND output", backport PR [stable33] perf(dav): Stream PROPFIND output #62225
    into stable33. Still present on master, stable33 and stable34.
  • Clients affected: anything that relies on 404 to test for existence before writing.
    Microsoft-WebDAV-MiniRedir (Windows mapped drives) is the obvious one and the one that
    hurts, since it can no longer create folders or files at all. Anything doing
    PROPFIND-then-PUT against the legacy endpoint is presumably equally broken.
  • Not affected: the Nextcloud desktop/mobile clients, since they use /remote.php/dav.
    Which is probably why this got through CI and a full point release.
  • Red herring, flagged so nobody else burns an afternoon on it: server-wide 403 counts
    also collapse to zero at the same upgrade. That part is not this bug — most of those
    403s were forbidden-filename blocking of desktop.ini, and that behaviour was changed
    deliberately; those requests now correctly return 404 on the modern endpoint. "The 403s
    came back" is not a fix signal for this issue. The only reliable signal is 404 returning
    to the legacy endpoint.
  • Workaround for anyone finding this issue via search: remap affected clients from
    https://cloud.example.com/remote.php/webdav to
    https://cloud.example.com/remote.php/dav/files/<uid>/. Note that <uid> is the internal
    user ID, which for SSO accounts may be a GUID rather than the login name — get it with
    occ user:info <login>. Alternatively, delete the one line at ServerFactory.php:83
    locally, at the cost of the streaming optimisation and your integrity check.
  • I'm happy to test a patch against 33.x or 34.x on a staging instance.

Metadata

Metadata

Assignees

Labels

Type

Projects

Status
Triaged

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions