Sandboxing a PDF Reader

Here is a short technical note about sandboxing a PDF reader (Okular) with Firejail.

Firejail is a Linux sandbox using the namespaces and seccomp-bpf features of the Linux kernel.

In my opinion, Okular is feature-wise the best PDF reader so that is what I'll use here; however, the instructions can be extended to apply to other readers such as Evince or Acrobat Reader.

The goals:

  • Okular can only see the PDF it is reading, nothing else.
  • Okular will not be able to reach the network.
  • The network will not be able to reach Okular.
  • Okular will not be able to see other processes.
  • Be able to open PDFs via the Nautilus file manager (Ubuntu).

The motivation for this is to reduce the attack surface, should a hostile PDF be opened.

What you need

Mise en place, you'll need:

The Firejail profile

To sandbox an application in Firejail you need a profile. There are built-in profiles, but let's make a custom one.

Replace YOURUSER with your username, some paths must be absolute.

I use a profile like this:

# Generic GUI application profile
include /etc/firejail/
include /etc/firejail/
include /etc/firejail/
blacklist ${HOME}/.pki/nssdb
caps.drop all
protocol unix,inet,inet6

# Okular stuff starts here
read-only /etc
read-only /dev
read-only /boot
read-only /usr
read-only /opt
read-only /bin
read-only /sbin
read-only /lib
read-only /lib32
read-only /lib64
read-only /var
read-only /media
read-only /root
read-only ${HOME}

noblacklist ${HOME}/.kde/share/config/okularpartrc
whitelist ${HOME}/.kde/share/config/okularpartrc
read-only ${HOME}/.kde/share/config/okularpartrc
# cache-* depends on the host name
noblacklist ${HOME}/.kde/cache-okular
whitelist ${HOME}/.kde/cache-okular
whitelist ${HOME}/.kde
whitelist /tmp/.X11-unix/X0


# the fonts stuff is needed for proper display of PDFs
private-etc passwd,groups,fonts/fonts.conf,fonts/conf.d,fonts/conf.avail

# no ${HOME} supported here apparently
netfilter /home/YOURUSER/.config/firejail/


hostname okular
name okular

Basically a lot of paths are set as read-only and the Okular configuration, a cache and the X11 socket are specifically whitelisted. The latter is problematic, and is a hole in the application isolation. It's possible to isolate X11 applications too, but that is not described here.

Custom network filtering is also set up. The "net none" directive caused problems with X11 connections.

