Skip to content

Authentication & access control

WebDAV is only available on private:// files

The module refuses public:// URIs at both the mint endpoint (/webdav-url/{fid}/{target} → 403) and the WebDAV verify path (/webdav/... → 403). The reason: Drupal core's FileAccessControlHandler short-circuits download to AccessResult::allowed() for public:// files unconditionally, and the canonical hook_file_download chain isn't invoked for public files at all (the web server serves them directly). That leaves WebDAV writes on public files with no enforceable access gate — the entity-level revocation lever doesn't apply, the role-based deny lever doesn't apply, and there's no way for a site builder to opt a public file out of WebDAV access. The honest answer is to refuse outright and ask operators to store files on private:// if they want to edit them over WebDAV.

Reference for the auth models the module ships. Two coexist on the same /webdav/... URL prefix:

  • URL-token auth (default, embedded in path) — for the in-page "Open with X" UX. The auth segment <uid>-<token> carries an HMAC binding the URL to a specific (user, file) pair. Leak one URL, lose access to one file. Designed around two constraints that make Basic auth awkward for per-file delivery via the field formatter:
    • macOS Keychain caches one password per (host, realm, user) tuple — you can't deliver per-file credentials via Basic challenge.
    • LibreOffice's soffice CLI doesn't parse https://user:pw@host/file.odt, so credentials in the URL must use the path segment, not userinfo or query string.
  • Basic auth (optional, opt-in via webdav.settings:basic_auth_enabled) — for mount-style clients (Cyberduck, Mac Finder, rclone, Office desktop, automation scripts). The same (host, realm) Keychain cache that's a drawback for per-file delivery is a feature here: one credential covers every WebDAV-accessible file for that user. Leak the credential, lose access to all of that user's files — but that's the appropriate trade-off for the mount UX.

LibreOffice supports both — the choice is about delivery UX, not LibreOffice capability. mTLS is not on the table for either: verified by reading ucb/source/ucp/webdav-curl/CurlSession.cxx (zero CURLOPT_SSLCERT / CURLOPT_SSLKEY calls).

The dispatch is by literal slash count in the request path:

  • 4 slashes (/webdav/<auth>/<fid>/<filename>) → URL-token mode.
  • 3 slashes (/webdav/<fid>/<filename>) → Basic-auth mode.

