Skip to content

Commit

Permalink
Implement resized image protection (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
fritzmg authored Sep 26, 2023
1 parent 059bef4 commit 6705162
Show file tree
Hide file tree
Showing 15 changed files with 723 additions and 124 deletions.
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,29 @@ Now, when a user which has not logged in yet opens the link to a file, he will b

Since version `2.3.0` you are also able to grant front end users access to the files in their user home directory in the settings of the member.

## Protect Resized Images

Since version `2.4.0` it is possible to also automatically protect any resized images (thumbnails) of protected files
which would otherwise be publicly available under `assets/images`. You can enable this feature in your config:

```yaml
# config/config.yaml
contao_file_access:
protect_resized_images: true
```
Note that this will however put additional load on your application as all requests to any resized protected image must
be processed by the application.
Also note that due to technical limitations you will always have access to these images (i.e. see these images) if you
are logged into the back end in your current browser session.
## Important Notes
Since this access restriction is done via PHP, the file is also sent to the client via PHP. This means that the `max_execution_time` needs to be sufficiently large, so that any file can be transferred to the client before the script is terminated. Thus you should be aware that problems can occur if a file is either very large or the client's connection to the server is very slow, or both. The script tries to disable the `max_execution_time`, though there is no guarantee that this will work. Also there can be other timeouts in the webserver.

Also currently any automatically generated images by Contao are __not__ protected. So if you use thumbnails of protected images, the URLs to these thumbnails can still be accessed by anyone. Though it is planned to also be able to protect those in a future version.
If you did not enable `protect_resized_images` (see above) and you use thumbnails of protected images, the URL to these
thumbnails can still be accessed by anyone.

## Acknowledgements

Expand Down
26 changes: 19 additions & 7 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@
}
],
"require": {
"php": "^7.1 || ^8.0",
"contao/core-bundle": "^4.9 || ^5.0",
"symfony/config": "^4.4 || ^5.2 || ^6.0",
"symfony/dependency-injection": "^4.4 || ^5.2 || ^6.0",
"symfony/http-foundation": "^4.4 || ^5.2 || ^6.0",
"symfony/http-kernel": "^4.4 || ^5.2 || ^6.0",
"webmozart/path-util": "^2.3"
"php": "^7.4 || ^8.0",
"contao/core-bundle": "^4.13 || ^5.0",
"contao/image": "^1.2",
"symfony/config": "^5.2 || ^6.0",
"symfony/dependency-injection": "^5.2 || ^6.0",
"symfony/http-foundation": "^5.2 || ^6.0",
"symfony/http-kernel": "^5.2 || ^6.0",
"symfony/security-core": "^5.2 || ^6.0",
"webmozart/path-util": "^2.3",
"symfony/service-contracts": "^2.5 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0"
},
"autoload": {
"psr-4": {
Expand All @@ -41,5 +47,11 @@
},
"extra": {
"contao-manager-plugin": "InspiredMinds\\ContaoFileAccessBundle\\ContaoManagerPlugin"
},
"config": {
"allow-plugins": {
"contao-components/installer": true,
"php-http/discovery": false
}
}
}
8 changes: 8 additions & 0 deletions src/ContaoFileAccessBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@

namespace InspiredMinds\ContaoFileAccessBundle;

use InspiredMinds\ContaoFileAccessBundle\DependencyInjection\Compiler\AdjustProtectedResizerServicePass;
use InspiredMinds\ContaoFileAccessBundle\DependencyInjection\Compiler\AdjustResizerServicePass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;

class ContaoFileAccessBundle extends Bundle
{
public function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new AdjustProtectedResizerServicePass());
$container->addCompilerPass(new AdjustResizerServicePass());
}
}
136 changes: 136 additions & 0 deletions src/Controller/AbstractFilesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

/*
* This file is part of the ContaoFileAccessBundle.
*
* (c) inspiredminds
*
* @license LGPL-3.0-or-later
*/

namespace InspiredMinds\ContaoFileAccessBundle\Controller;

use Contao\Controller;
use Contao\CoreBundle\Exception\AccessDeniedException;
use Contao\CoreBundle\Exception\InsufficientAuthenticationException;
use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\CoreBundle\Security\Authentication\Token\TokenChecker;
use Contao\Date;
use Contao\FilesModel;
use Contao\FrontendUser;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Security;
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\Contracts\Service\ServiceSubscriberTrait;