The contents of the network isolation script (called are as follows:

$ cat /home/YOURUSER/.config/firejail/

-A INPUT -i lo -j DROP

It just drops all incoming and outgoing traffic. Note that the X11 socket was specifically whitelisted in the profile.

Test this setup

A simple test can be done by running a shell such as Bash instead of Okular.

Note how Firejail is specifically told the profile to use, and just one file is whitelisted (as read-only). In the example, ~/Stuff contains a lot of other files:

YOURUSER@NSA-mainframe:~/Stuff$ ls -1

The funny names are straight from Citeseer. Here is what the Firejail test looks like with the --trace enabled:

YOURUSER@NSA-mainframe:~/Stuff$ firejail --trace --profile=/home/YOURUSER/.config/firejail/okular.profile --whitelist=`readlink -f` --read-only=`readlink -f` bash
Reading profile /home/YOURUSER/.config/firejail/okular.profile
Reading profile /etc/firejail/
Reading profile /etc/firejail/
Reading profile /etc/firejail/
Warning: --trace and --tracelog are mutually exclusive; --tracelog disabled
Parent pid 8232, child pid 8233

Child process initialized
11:bash:open /dev/tty:3
11:bash:open /dev/tty:3
11:bash:fopen /etc/passwd:0xaa5808
11:bash:open /etc/bash.bashrc:-1
11:bash:open /home/YOURUSER/.bashrc:-1
11:bash:open /home/YOURUSER/.bash_history:-1
11:bash:access /lib/terminfo/x/xterm-256color:0
11:bash:fopen /lib/terminfo/x/xterm-256color:0xab3808

# List the files, only the whitelisted file is seen among the trace output:

[YOURUSER@okular Stuff]$ ls -1
12:ls:fopen64 /proc/filesystems:0xc62010
12:ls:opendir .:0xc67c10

# Try the network:

[YOURUSER@okular Stuff]$ nslookup
13:nslookup:socket AF_INET SOCK_STREAM IPPROTO_IP:3
13:nslookup:socket AF_INET6 SOCK_STREAM IPPROTO_IP:3
13:nslookup:socket AF_LOCAL SOCK_STREAM IPPROTO_IP:3
13:nslookup:fopen64 /usr/lib/ssl/openssl.cnf:(nil)
13:nslookup:fopen64 /etc/resolv.conf:(nil)
13:nslookup:socket AF_INET SOCK_DGRAM IPPROTO_UDP:6
13:nslookup:bind port 0:0
13:nslookup:socket AF_INET6 SOCK_DGRAM IPPROTO_UDP:6
13:nslookup:bind :::0
;; connection timed out; no servers could be reached

[YOURUSER@okular Stuff]$ exit
11:bash:open /home/YOURUSER/.bash_history:-1
11:bash:open /home/YOURUSER/.bash_history:-1
11:bash:open /home/YOURUSER/.bash_history:-1

parent is shutting down, bye...

System-wide setup

You need to be root for this.

It's not possible to specify variable parts like the invoked file in the profile, so we'll make a wrapper instead.

$ mv /usr/bin/okular /usr/bin/okular.original
$ vim /usr/bin/okular
$ ls -l /usr/bin/okular*
-rwxr-xr-x 1 root root    97 Feb 20 19:38 /usr/bin/okular
-rwxr-xr-x 1 root root 90752 Aug  5  2014 /usr/bin/okular.original
$ cat /usr/bin/okular

# first expand to fully qualified filename
REAL_FILE=`readlink -f "$1"`

firejail --profile=/home/YOURUSER/.config/firejail/okular.profile \
   --whitelist="$REAL_FILE" --read-only="$REAL_FILE" okular.original "$REAL_FILE"

The file given as parameter is first expanded to a fully qualified absolute name, then whitelisted.

(Note that Firejail prohibits certain characters from the filename, you can easily disable or change this filter in function invalid_filename in the file util.c)

Now you might ask: "wait, we blocked the network, so can I still read documents from a network share?". That is a good question, and the answer is yes, you can. Generally, you just see a filesystem and another process takes care of reading/writing to the network share - the network stuff is not done by the Okular process.

Nautilus integration

Integration into Nautilus is very easy. Note, the "somefile.pdf" does not need to exist:

$ mimeopen -d somefile.pdf
Please choose a default application for files of type application/pdf

        1) Document Viewer  (evince)
        2) GIMP Image Editor  (gimp)
        3) Print Preview  (evince-previewer)
        4) Other...

use application #4
use command: okular

Now when right-clicking in Nautilus, the default selection is "okular" (the sandboxed one).

Gains and losses

What was gained?

Let's revisit the goals:

  • Okular can only see the PDF it is reading, nothing else. Yes!
  • Okular will not be able to reach the network. Yes!
  • The network will not be able to reach Okular. Yes!
  • Okular will not be able to see other processes. Yes!
  • Be able to open PDFs via the Nautilus file manager (Ubuntu). Yes!

Another note: one can still click on a link inside a PDF and open the link in a web browser; this is conducted via the DBus and may or may not be what you want.

What was lost?

One downside is that only one Okular instance can be open at a time. This is because the PID namespacing (CLONE_NEWPID) causes the PIDs to start from a low number inside the sandbox, and two different instances end up eventually registering themselves to DBus with the same PID, causing the latter registration to fail, and thus leading to the application stopping.

I'm not yet sure what would be the best way to work around this, perhaps by creating a full-blown (lxc) container for each Okular instance. This way each Okular would have their own DBus. Or perhaps one could run separate DBus instances without a full OS container.

Web Slinging

This is a short technical note describing how to setup aiohttp-wsgi and aiohttp to serve Django via WSGI while allowing WebSockets on the same port.

Hopefully this setup is useful to you for doing awesome things. Note, this is just a technical note to use as a starting point and I have not benchmarked the performance in any way. I work daily with (more or less) embedded systems, big and small, and all this web stuff is just a hobby, so to speak.

What you need

Firstly, mise en place. You need:

  • Python 3
  • aiohttp
  • aiohttp-wsgi
  • your Django app
  • nginx

Why do we use these

