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
sofficeCLI doesn't parsehttps://user:pw@host/file.odt, so credentials in the URL must use the path segment, not userinfo or query string.
- macOS Keychain caches one password per
- 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.clerk→access webdavonly: 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/<path>"]
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 carriesCache-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
webdavlogger 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_logoutwhen 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'ssession.storage.options.gc_maxlifetimeso the two windows naturally align. Operators can override viawebdav.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 ashttp://, LibreOffice sends the token in cleartext, and HMAC's protection is lost on the wire. Mitigation:$settings['reverse_proxy'] = TRUEplusreverse_proxy_trusted_headerscoveringX_FORWARDED_PROTO. - Compromised database. Anyone with read access to the
webdav_master_secretkeyvalue table can mint arbitrary tokens for any user. Optional defense: enable thewebdav_keysubmodule to encrypt the stored masters at rest with a key managed bydrupal/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_maxlifetimeso the WebDAV master ages out around the same time inactivity would invalidate the user's authenticated Drupal session. Operators can override viawebdav.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 arequirements()warning on misconfiguration; treat that warning as critical. Files underpublic://are served directly by Apache without any access check, including the WebDAV-routed copies, so notarial content should always live underprivate://.- Format-preserving PUTs. A WebDAV
PUTwhose requestContent-Typeheader doesn't match the existing File entity'sfilemimeis rejected with403. This catches the typical accidental Save-As (e.g. clicking "Save As DOCX" on an open ODT), which would otherwise silently desynchronize Drupal'sfilemimefrom 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 theContent-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 togetTempDirectory() . '/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 staleLock-Tokenwill receive409 Conflicton its nextUNLOCK(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:
- Enable the
webdav_lock_pdosubmodule (recommended). Decorates thewebdav.lock_backendandwebdav.lock_managerservices so every LOCK / UNLOCK and every admin lock-list query goes through the database instead. Locks survive container restarts. The submodule ships ahook_schema()for thewebdav_lockstable and reuses Drupal's active database connection — no separate connection string to configure. Enable viadrush en webdav_lock_pdoor 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. - 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.
Related code¶
| 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 |