Skip to content

Commit 6e539c2

Browse files
author
tchapi
committed
chore
1 parent cb29fd2 commit 6e539c2

7 files changed

Lines changed: 342 additions & 33 deletions

File tree

.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ INVITE_FROM_ADDRESS=no-reply@example.org
8585
# USE ABSOLUTE PATHS for better predictability
8686
WEBDAV_TMP_DIR='/webdav/tmp'
8787
WEBDAV_PUBLIC_DIR='/webdav/public'
88+
# The public directory is readable by every authenticated user. By default only admins
89+
# (users flagged as such in the dashboard) can create, modify or delete files in it.
90+
# Set this to true to let every authenticated user write to it (shared drop folder).
91+
WEBDAV_PUBLIC_DIR_WRITABLE=false
8892
# By default, home directories are disabled totally (env var set to an empty string).
8993
# If needed, it is recommended to use a folder that is NOT a child of the public dir,
9094
# such as /webdav/homes for instance, so that users cannot access other users' homes.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,14 @@ BIRTHDAY_REMINDER_OFFSET=false
194194
```shell
195195
WEBDAV_TMP_DIR=/webdav/tmp
196196
WEBDAV_PUBLIC_DIR=/webdav/public
197+
WEBDAV_PUBLIC_DIR_WRITABLE=false
197198
WEBDAV_HOMES_DIR=
198199
```
199200

201+
> [!NOTE]
202+
>
203+
> The public directory (served at `/dav/public`) is readable by every authenticated user. By default only users flagged as admins in the dashboard can create, modify or delete files in it; set `WEBDAV_PUBLIC_DIR_WRITABLE=true` to let every authenticated user write to it. Directories must be absolute paths and must not live inside the web root.
204+
200205
> [!NOTE]
201206
>
202207
> In a docker setup, I recommend setting `WEBDAV_TMP_DIR` to `/tmp`.

config/services.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ parameters:
1212
birthday_reminder_offset: '%env(default:default_birthday_reminder_offset:BIRTHDAY_REMINDER_OFFSET)%'
1313
default_birthday_reminder_offset: "PT9H"
1414
caldav_enabled: "%env(bool:CALDAV_ENABLED)%"
15+
default_webdav_public_dir_writable: "false"
1516
carddav_enabled: "%env(bool:CARDDAV_ENABLED)%"
1617

1718
services:
@@ -64,6 +65,7 @@ services:
6465
$webdavPublicDir: "%env(resolve:WEBDAV_PUBLIC_DIR)%"
6566
$webdavHomesDir: "%env(resolve:WEBDAV_HOMES_DIR)%"
6667
$webdavTmpDir: "%env(resolve:WEBDAV_TMP_DIR)%"
68+
$webdavPublicDirWritable: "%env(bool:default:default_webdav_public_dir_writable:WEBDAV_PUBLIC_DIR_WRITABLE)%"
6769

6870
App\Security\LoginFormAuthenticator:
6971
arguments:

docker/.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ LDAP_CERTIFICATE_CHECKING_STRATEGY=try # never, hard, demand, try, or allow
4747
# WebDAV settings
4848
WEBDAV_TMP_DIR=/webdav/tmp
4949
WEBDAV_PUBLIC_DIR=/webdav/public
50+
WEBDAV_PUBLIC_DIR_WRITABLE=false
5051
WEBDAV_HOMES_DIR=
5152

5253
# Mail settings

src/Controller/DAVController.php

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use App\Entity\User;
77
use App\Plugins\BirthdayCalendarPlugin;
88
use App\Plugins\DavisIMipPlugin;
9+
use App\Plugins\DavisTemporaryFileFilterPlugin;
910
use App\Plugins\PublicAwareDAVACLPlugin;
1011
use App\Services\BasicAuth;
1112
use App\Services\BirthdayService;
@@ -92,6 +93,14 @@ class DAVController extends AbstractController
9293
*/
9394
protected $webdavTmpDir;
9495

96+
/**
97+
* Can every authenticated user write to the WebDAV public directory
98+
* (otherwise only admins can, everybody can read).
99+
*
100+
* @var bool
101+
*/
102+
protected $webdavPublicDirWritable;
103+
95104
/**
96105
* @var EntityManagerInterface
97106
*/
@@ -149,7 +158,7 @@ class DAVController extends AbstractController
149158
*/
150159
protected $server;
151160