Python 3.4+ is a must since you need the asyncio support. This gives you asynchronous I/O, event loops, coroutines, and tasks. These basically let you do co-operatively scheduled single-threaded concurrent code which is quite useful when dealing with inputs/output handling from, say, the network.

The aiohttp is an (almost) pure Python HTTP server (and client), using the asyncio support. It provides WebSocket handling, among other things.

The aiohttp-wsgi is a WSGI adapter for aiohttp. This is a bit more flexible than the built-in aiohttp.wsgi. The lack of setup instructions in aiohttp-wsgi was actually the main motivation in putting up this note.

Your Django app, this is any Django application you want to make asyncio-capable, to add WebSocket support to, etc.

Nginx is a fast HTTP and reverse proxy server. You could probably also use HAProxy or similar tools as well.

Prepare aiohttp-wsgi

Note: be sure to use version 0.1.1 or higher, as in 0.1.0 Django responses will make aiohttp-wsgi nag with a RuntimeError.

The big picture

The satellite level view is as follows:

  • The client connects to port 8000.
  • Nginx auto-redirects HTTP (port 8000) to HTTPS (port 8443).
  • SSL handshake and setup are done, further traffic to and from 8443 is SSL protected.
  • Traffic coming to 8443 is decrypted internally and routed to aiohttp WSGI front-end at
  • Here the traffic goes either to WSGI app (Django) or WebSockets.
  • Response is sent to the client.

The ports are high ports to allow an unpriviledged user to test this - no need to be root. You can easily map the ports to suitable priviledged ones like 80, 443 and so on.

The directory structure

The directory tree is set up as follows:

├── nginx
│   ├── log
│   ├── nginx.conf
│   └── ssl
└── yourdjangoapp
    └── yourdjangoapp
        ├── templates
        └── etc.

Note: it is assumed that aiohttp and aiohttp_wsgi are installed system-wide. It's an unimportant detail - you can install into virtualenv instead (I did).

Your Django app

The Django app, which is called "yourdjangoapp" from now on, is a regular Django app. In this note, the app is stupendously simple and basically routes "/" to the index-method, which serves up a templates/index.html file.

The is setup like this:

from yourdjangoapp import views