Both modes run through the same per-file access chain (hook_file_download, public:// refusal, access webdav / modify webdav perm gates). The auth method differs; the authorization rules are identical.

External authentication providers (OIDC, SAML, CAS, LDAP)

URL-token mode authenticates the Drupal session, not a password. The per-user master secret is created on first WebDAV usage against an active Drupal session, regardless of how that session was obtained — local login form, OpenID Connect, SAML, CAS, LDAP, anything Drupal lets the user log in with. Deployments where local passwords are absent (or randomized at provisioning, or disabled by policy) get a fully working WebDAV surface out of the box, with no additional configuration.

Basic auth, in contrast, validates against Drupal's local password hash via the same code path as the standard login form. Users with no usable local password — the typical OIDC / SAML / CAS / LDAP pass-through case — cannot authenticate over Basic auth even when basic_auth_enabled: TRUE. For mixed populations, the Basic-auth surface is silently unavailable to the IdP-only subset; only URL-token mode covers them.

The planned webdav_app_passwords submodule (see roadmap item #1) addresses the Basic-auth gap by letting any user mint per-application credentials independent of their primary login mechanism. Until that lands, URL-token mode is the only WebDAV auth path for IdP-only accounts.

The URL shape

Every WebDAV resource URL has exactly this layout:

https://<host>/webdav/<uid>-<token>/<fid>/<filename>
Segment Meaning
<uid> Drupal user id that the URL was minted for
<token> base64url-encoded HMAC-SHA256, 43 chars
<fid> The File entity id (numeric)
<filename> Basename of the File entity's uri property

The full <uid>-<token> segment is the auth segment — it proves the bearer can read this specific file as this specific user.

A few design properties follow from this shape:

  • The URL is self-authenticating: no separate Authorization header, no cookies, no session. LibreOffice carries it verbatim through every verb (OPTIONS / PROPFIND / LOCK / GET / PUT / UNLOCK).
  • Path-based, not query-based. LO strips query strings before PUT (RFC-compliant: query is not part of the resource identifier). Tokens in ?token= survive only read-side verbs and fail on save. Path tokens survive everything.
  • One token per file. A leaked token authorizes only the file it was minted for — not the user's other files, not files belonging to other users.
  • No data-model leakage. The URL carries only the file's numeric id; it doesn't expose the parent entity type, id, or field name. A leaked URL tells the holder nothing about the surrounding content model.

How the token is derived

flowchart LR
    A["Per-user master secret<br/>(server-side, never sent)"]
    B["Per-file inputs<br/>fid:filename:version"]
    C["HMAC-SHA256"]
    D["base64url<br/>(43 chars, URL-safe)"]
    A --> C
    B --> C
    C --> D --> E["token in URL path"]

In code, Drupal\webdav\Auth\SessionPassword::derive():

$message = "$fid:$filename:$version";
$raw = hash_hmac('sha256', $message, $master, TRUE);
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');

The master secret is minted on first WebDAV usage (32 random bytes from Crypt::randomBytesBase64), stored in the non-expirable keyvalue collection webdav_master_secret keyed by user id, and revoked on logout (see hook_user_logout). It never leaves the server; only the HMAC output does.

Two consequences of HMAC being a one-way function:

  • A leaked per-file token reveals nothing about the master.
  • The master cannot be reconstructed by collecting many tokens.

The three revocation levers

Lever Scope How to trigger
Per-user All files for one user The user_logout hook (in Drupal\webdav\Hook\WebDavHooks) calls SessionPassword::revoke(), which deletes the master from the keyvalue store. Account blocking (active=0) also denies at verify time.
Per-entity All users for one entity Drupal's canonical hook_file_download chain consulted at verify time. Revoking a role's view on the entity, moving the entity to a private workspace, returning -1 from a custom hook_file_download — any of those denies the chain.
Per-file All users for one file drush webdav:revoke-file <fid> bumps a per-file counter in the webdav_file_revocation keyvalue collection. The counter is mixed into the HMAC input, so all previously-minted URLs stop verifying.

The three are independent. Pulling any one invalidates URLs in its scope without affecting the others. A typical "compromise contained, broader access preserved" workflow:

  • User reports their machine was lost: log them out → just their URLs invalidate. Other users keep working.
  • A specific document was screenshotted and shared: bump that file's counter → that file's URLs invalidate everywhere. Other documents keep working.
  • A clerk's role changed: revoke the role's entity view → every URL that user could resolve to entities they no longer view fails. Their other entity access is untouched.

Permissions

Two new permissions ship with the module, both required for WebDAV access:

Permission Required for Recommended default
access webdav Every WebDAV verb (the protocol-level on/off switch per role) Granted to roles that should be able to open documents in a desktop client
modify webdav Additionally for mutating verbs (PUT, DELETE, MOVE, COPY, MKCOL, PROPPATCH, LOCK, UNLOCK, PATCH, POST) Granted to roles that should be able to save changes back

The read/write split is the canonical lever for read-only WebDAV: grant access webdav only, and the user can open documents in LibreOffice (browse, GET, PROPFIND), but any save attempt returns 403 — LibreOffice surfaces the save error and offers Save As locally.

Per-role grants compose cleanly:

  • notary → both permissions: full read+write WebDAV.
  • clerkaccess webdav only: read documents in LO, save rejected.
  • external_counsel → neither: never reaches WebDAV. Documents are still downloadable via the standard browser link (see Fallback rendering below).

Two admin-only permissions complete the surface:

Permission Purpose
administer webdav locks Inspect and force-release stale locks at /admin/reports/webdav-locks
administer webdav Manage WebDAV link targets (the "Open with X" protocols offered by the link field formatter)

The verification flow

What happens when a /webdav/<uid>-<token>/.../ request lands on the server, end to end:

flowchart TD
    A["Symfony Request"] --> B["WebDAVMiddleware<br/>(after page_cache + kernel_pre_handle,<br/>before session)"]
    B --> C{"Path<br/>/webdav/<auth>/...<br/>shape"}
    C -->|"malformed"| Z["404"]
    C -->|"OK"| D["SessionPassword::<br/>verifyAuthSegment"]
    D --> E1{"uid > 0"}
    E1 -->|"no"| Y["403"]
    E1 -->|"yes"| E2{"isActive()"}
    E2 -->|"blocked"| Y
    E2 -->|"yes"| E3{"hasPermission<br/>'access webdav'"}
    E3 -->|"no"| Y
    E3 -->|"yes"| F["Load File by fid<br/>+ filename match<br/>+ revocation version"]
    F --> G{"HMAC matches?"}
    G -->|"no"| Y
    G -->|"yes"| H["canDownloadAs:<br/>hook_file_download chain<br/>under switched account"]
    H -->|"deny"| Y
    H -->|"allow"| I{"Write verb?<br/>(PUT, DELETE, …)"}
    I -->|"yes"| J{"hasPermission<br/>'modify webdav'"}
    I -->|"no"| K["Hand off to sabre/dav"]
    J -->|"no"| Y
    J -->|"yes"| K
    Y --> L["Log warning on<br/>'webdav' channel"]

Every rejection path logs a warning on the webdav channel so probing attempts surface in dblog (or anywhere else the site routes the webdav logger channel).

Fallback rendering

A user without access webdav sees a plain <a href="/system/files/...">Download <filename></a> instead of the WebDAV-scheme buttons:

flowchart LR
    U["User views entity field"] --> P{"hasPermission<br/>'access webdav'"}
    P -->|"yes"| W["WebDAV-scheme links:<br/>libreoffice:, ms-word:, …"]
    P -->|"no"| D["Plain download link:<br/>/system/files/&lt;path&gt;"]

For private:// files, the fallback link goes through Drupal's standard FileDownloadController which consults hook_file_download — so entity-view access is still enforced via the same chain WebDAV uses. The two surfaces share the file-access decision; only the delivery path differs.

For public:// files, the link is served directly by Apache / nginx with no Drupal-side access check. That's the standard public:// semantic. WebDAV scheme links (libreoffice:, ms-word:, …) are not rendered for public files — the mint endpoint refuses them — so users with access webdav see only the direct-download fallback for public-bucket content. See the warning callout at the top of this page for the full rationale.

Threat model

What the design defends against:

  • Cross-user URL leak. Per-user master secrets + per-user HMAC tokens, AND the field formatter no longer bakes the auth segment into the rendered HTML at all — the formatter emits (fid, target_id) data attributes only, and the JS click handler POSTs to /webdav-url/{fid}/{target} at click time to mint the per-user URL. The mint response carries Cache-Control: no-store, private, so no cache tier between the controller and the browser holds the URL. User A's URL never reaches User B's browser at all — not just "not via the render cache".
  • Mass-credential compromise. No global "WebDAV password" exists. A leaked token authorizes one file under one user; it doesn't unlock anything else.
  • Master-secret regeneration via collected tokens. HMAC is one-way; gathering many tokens reveals nothing about the master.
  • Token timing attacks. Verification uses hash_equals, constant-time. The token alphabet is [A-Za-z0-9_-] (43 chars, ~256 bits of entropy) so brute force is not a practical concern.
  • Stale URLs after logout / block / role change. All three revocation levers kick in at verify time, not just at mint time. URLs minted minutes ago stop working the moment the conditions change.
  • Probing without trace. Every auth failure emits a structured warning on the webdav logger channel.
  • Sibling enumeration. The synthetic parent collection in the WebDAV tree contains exactly one File — the one the auth segment was minted for. PROPFIND on the parent dir never enumerates other files in the same field / entity. A leaked token authorizes precisely that file, with no expansion surface.

What the design does not defend against (acceptable residual risks, with stated mitigations):

  • Bearer-token capture. Anyone with the live URL has authorization until something invalidates it. Mitigations: HTTPS (mandatory), short master TTL (default 7 days), three revocation levers, no userinfo or query-string parts so the URL stays out of browser history under private://.
  • Session expiry without explicit logout. Drupal core does not fire hook_user_logout when a session simply times out; it only fires on explicit logout. Tokens minted before the session expired remain valid against the still-present master until the master TTL elapses. Mitigation: the module defaults the master TTL to Drupal's session.storage.options.gc_maxlifetime so the two windows naturally align. Operators can override via webdav.settings:master_secret_ttl_seconds. The sliding-window TTL refresh on mint keeps active users from being kicked out mid-session.
  • TLS termination at a misconfigured proxy. If the proxy doesn't forward X-Forwarded-Proto: https, Drupal renders WebDAV URLs as http://, LibreOffice sends the token in cleartext, and HMAC's protection is lost on the wire. Mitigation: $settings['reverse_proxy'] = TRUE plus reverse_proxy_trusted_headers covering X_FORWARDED_PROTO.
  • Compromised database. Anyone with read access to the webdav_master_secret keyvalue table can mint arbitrary tokens for any user. Optional defense: enable the webdav_key submodule to encrypt the stored masters at rest with a key managed by drupal/key. A DB dump alone then becomes useless — the attacker also needs whichever credential controls the key provider.
  • Compromised server. Standard "if you root the server it's over" territory — not solvable without an HSM-backed signing service, which is out of scope for this module.
  • URLs that outlive a content edit. A WebDAV PUT updates the file bytes but does not auto-bump the per-file revocation counter. A token minted before the PUT (shared via chat, e.g. attached to a screenshot) still resolves to the file after the edit — and now points at content the original sharer may not have intended to expose. This is by design: auto-bumping on PUT would invalidate every other user's outstanding URL for the same file every time anyone edited it, which is far too disruptive for collaborative editing UX. Mitigation: operators bump manually after a sensitive edit via drush webdav:revoke-file <fid>, or wait for the master TTL to age the user's URLs out.

Operational notes

  • HTTPS is mandatory. The whole scheme falls apart on HTTP because the token travels in cleartext in the URL path. Enforce HTTPS at the deployment layer (reverse proxy, load balancer, edge).
  • Master secret idle TTL. Sliding-window TTL counted from the user's last activity, not from master creation. By default it derives from session.storage.options.gc_maxlifetime so the WebDAV master ages out around the same time inactivity would invalidate the user's authenticated Drupal session. Operators can override via webdav.settings:master_secret_ttl_seconds (range 60–31536000 = 1 minute to 1 year). Every URL mint refreshes the stored master's expiry, so an active user's URLs never expire under their feet; only inactivity invalidates them. Set with e.g. drush config:set webdav.settings master_secret_ttl_seconds 86400.
  • Server access logs contain full tokens. The auth segment travels in the URL path, so Apache / nginx access logs capture it on every request. A leaked log file exposes every token it recorded (within the master TTL). Scrub or short-rotate access logs on hosts that serve /webdav/, or use a log format that redacts the path. Consider also disabling URL-path logging on any external collector that ingests them.
  • Drush commands.
  • drush webdav:revoke-file <fid> — invalidate every URL minted for a specific file. Common after a file-specific leak.
  • private:// deployment. $settings['file_private_path'] must point at a directory outside the docroot. Drupal emits a requirements() warning on misconfiguration; treat that warning as critical. Files under public:// are served directly by Apache without any access check, including the WebDAV-routed copies, so notarial content should always live under private://.
  • Format-preserving PUTs. A WebDAV PUT whose request Content-Type header doesn't match the existing File entity's filemime is rejected with 403. This catches the typical accidental Save-As (e.g. clicking "Save As DOCX" on an open ODT), which would otherwise silently desynchronize Drupal's filemime from the bytes on disk — EntityFileNode::put() only writes bytes, it never re-derives the mime. The check is a UX guardrail, not a security feature: a deliberately-lying client could spoof the Content-Type. Comprehensive content-based format validation, if you ever need it, belongs at Drupal's entity-API layer, not in this module. To change a file's format intentionally, upload via the Drupal file UI (which validates extension + creates a new File entity).
  • WebDAV lock persistence depends on the sabre lock backend. The default backend is Sabre\DAV\Locks\Backend\File, which serializes the lock list to getTempDirectory() . '/webdav/locks.data'. On containerized deployments the temp directory evaporates with the container, so a restart drops every active LOCK — a desktop client still holding a stale Lock-Token will receive 409 Conflict on its next UNLOCK (typically surfaced as a "Save failed" dialog in LibreOffice on close). Force-released locks (via /admin/reports/webdav-locks) produce the same client symptom on purpose: the operator's intent is to break the lock, the noisy error is the natural backpressure that warns the holder their state diverged from the server's.

Two ways to address the reset — only the default file backend has this property:

  1. Enable the webdav_lock_pdo submodule (recommended). Decorates the webdav.lock_backend and webdav.lock_manager services so every LOCK / UNLOCK and every admin lock-list query goes through the database instead. Locks survive container restarts. The submodule ships a hook_schema() for the webdav_locks table and reuses Drupal's active database connection — no separate connection string to configure. Enable via drush en webdav_lock_pdo or the Extend page. Uninstall drops the table; reverting to the file backend on the next request loses any locks held in the DB at the time, which is fine for a planned switch.
  2. Point getTempDirectory() at a persistent volume. Zero code change; the default file backend then survives container restarts via volume persistence rather than DB persistence. Smaller blast radius if you don't want the extra table.

Beyond persistence: any module can register its own Sabre\DAV\Locks\Backend\BackendInterface implementation as webdav.lock_backend (and the matching LockManagerInterface as webdav.lock_manager) to swap in a different store — Memcached, Redis, an external lock service, anything that satisfies the contract. The audit-event dispatch (WebDavEvents::LOCK_ACQUIRED, LOCK_RELEASED, LOCK_FORCE_RELEASED) lives in the sabre plugin layer and is backend-agnostic, so swapping backends does not break audit trail integration.

Component Class / file
Per-user master + per-file HMAC + verify Drupal\webdav\Auth\SessionPassword
Per-file revocation counter Drupal\webdav\Auth\RevocationStore
HTTP entry point + write-verb gate Drupal\webdav\StackMiddleware\WebDAVMiddleware
Format-preservation guardrail on PUT Drupal\webdav\Plugin\MimePreservationPlugin
Logout hook Drupal\webdav\Hook\WebDavHooks::userLogout()
File deletion cleanup Drupal\webdav\Hook\WebDavHooks::fileDelete()
Field rendering (per-user URLs + fallback) Drupal\webdav\Plugin\Field\FieldFormatter\WebDavLinkFormatter
Drush commands Drupal\webdav\Drush\Commands\WebDAVCommands