abstract class AbstractFilesController implements ServiceSubscriberInterface
{
use ServiceSubscriberTrait;

/**
* @throws PageNotFoundException
* @throws AccessDeniedException
* @throws InsufficientAuthenticationException
*/
protected function checkFilePermissions(FilesModel $filesModel): void
{
// Check folder permissions
$allowLogin = false;
$allowAccess = false;

// Get the current user
$user = $this->security()->getUser();

// Check if the current user can access their home directory
$canAccessHomeDir = $user instanceof FrontendUser && !empty($user->homeDir) && $user->accessHomeDir;

do {
// Check if this is a folder
if ('folder' === $filesModel->type) {
// Check if the current directory is an accessible user home
$isHomeDir = (bool) $this->connection()->fetchOne('SELECT COUNT(*) FROM tl_member WHERE accessHomeDir = 1 AND homeDir = ?', [$filesModel->uuid]);

// Only check when member groups have been set or the folder is a user home
if (null !== $filesModel->groups || $isHomeDir) {
$allowLogin = true;

// Set the model to protected on the fly
$filesModel->protected = true;

// Check if this is the user's home directory
$isUserHomeDir = $user instanceof FrontendUser && $user->homeDir === $filesModel->uuid;

// Check access
if (($canAccessHomeDir && $isUserHomeDir) || Controller::isVisibleElement($filesModel)) {
$allowAccess = true;
break;
}
}
}

// Get the parent folder
$filesModel = $filesModel->pid ? FilesModel::findById($filesModel->pid) : null;
} while (null !== $filesModel);

// Throw 404 exception, if there were no user homes or folders with member groups
if (!$allowLogin) {
throw new PageNotFoundException();
}

// Deny access
if (!$allowAccess) {
// If a user is authenticated or the 401 exception does not exist, throw 403 exception
if ($this->security()->isGranted('ROLE_MEMBER')) {
throw new AccessDeniedException();
}

// Otherwise throw 401 exception
throw new InsufficientAuthenticationException();
}
}

protected function setRootPage(Request $request): void
{
$root = $this->findFirstPublishedRootByHostAndLanguage($request->getHost(), $request->getLocale());

if (null !== $root) {
$root->loadDetails();
$request->attributes->set('pageModel', $root);
$GLOBALS['objPage'] = $root;
}
}

protected function connection(): Connection
{
return $this->container->get(__METHOD__);
}

protected function security(): Security
{
return $this->container->get(__METHOD__);
}

protected function tokenChecker(): TokenChecker
{
return $this->container->get(__METHOD__);
}

private function findFirstPublishedRootByHostAndLanguage(string $host, string $language): ?PageModel
{
$t = PageModel::getTable();
$columns = ["$t.type='root' AND ($t.dns=? OR $t.dns='') AND ($t.language=? OR $t.fallback='1')"];
$values = [$host, $language];
$options = ['order' => "$t.dns DESC, $t.fallback"];

if (!$this->tokenChecker()->isPreviewMode()) {
$time = Date::floorToMinute();
$columns[] = "$t.published='1' AND ($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'$time')";
}

return PageModel::findOneBy($columns, $values, $options);
}
}
108 changes: 10 additions & 98 deletions src/Controller/FilesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,25 @@

namespace InspiredMinds\ContaoFileAccessBundle\Controller;

use Contao\Controller;
use Contao\CoreBundle\Exception\AccessDeniedException;
use Contao\CoreBundle\Exception\InsufficientAuthenticationException;
use Contao\CoreBundle\Exception\PageNotFoundException;
use Contao\CoreBundle\Framework\ContaoFramework;
use Contao\CoreBundle\Security\Authentication\Token\TokenChecker;
use Contao\Date;
use Contao\Dbafs;
use Contao\FilesModel;
use Contao\FrontendUser;
use Contao\PageModel;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Security\Core\Security;
use Webmozart\PathUtil\Path;