152-
public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, BirthdayService $birthdayService, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null)
161+
public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend, IMAPAuth $IMAPAuthBackend, LDAPAuth $LDAPAuthBackend, UrlGeneratorInterface $router, EntityManagerInterface $entityManager, LoggerInterface $logger, BirthdayService $birthdayService, string $publicDir, bool $calDAVEnabled = true, bool $cardDAVEnabled = true, bool $webDAVEnabled = false, bool $publicCalendarsEnabled = true, ?string $inviteAddress = null, ?string $authMethod = null, ?string $authRealm = null, ?string $webdavPublicDir = null, ?string $webdavHomesDir = null, ?string $webdavTmpDir = null, bool $webdavPublicDirWritable = false)
153162
{
154163
$this->publicDir = $publicDir;
155164

@@ -162,6 +171,7 @@ public function __construct(MailerInterface $mailer, BasicAuth $basicAuthBackend
162171
$this->webdavPublicDir = $webdavPublicDir;
163172
$this->webdavHomesDir = $webdavHomesDir;
164173
$this->webdavTmpDir = $webdavTmpDir;
174+
$this->webdavPublicDirWritable = $webdavPublicDirWritable;
165175

166176
$this->em = $entityManager;
167177
$this->logger = $logger;
@@ -222,6 +232,7 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL
222232
];
223233

224234
if ($this->webdavHomesDir) {
235+
$this->assertWebdavDirectory($this->webdavHomesDir, 'WEBDAV_HOMES_DIR');
225236
$nodes[] = new \Sabre\DAVACL\FS\HomeCollection($principalBackend, $this->webdavHomesDir);
226237
}
227238

@@ -234,7 +245,19 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL
234245
$nodes[] = new \Sabre\CardDAV\AddressBookRoot($principalBackend, $carddavBackend);
235246
}
236247
if ($this->webDAVEnabled && $this->webdavTmpDir && $this->webdavPublicDir) {
237-
$nodes[] = new \Sabre\DAV\FS\Directory($this->webdavPublicDir);
248+
$this->assertWebdavDirectory($this->webdavTmpDir, 'WEBDAV_TMP_DIR');
249+
$this->assertWebdavDirectory($this->webdavPublicDir, 'WEBDAV_PUBLIC_DIR');
250+
251+
// Explicit ACL for the shared directory: every authenticated user can read it, and
252+
// writing is reserved to admins (the ACL plugin grants them every privilege) unless
253+
// WEBDAV_PUBLIC_DIR_WRITABLE opens it to everyone. Children inherit this ACL.
254+
$publicDirAcl = [
255+
['principal' => '{DAV:}authenticated', 'privilege' => '{DAV:}read', 'protected' => true],
256+
];
257+
if ($this->webdavPublicDirWritable) {
258+
$publicDirAcl[] = ['principal' => '{DAV:}authenticated', 'privilege' => '{DAV:}write', 'protected' => true];
259+
}
260+
$nodes[] = new \Sabre\DAVACL\FS\Collection($this->webdavPublicDir, $publicDirAcl);
238261
}
239262

240263
// The object tree needs in turn to be passed to the server class
@@ -287,13 +310,34 @@ private function initServer(string $authMethod, string $authRealm = User::DEFAUL
287310

288311
// WebDAV plugins
289312
if ($this->webDAVEnabled && $this->webdavTmpDir && $this->webdavPublicDir) {
290-
if (!is_dir($this->webdavTmpDir) || !is_dir($this->webdavPublicDir)) {
291-
throw new \Exception('The WebDAV temp dir and/or public dir are not available. Make sure they are created with the correct permissions.');
292-
}
293313
$lockBackend = new \Sabre\DAV\Locks\Backend\File($this->webdavTmpDir.'/locksdb');
294314
$this->server->addPlugin(new \Sabre\DAV\Locks\Plugin($lockBackend));
295315
$this->server->addPlugin(new \Sabre\DAV\Browser\GuessContentType());
296-
$this->server->addPlugin(new \Sabre\DAV\TemporaryFileFilterPlugin($this->webdavTmpDir));
316+
// Temporary files must obey the ACL of their directory (see the plugin for the why)
317+
$this->server->addPlugin(new DavisTemporaryFileFilterPlugin($this->webdavTmpDir));
318+
}
319+
}
320+
321+
/**
322+
* A WebDAV directory must exist, be given as an absolute path (a relative one would be
323+
* resolved against the PHP process' working directory, which is not predictable) and
324+
* must not live inside the web root, where the web server would serve its content
325+
* directly and bypass every DAV permission check.
326+
*/
327+
private function assertWebdavDirectory(string $dir, string $envVar): void
328+
{
329+
if (!str_starts_with($dir, '/')) {
330+
throw new \RuntimeException(sprintf('%s must be an absolute path, "%s" given.', $envVar, $dir));
331+
}
332+
333+
$realDir = realpath($dir);
334+
if (false === $realDir || !is_dir($realDir)) {
335+
throw new \RuntimeException(sprintf('%s points to "%s", which does not exist or is not a directory. Make sure it is created with the correct permissions.', $envVar, $dir));
336+
}
337+
338+
$webRoot = realpath($this->publicDir);
339+
if (false !== $webRoot && ($realDir === $webRoot || str_starts_with($realDir.'/', $webRoot.'/'))) {
340+
throw new \RuntimeException(sprintf('%s ("%s") must not be inside the web root ("%s"): the web server would serve these files without any permission check.', $envVar, $dir, $webRoot));
297341
}
298342
}
299343

