How to build an asset firewall

Creating "secret" pages for logged in users is very easy in Kirby with the built-in user and authentication system. But how can you protect images and other files in your content folders from being accessed by any visitor?

By default all the files, which you upload are public and are not protected even for locked pages. As soon as one of your visitors knows the full URL of an image for example, they can access it. But Kirby's plugin and routing system offers a straight-forward way to lock access to files as well. Here's how…

Creating a new plugin

We are going to solve this with a simple plugin, so you can take this solution with you to every new project with similar requirements.

site/plugins/firewall/firewall.php

Let's just call the plugin firewall. That somehow seems obvious. Create a new firewall folder in site/plugins and add a firewall.php file.

That's it! We've already created a plugin, which will automatically be loaded by Kirby on every request.

The router

Kirby offers the option to add routes on the fly in plugin files. This can be achieved by using the kirby()->routes() method to register any additional routes for our plugin.

The routes() method expects an array of route definitions. You can find more about the router in the docs.

Adding the content route

In this case we want to add a route, which handles all incoming requests for the content folder.

routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) {
      // our firewall logic
    }
  )  
));

The basic setup is very simple. We define a pattern, which applies to all URLs starting with /content. The (:all) placeholder will fetch the path after /content and pass it to the router action. The router action is a simple callback function, which we can use to do all kinds of crazy things with the request before it will be passed on to the regular Kirby machinery.

Splitting the path

In the next step, we split the path and try to find the page and file for it.

kirby()->routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) {

      $dirs     = str::split($path, '/');
      $filename = array_pop($dirs);

      …

  }
));

With str::split we can easily split the path into an array. With array_pop() we fetch the last element of that array, which will probably be the filename content/some/page/filename.jpg. array_pop() also removes the last element at the same time, so $dirs will be the clean path without the filename afterwards, which is pretty cool.

Searching for the parent page

Right now, we have the filename and the path of directories of the page, to which the file belongs. We now need to find the Kirby $page object by working with those directories somehow.

kirby()->routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) {

      $dirs     = str::split($path, '/');
      $filename = array_pop($dirs);

      // we start with site->children()
      // and then climb up the tree with every round of
      // the foreach loop
      $parent = site();

      foreach($dirs as $dirname) {
        // try to find the next parent page by $dirname
        if($child = $parent->children()->findBy('dirname', $dirname)) {
          // overwrite the parent for the next round
          $parent = $child;
        } else {
          header::notFound();
          die('Page not found');        
        }
      }

      …

  }
));

So in this step, we take the $dirs array and loop through it to get each individual $dirname. With every round of the loop, we climb up the directory tree and see if we can find the right page. Sounds a bit complicated, but if you give it a bit, it should be quite logical.

In the end we either found a page for the directory path, or we didn't. In this case we stop and send an error header together with a simple error message. This will make sure that the browser knows what to do with invalid requests.

Searching for the file

If a page has been found, we keep on looking for the file by the filename we got earlier. If the file could not be found, we create a simple error message again together with a 404 header.

kirby()->routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) {

      $dirs     = str::split($path, '/');
      $filename = array_pop($dirs);

      // we start with site->children()
      // and then climb up the tree with every round of
      // the foreach loop
      $parent = site();

      foreach($dirs as $dirname) {
        // try to find the next parent page by $dirname
        if($child = $parent->children()->findBy('dirname', $dirname)) {
          // overwrite the parent for the next round
          $parent = $child;
        } else {
          header::notFound();
          die('Page not found');        
        }
      }

      // now let's try to find that file 
      if($file = $parent->file($filename)) {

        // our authentication logic…

      } else {
        header::notFound();
        die('File not found');
      }

  }
));

Checking permissions

In this last step we check if the user has access to the requested file. In this example all logged in users get access to all files and if a user is not logged in all files will locked.

If no permissions are granted, we will return a simple 403 header (header::forbidden()) and an error message.

kirby()->routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) {

      $dirs     = str::split($path, '/');
      $filename = array_pop($dirs);

      // we start with site->children()
      // and then climb up the tree with every round of
      // the foreach loop
      $parent = site();

      foreach($dirs as $dirname) {
        // try to find the next parent page by $dirname
        if($child = $parent->children()->findBy('dirname', $dirname)) {
          // overwrite the parent for the next round
          $parent = $child;
        } else {
          header::notFound();
          die('Page not found');        
        }
      }

      // now let's try to find that file 
      if($file = $parent->file($filename)) {

        // check for a logged in user
        if($user = site()->user()) {
          $file->show();
        } else {
          header::forbidden();
          die('Unauthorized access');
        }

      } else {
        header::notFound();
        die('File not found');
      }

  }
));