class FilesController
class FilesController extends AbstractFilesController
{
protected $rootDir;
protected $session;
protected $framework;
protected $security;
protected $db;
protected $tokenChecker;
protected $projectDir;

public function __construct(string $rootDir, Session $session, ContaoFramework $framework, Security $security, Connection $db, TokenChecker $tokenChecker)
public function __construct(ContaoFramework $framework, string $projectDir)
{
$this->rootDir = $rootDir;
$this->session = $session;
$this->framework = $framework;
$this->security = $security;
$this->db = $db;
$this->tokenChecker = $tokenChecker;
$this->projectDir = $projectDir;
}

public function fileAction(Request $request, string $file): BinaryFileResponse
Expand All @@ -62,16 +46,10 @@ public function fileAction(Request $request, string $file): BinaryFileResponse
$this->framework->initialize(true);

// Set the root page for the domain as the pageModel attribute
$root = $this->findFirstPublishedRootByHostAndLanguage($request->getHost(), $request->getLocale());

if (null !== $root) {
$root->loadDetails();
$request->attributes->set('pageModel', $root);
$GLOBALS['objPage'] = $root;
}
$this->setRootPage($request);

// Check whether the file exists
if (!is_file(Path::join($this->rootDir, $file))) {
if (!is_file(Path::join($this->projectDir, $file))) {
throw new PageNotFoundException();
}

Expand All @@ -88,82 +66,16 @@ public function fileAction(Request $request, string $file): BinaryFileResponse
throw new PageNotFoundException();
}

// Check folder permissions
$allowLogin = false;
$allowAccess = false;

// Get the current user
$user = $this->security->getUser();

// Check if the current user can access their home directory
$canAccessHomeDir = $user instanceof FrontendUser && !empty($user->homeDir) && $user->accessHomeDir;

do {
// Check if this is a folder
if ('folder' === $filesModel->type) {
// Check if the current directory is an accessible user home
$isHomeDir = (bool) $this->db->fetchOne('SELECT COUNT(*) FROM tl_member WHERE accessHomeDir = 1 AND homeDir = ?', [$filesModel->uuid]);

// Only check when member groups have been set or the folder is a user home
if (null !== $filesModel->groups || $isHomeDir) {
$allowLogin = true;

// Set the model to protected on the fly
$filesModel->protected = true;

// Check if this is the user's home directory
$isUserHomeDir = $user instanceof FrontendUser && $user->homeDir === $filesModel->uuid;

// Check access
if (($canAccessHomeDir && $isUserHomeDir) || Controller::isVisibleElement($filesModel)) {
$allowAccess = true;
break;
}
}
}

// Get the parent folder
$filesModel = $filesModel->pid ? FilesModel::findById($filesModel->pid) : null;
} while (null !== $filesModel);

// Throw 404 exception, if there were no user homes or folders with member groups
if (!$allowLogin) {
throw new PageNotFoundException();
}

// Deny access
if (!$allowAccess) {
// If a user is authenticated or the 401 exception does not exist, throw 403 exception
if ($this->security->isGranted('ROLE_MEMBER')) {
throw new AccessDeniedException();
}

// Otherwise throw 401 exception
throw new InsufficientAuthenticationException();
}
// Check the permissions
$this->checkFilePermissions($filesModel);

// Close the session
$this->session->save();
$request->getSession()->save();

// Try to override max_execution_time
@ini_set('max_execution_time', '0');

// Return file to browser
return new BinaryFileResponse(Path::join($this->rootDir, $file));
}

protected function findFirstPublishedRootByHostAndLanguage(string $host, string $language): ?PageModel
{
$t = PageModel::getTable();
$columns = ["$t.type='root' AND ($t.dns=? OR $t.dns='') AND ($t.language=? OR $t.fallback='1')"];
$values = [$host, $language];
$options = ['order' => "$t.dns DESC, $t.fallback"];

if (!$this->tokenChecker->isPreviewMode()) {
$time = Date::floorToMinute();
$columns[] = "$t.published='1' AND ($t.start='' OR $t.start<='$time') AND ($t.stop='' OR $t.stop>'$time')";
}

return PageModel::findOneBy($columns, $values, $options);
return new BinaryFileResponse(Path::join($this->projectDir, $file));
}
}
Loading

0 comments on commit 6705162

Please sign in to comment.