@@ -365,38 +409,38 @@ public function dav(Request $request, ?string $path, ?Profiler $profiler = null)
365409
return $response;
366410
}
367411

368-
// \Sabre\DAV\Server does not let us use a custom SAPI, and its behaviour
369-
// is to directly output headers and content to php://output. Hence, we
370-
// let the headers pass (we have not choice) and capture the output in a
371-
// buffer.
372-
// This allows us to use a Response, and not to break the events triggered
373-
// by Symfony after the response is sent, like for instance the TERMINATE
374-
// event from the Kernel, that is used to send emails...
375-
412+
// \Sabre\DAV\Server does not let us use a custom SAPI: it writes its status line and
413+
// headers with header() and streams the body to php://output. We capture the output
414+
// so that we can hand a proper Response back to Symfony (and keep its kernel events,
415+
// like TERMINATE, working).
376416
ob_start(); // Does not capture headers!
377417
$this->server->start();
378-
379-
$output = ob_get_contents();
380-
ob_end_clean();
381-
382-
// As previously said, headers are already _prepared_ by the server,
383-
// so we can't modify them or remove them. But they are not _sent_ yet,
384-
// so headers_sent() is false, and Symfony will add its own headers above it.
385-
//
386-
// The Content-type header is the problem, since Symfony will
387-
// output `text/html` for everything since it doesn't know any better.
388-
// Thus, we have to get the _real_ Content-type header already prepared,
389-
// and force it in the Symfony Response.
390-
//
391-
// That's what we do here.
392-
$response = new Response($output, http_response_code(), []);
393-
foreach (headers_list() as $header) {
394-
if ('content-type:' === strtolower(substr($header, 0, 13))) {
395-
$headerArray = explode(':', $header);
396-
$response->headers->set('Content-type', $headerArray[1]);
418+
$output = ob_get_clean();
419+
420+
// Some plugins short-circuit a request by returning false from `beforeMethod` (the
421+
// temporary file filter does, for .DS_Store and friends). sabre then never sends
422+
// anything: status, headers and body only exist in its response object. So we always
423+
// rebuild the Symfony response from that object, falling back to its body when
424+
// nothing was streamed.
425+
$sabreResponse = $this->server->httpResponse;
426+
if ('' === $output) {
427+
$body = $sabreResponse->getBody();
428+
// A stream that sabre already sent has been closed (is_resource() is then false):
429+
// only read bodies that were never streamed.
430+
if (is_string($body) || (is_resource($body) && 'stream' === get_resource_type($body))) {
431+
$output = $sabreResponse->getBodyAsString();
397432
}
398433
}
399434

435+
// Drop the headers sabre may already have queued with header(): Symfony re-sends the
436+
// very same ones from the Response below, and would otherwise duplicate them.
437+
header_remove();
438+
439+
$response = new Response($output, $sabreResponse->getStatus());
440+
foreach ($sabreResponse->getHeaders() as $name => $values) {
441+
$response->headers->set($name, $values);
442+
}
443+
400444
return $response;
401445
}
402446
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
namespace App\Plugins;
4+
5+
use Sabre\DAV\TemporaryFileFilterPlugin;
6+
use Sabre\DAVACL\Plugin as AclPlugin;
7+
use Sabre\HTTP\RequestInterface;
8+
use Sabre\HTTP\ResponseInterface;
9+
use Sabre\Uri;
10+
11+
/**
12+
* sabre/dav's TemporaryFileFilterPlugin intercepts the junk files desktop clients
13+
* write next to real files (.DS_Store, Thumbs.db, ._*, *.swp, ...) and stores them
14+
* outside the DAV tree. Because those files never exist in the tree, the ACL plugin
15+
* never checks anything for them: without this subclass, anybody (authenticated or
16+
* not) could store, read and delete such files under any path.
17+
*
18+
* We make temporary files obey the privileges of the directory they would live in,
19+
* exactly like a real file would.
20+
*/
21+
final class DavisTemporaryFileFilterPlugin extends TemporaryFileFilterPlugin
22+
{
23+
public function beforeMethod(RequestInterface $request, ResponseInterface $response)
24+
{
25+
$path = $request->getPath();
26+
if (false === $this->isTempFile($path)) {
27+
return;
28+
}
29+
30+
$acl = $this->server->getPlugin('acl');
31+
if ($acl instanceof AclPlugin) {
32+
[$parent] = Uri\split($path);
33+
34+
$privilege = match ($request->getMethod()) {
35+
'PUT' => '{DAV:}bind',
36+
'DELETE' => '{DAV:}unbind',
37+
default => '{DAV:}read',
38+
};
39+
40+
// Throws NotAuthenticated (401) for anonymous users and NeedPrivileges (403) otherwise
41+
$acl->checkPrivileges($parent ?? '', $privilege);
42+
}
43+
44+
return parent::beforeMethod($request, $response);
45+
}
46+
}

0 commit comments

Comments
 (0)