You can use this code right away to lock access to all your files for users, which are not logged in.

htaccess

One last step is needed though to make it work. Kirby's default htaccess file makes sure that all requests to existing files are directly handled by the server and are not sent to Kirby's index.php. In order to make the routing work though, we need to send all those requests to the index.php as well.

In your htaccess just add the following line below the first content folder rule:

# firewall
RewriteRule ^content/(.*)$ index.php [L]

From now on every request will go through our route first and we can intercept it with the code from above.

You can check out if it works by simple browsing your site without being logged in. All images should no longer be accessible. As soon as you login to the panel, the images should appear again.

Fine tuning

This firewall is pretty brutal and would only fit to a fully locked down site. Of course it's easy to adjust the permission check to make it less strict and only check for certain templates for example:

…

if($parent->template() == 'secret' and !site()->user()) {
  header::forbidden();
  die('Unauthorized access');
} else {
  $file->show();
}

…

…or you could allow access only for certain user roles

…

if($user = site()->user() and $user->hasRole('admin')) {
  $file->show();
} else {
  header::forbidden();
  die('Unauthorized access');
}

…

You could even make that dynamic by using a page field to determin which users have access:

title: My page
----
text: some text
----
fileaccess:
- homer
- marge
- lisa
…

if($user = site()->user() and in_array($user->username(), $parent->fileaccess()->yaml())) {
  $file->show();
} else {
  header::forbidden();
  die('Unauthorized access');
}

…

Final thoughts

As you can see this can be extended to a quite powerful plugin, which can follow you to each new project. With Kirby's user authentication system, roles and this little firewall you can basically build a full blown, secure client area or similar scenarios.

I hope you enjoyed the first tutorial after quite a long time! As always, I'm looking forward to any kind of feedback.

One last thing

This article is the first which uses the new Discourse Forum for comments instead of Disqus. I have no experience with the embedding system of Discourse so far, so I hope it will all be fine. Let me know if you run into any issues.


This is a companion discussion topic for the original entry at http://getkirby.com/blog/asset-firewall
6 Likes

Your solution with the field

cannot be used at this moment, if one or more editors with access to the panel are no admins.
Because they can change the not restricted users in this field.

Or am I wrong?

You are actually right, but you can use this for frontend users with a role that does not have access to the panel.

At the moment (Kirby v. 2.1.0) it may be a solution (may be NOT SECURE!) to follow
Hidden | Create a hidden field for the field fileaccess

Hint:
I don’t know, what you have to change in the instructions at the top of this page!

The end of the php code

in the first example is correct.

In the other complete code examples

add the needed third “)”.


@bastianallgeier:
If you have made the correction, would you please delete this post here, so others do not get confused? Thank you.

Is this article still relevant?

I am having with trouble protecting assets. I am building a website for a school and there galleries where the user needs to be logged in in order to see them. I have created an additional user role and everything works in regards to the “front end” side of things.

But the files are still accessible if you know the content folder full url, and also the thumbs folder url.

I have been trying to protect the files using the methods described on https://getkirby.com/blog/asset-firewall

Before I get too into specifics it would really help if I knew if there is anything glaringly obvious or outdated in the “asset firewall” article?

Such as the syntax of the route where in the docs a plugin route is structured as :

$kirby->set('route', array(
  'pattern' => 'my/awesome/url',
  'action'  => function() {
    // do something here when the URL matches the pattern above
  }
));

but in the article:

kirby()->routes(array(
  array(
    'pattern' => 'content/(:all)', 
    'action' => function($path) { ...

Thanks

No, the different ways of setting a route are still valid. As to the rest of the article, it should not be outdated, but I’ll try to see if I can find anything obvious.

Edit: Could you post your code, pls.?

As pointed out by @anon77445132, there is a parenthesis missing in the code examples.

1 Like

Have you defined the rewrite rule from the article? Without that, the route will not be called at all and Apache will serve the files without asking Kirby about them.

Thanks for the swift replies :slight_smile:

firewall.php

<?php 

kirby()->routes(array(
  array(
    'pattern' => array('content/galleries/(:all)', 'thumbs/galleries/(:all)'), 
    'action' => function($path) {

      $dirs     = str::split($path, '/');
      $filename = array_pop($dirs);

      // we start with site->children()
      // and then climb up the tree with every round of
      // the foreach loop
      $parent = site();

      foreach($dirs as $dirname) {
        // try to find the next parent page by $dirname
        if($child = $parent->children()->findBy('dirname', $dirname)) {
          // overwrite the parent for the next round
          $parent = $child;
        } else {
          header::notFound();
          die('Page not found');        
        }
      }

      // now let's try to find that file 
      if($file = $parent->file($filename)) {

        // check for a logged in user
       if($user = site()->user() and $user->hasRole('parent')) {
         $file->show();
       } else {
         header::forbidden();
         die('Unauthorized access');
       }

      } else {
        header::notFound();
        die('File not found');
      }

  }
  )
));

htaccess


# block text files in the content folder from being accessed directly
RewriteRule ^content/(.*)\.(txt|md|mdown)$ index.php [L]
# RewriteRule ^content/(.*) index.php [L]

RewriteRule ^content/galleries/(.*)$ index.php [L]
RewriteRule ^thumbs/galleries/(.*)$ index.php [L]

# block all files in the site folder from being accessed directly
RewriteRule ^site/(.*) index.php [L]

# block all files in the kirby folder from being accessed directly
RewriteRule ^kirby/(.*) index.php [L]

# make panel links work
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^panel/(.*) panel/index.php [L]

# make site links work
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*) index.php [L]

Which gives me “page not found” when I try to access http://bhm.dev/thumbs/galleries/1-autumn/autumn-1-429x322.jpg or http://bhm.dev/content/galleries/1-autumn/autumn-1.jpg it doesn’t get as far as checking the user role.

When I stripped it back to :

<?php 
$kirby->set('route', array(
  'pattern' => array('content/galleries/(:all)', 'thumbs/galleries/(:all)'),
  'action' => function ($path) {

    $dirs = str::split($path, '/');
    $filename = array_pop($dirs);

    $parent = site();
    if($user = site()->user() and $user->hasRole('parent')) {
      $file = $parent->file($filename);
       echo $path;

    } else {
      header::forbidden();
      die('Unauthorized access');
    }

  }
  ));

Which does echo the path if logged in as a parent, but if I try any Kirby functions such as $file->show(); I get the error “Fatal error: Uncaught Error: Call to a member function show() on null”

When I change $file = $parent->file($filename); to $file = $parent->files($filename); it no longer give me the error, it does echo the path but doesn’t give me the file.

1 Like

Is there a reason why the firewall would block only certain images in a directory?

I used the base example to test my setup and it sort of worked. On some pages, all images were blocked. On others, only certain images were gone.

Are you using thumbs maybe? If so, the thumbs folder is not blocked in the example. That part is missing and should be added.

Actually, it was my local browser cache causing the false viewing. I viewed in a private window, and all image content is hidden, so the firewall example works.

This leads to my next question:

I have a template, that has certain pages that only users can see. Pages that have the ‘nda’ filter of yes will only be seen by those users. Part of the code is below.

$projects = $pages->find('portfolio')
                          ->children()
                          ->visible()
                          ->filterBy('nda', '!=', 'Yes');                 
}

How do I prevent everyone regular users (aka the web) from getting access to those files with the firewall? Use something similar to the code in the article like below?

if($parent->template() == 'secret' and !site()->user()) {
  header::forbidden();
  die('Unauthorized access');
} else {
  $file->show();
}

As always, thanks for the awesome help.

In your use case, you would check the value of the nda field, something like

if($page->template() == 'project' &&  $page->nda() == 'Yes' && !site()->user()) {
  header::forbidden();
  die('Unauthorized access');
} else {
  $file->show();
}

Would I get rid of the other else statement? When I use this as is below, all of the images show up as missing. Should this be empty? I only want to prevent regular users from getting access to the files.

if($file = $parent->file($filename)) {`
    // check for a logged in user
    if($page->template() == 'project' &&  $page->nda() == 'Yes' && !site()->user()) {
      header::forbidden();
      die('Unauthorized access');
    } else {
      $file->show();
    }

  } else {
    header::notFound();
    die('File not found');
  }
1 Like