urlpatterns = patterns('',
    url(r'^$', 'yourdjangoapp.views.index', name='index'),

...and a is setup like this:

from django.shortcuts import render_to_response

def index(request):
    return render_to_response("templates/index.html")

Thus when "/" is accessed, the templates/index.html is sent to the client. The templates/index.html is a truly remarkable gem of state-of-the-art web design and contains:

<body bgcolor=white text=black>
<h1>This is the index...</h1>
<p>This site uses cookies. Get over it.</p>
    var socket = new WebSocket('wss://');
    socket.onopen = function(event) {
        console.log('a user connected');
        socket.send("Howdy ho");
    socket.onclose = function(event) {
        console.log('a user disconnected');
    socket.onerror = function(event) {
        console.log('a user got sour');
    socket.onmessage = function(event) {
        console.log('Server said: ' +;
        // delayed reply
        setTimeout(function() { console.log('I said: Say it again'); socket.send("Say it again"); }, 2000);

The client will ping-pong messages with the server.

Notice the address wss:// This is the IP address of the Nginx reverse proxy SSL port with the WebSocket URL (see below) with an unpriviledged port used for SSL.

Note: you must add the following to yourdjangoapp/ to use the index.html template.

TEMPLATE_DIRS = [("%s/yourdjangoapp" % BASE_DIR)]

The aiohttp WSGI front end (

The is a front-end component to manage routing to WebSockets and to Django. It contains also basic WebSockets logic used for testing.

Let's split the file up and examine the parts, going from the beginning of the file all the way to the end.

First, the basic imports:

import sys
import os
import asyncio

from aiohttp_wsgi import api
import aiohttp

Then, the WebSockets handler for aiohttp.

Note how this is an asyncio coroutine. You can also see the "yield from", which means that at such a point the execution can go elsewhere until the stuff that is yielded from finishes whatever it is doing. Once it does finish, the loop continues from the yield from line. On the next iteration, the same thing happens - there is no blocking for ws.receive_str() to finish.

Note how the send_str doesn't have a yield from.

def ws_testing(request):
    # websockets aiohttp req-resp
    ws = aiohttp.web.WebSocketResponse()

    ws.send_str("Initial server hello")
    while True:
            data = yield from ws.receive_str()
            if data == 'close':
                ws.send_str(data + ' says the server')
        except Exception as exc:
            print(exc.code, exc.message)
            return ws

Django setup for aiohttp is next. It's all in all quite simple - just 2-3 lines, thanks to aiohttp-wsgi. With the Django WSGI application instance, the aiohttp server is configured with the chosen port and host and told to run the Django WSGI application.

# Django setup
sys.path.append("%s/yourdjangoapp" % (os.getcwd()))
from yourdjangoapp import wsgi

loop = asyncio.get_event_loop()
server, app = api.configure_server(

The default routes come from the Django parts ( Let's add another route for the WebSockets. Django does not know about this routing since it happens in the aiohttp level.

# paths handled as WebSockets
app.router.add_route('GET', '/ws', ws_testing)

So what did we do so far? At this point we made "/" go to the Django app, via WSGI. The "/ws" goes to the WebSocket testing method, which simply first sends a message and then starts echoing all client messages back.

Next, a basic event loop is started to loop forever.

except KeyboardInterrupt:
    api.close_server(server, app, loop=loop)

print("All done, post cleanup stuff happens here")

There is no "post cleanup stuff", something could happen there but now it's just a placeholder.

And that's it for the

Nginx configuration

Note, here I assume your username is bob, the nginx files are located under /home/bob/nginx/ and your host IP is

Note: in case you're wondering, the "lh" in the SSL keys means "localhost".

SSL setup

If you need a refresher in how to make an openssl setup for nginx, refer to this tutorial. As a summary:

  • cd ~bob/nginx/ssl
  • openssl genrsa -des3 -out lh.key 2048
  • openssl req -new -key lh.key -out lh.csr
  • Fill in the information
  • Remove the passphrase
    • cp lh.key lh.key.orig
    • openssl rsa -in lh.key.orig -out lh.key
  • openssl x509 -req -days 1500 -in lh.csr -signkey lh.key -out lh.crt
  • Point nginx to the .key and the .crt

That's it for the SSL setup.


worker_processes  10;
error_log         /home/bob/nginx/log/error.log;
pid               /home/bob/nginx/;
worker_rlimit_nofile 8192;

events {
    worker_connections  4096;

http {
    server_tokens     off;

    upstream wsgitest {

    server {
        listen 8443;

        error_log         /home/bob/nginx/log/error-lh.log;
        access_log        /home/bob/nginx/log/access-lh.log;

        root /home/bob/nginx/siteroot;

        # django + ws upgrade
        location / {
            proxy_pass http://wsgitest/;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";

        ssl on;
            ssl_certificate     /home/bob/nginx/ssl/lh.crt;
            ssl_certificate_key /home/bob/nginx/ssl/lh.key;

    # redirect http --> https
    server {
        error_log         /home/bob/nginx/log/error-lh.log;
        access_log        /home/bob/nginx/log/access-lh.log;
        listen 8000;
        return 301 https://$host:8443/$request_uri;

Testing it

First, start nginx and the aiohttp WSGI front-end:

nginx -c ~bob/nginx/nginx.conf

Then point your browser to Open the developer console. In Firefox the default shortcut for this is Shift-Control-K.

You should see the HTML output from the Django app, and in the console the WebSockets output ping-ponging every 2 seconds.

What's next

  • Stress-test the WS. Thor seemed to break the sort of echoing WebSockets as we have here after a few hundred connections.
  • Stress-test, somehow, a reasonable mix of WS + HTTPS to know where we're at.
  • Profile, make it better.
  • Build something on it!

Alive, again

Without change, things stagnate. When things stagnate, they risk becoming obsolete. Change for the sake of change is inefficient and just spins around chasing its own tail - the change should have a purpose. Therefore: to avoid risk of becoming obsolete, make purposeful changes.

That said, here is some purposeful change: a revamped incarnation of my blog.

The old posts were cleaned up and many were thrown away. I kept the ones which were still interesting to me. You can find the old posts in the Old-category at the top.

Also, the old "Zenburn updated" posts are gone - any future updates will be reflected at the Zenburn repository in Github.

Also, due to massive amounts of spam, the comment system has moved to Disqus. I have kept the old comments only at the Zenburn page since the comments are valuable to a number of people.