How We Engineered CMS Airship to be Simply Secure

CMS Airship is a Free Software content management system (available on Github) that we introduced to the world last year.

We chose to build CMS Airship for two reasons:

The population of online publishers that need a content management system significantly dwarfs the population of web designers or software developers. The existing content management systems offered inadequate security and neglected to cultivate a culture that promotes better security practices. (Often, they sacrifice security entirely in pursuit of adoption and market share.)

Although we have tried for years (and will continue to try) to improve the security posture of other open source CMS platforms, we believe that truly democratized publishing is only possible if a secure-by-default option is available today.

How can we be certain that CMS Airship is secure?

The rest of this post is dedicated to answering this question in detail. There is one thing we want you to keep in mind as you read this blog: If you're an Airship user, these are problems you'll likely never have to solve. The work is all done for you.

If you're not an Airship user, you may want to take notes and contrast our solutions with the platform you develop for.

Fundamental Security Best Practices

This section addresses the absolute basics of application security.

SQL Injection Prevention

CMS Airship's strategy to prevent SQL injection is to use prepared statements, with an API that encourages its usage.

<?php $data = $db->run( "SELECT * FROM table WHERE col1 = ? AND col2 != ?", $col1value, $col2value );

We don't bother with error-prone strategies that involve escaping input and inserting escaped data directly into the query string, and we don't burden Airship developers with these complicated steps. Prepared statements allow us to have database-interactive code that is:

Secure against SQL injection

Simple

Easy to read and understand

Furthermore, we disable emulated prepared statements (the PDO default) to side-step corner-case attacks.

Cross-Site Scripting Prevention

CMS Airship employs layered defenses against Cross-Site Scripting (XSS):

Context-aware escaping (provided by Twig, enabled by default).

Security headers.

Unlike other CMS platforms, we don't perform XSS escaping on input, we perform it while rendering the page (and cache the results). Escaping on output prevents clever tricks like column truncation from enabling stored XSS vulnerabilities which plague systems that escape on input.

Additionally, by storing the unaltered version of user data in our database, it becomes possible for us to detect, reproduce, and prevent any hypothetical filter bypass vulnerabilities that would become obfuscated if we escaped data before saving rather than after loading.

To prevent stored XSS vulnerabilities from uploaded files, Airship sends headers to disable MIME Type sniffing and download any files whose MIME type is not whitelisted as an attachment (instead of loading it in the browser). (Related: SVG can contain JavaScript and your browser will execute it if you view an SVG file directly. Standards are often terrible for security.)

CMS Airship also sends a Content-Security-Policy header (see the security headers section).

Cross-Site Request Forgery Mitigation

CMS Airship stores strict Same-Site cookies in your users' browsers (in particular, for the session cookie). For browsers that support Same-Site cookies, this would be enough to mitigate CSRF exploits. Not all browsers currently support Same-Site cookies.

CMS Airship also employs a challenge-response authentication mechanism that is resistant to both timing attacks and replay attacks. Additionally, CSRF mitigation tokens can be bound to a particular request URI.

The interface for managing tokens is straightforward:

PHP code

$postData = $this->post(); if ($postData) { // No CSRF occurred }

Twig Templates

<form method="post" action="/some/uri">{{ form_token("/some/uri") }} <!-- etc. --> </form>

We've explained the technical details of our automatic update mechanism here and covered secure automatic updates in general here.

CMS Airship is the only open source CMS in the PHP ecosystem that offers cryptographically secure automatic updates.

Gears

Airship's Gears feature allows you to change the behavior of the core without your changes being obliterated by automatic updates. This allows you a greater degree of freedom to develop powerful extensions for CMS Airship.

For example, if you wanted to replace the behavior of the AutoPilot router to use Nikita Popov's FastRoute instead, you could do something like this:

<?php declare(strict_types=1); namespace MyVendor\MyNamespace; Gears::extract('AutoPilot', 'AutoPilotShim', __NAMESPACE__); class FastRouteAutoPilot extends AutoPilotShim { /** * Actually serve the HTTP request */ public function route() { // Hook into nikic/fastroute instead, for example } } Gears::attach('AutoPilot', 'FastRouteAutoPilot', __NAMESPACE__);

In the Engine, instead of creating a new AutoPilot class at runtime, it instantiates the gear designated "AutoPilot".

We don't just use simple inheritance because you may want to use two different extensions that upgrade two different pieces of functionality in the same class, and if both classes were to simply extend our base AutoPilot class, it wouldn't be possible to use both extensions.

In sum, the Gears API allows you to:

Change the behavior of core Airship classes from within your custom application or extensions.

Persist your custom core behavior changes in the wake of an automatic update.

Install multiple extensions that affect the same class.

Access Controls / Permissions

Access controls allow you to designate some capabilities to some users. There are a lot of different ways to solve that problem. We designed our solution with these goals in mind:

Must have a simple and meaningful interface

Must be secure-by-default

Must be flexible You can grant roles to a specific user You can grant roles to a specific group of users Groups can inherit permissions from their parent group

Must be database-driven (no code changes, configurable from the web UI)

This is what the API looks like for Airship developers:

if ($this->can('update')) { // Do an update step }

From a Twig template, it looks like this:

{% if can('update') %} {# Update form goes here #} {% endif %}

How Airship's Permissions Work, Under the Hood

Airship uses a white-list access controls system based on three concepts:

Contexts: Where are you in the application? Actions: What are you trying to do? Rules: Which users/groups are allowed to perform which actions in which contexts?

A particular permissions request ( $this->can() ) can match many contexts, especially if there are overlapping patterns. When this happens, every context is validated and the permission request is only granted if they all succeed. If there are no contexts matching a particular request, the request is refused (unless the user is an admin).

Each application has its own set of possible actions (e.g. 'create', 'read', 'update', and 'delete').

Rules grant a particular user or group the ability to perform a particular action within a particular context. Rules can only be used to allow access, not deny access. (That's what white-list means.)

Group inheritance is baked in: If you set a rule to allow a group to perform an action within a given context, then all of that group's descendants will also be allowed.

If you're accessing a particular context, and there are no matching contexts, then (unless you're an administrator) $this->can() will return FALSE by default. This decision was made to prevent Missing Function Level Access Controls vulnerabilities if an attacker managed to find a valid request URI for your application, but that didn't match any contexts in the database.

Progressive Rate-Limiting

When a user attempts to login with an incorrect passphrase or reset their password, a tally is maintained by Airship. The more bad attempts are registered, the longer it will take Airship to respond to subsequent requests (up to a configurable maximum timeout).

The default timeout starts at 125ms and doubles for each failure.

Failure tallies are based on provided username (even if the user doesn't exist) or IP subnet. By default: All IPv4 addresses are treated as distinct (the subnet is /32 ). Any addresses in the same /64 IPv6 subnet is treated as the same user.

The default maximum timeout is 30 seconds.

The timeout can either be enforced client-side (the server just blocks requests and says "try again later") or server-side ( sleep() ). Client-side enforcement carries the corner-case risk of locking a legitimate user out. Server-side enforcement carries the corner-case risk of server resource exhaustion by opening a lot of long-running processes.

).

The goal of this feature is to stop brute-force attacks, not denial-of-service attacks, so we default to client-side enforcement. You can configure this by clicking a checkbox in the admininstrative control panel.

Secure Account Recovery

To put it bluntly, account recovery (a.k.a. "I forgot my password") mechanisms are a backdoor. If a hacker can break into your email account, they usually gain immediate access to any third-party websites that allow password resets (which is most websites that enforce logins).

Our account recovery implementation is the most secure in the industry because your users can opt out of it entirely.

Alternatively, if a user supplies their GnuPG public key, these emails will be encrypted with their public key. In order to attack this setup, you would need to:

Compromise the user's email account. Obtain the user's GnuPG private key.

Furthermore, we use split tokens, so any attempt to exploit a timing side-channel in the database lookup to find a valid password reset token will end in failure.

Input Filtering

In traditional PHP applications, HTTP POST data is accessed via $_POST , which can be a multi-dimensional array. For example, these are all valid HTTP POST queries:

foo=bar&baz=qux foo=bar&baz[]=qux foo=bar&baz[]=qux&baz[]=quux foo=bar&baz[qux]=0

These queries will result in vastly different $_POST structures:

array(2) { ["foo"]=> string(3) "bar" ["baz"]=> string(3) "qux" } array(2) { ["foo"]=> string(3) "bar" ["baz"]=> array(1) { [0]=> string(3) "qux" } } array(2) { ["foo"]=> string(3) "bar" ["baz"]=> array(2) { [0]=> string(3) "qux" [1]=> string(3) "quux" } } array(2) { ["foo"]=> string(3) "bar" ["baz"]=> array(1) { ["qux"]=> string(1) "0" } }

This can be problematic if you're expecting $_POST['baz'] to be a string, but you're given an array. This will either throw a TypeError or raise an E_NOTICE and cast the input to the useless string Array .

To prevent this, we use an input filter system which is implemented alongside CSRF mitigation, so if a CSRF attack occurs or a user sends invalid input, the application will treat the request as if no POST data was present at all:

if ($this->post(new EditPostFilter)) { // Not a CSRF attack AND the input is valid }

Our input filter system contains two components:

An input filter container

The individual filter rules

The input filter system is best illustrated by real-world example.

$this ->addFilter('author', new IntFilter()) ->addFilter('blog_post_body', new StringFilter()) ->addFilter('category', new IntFilter()) ->addFilter('description', new StringFilter()) ->addFilter( 'format', (new WhiteList( 'HTML', 'Markdown', 'Rich Text', 'RST' ))->setDefault('Rich Text') ) ->addFilter('published', new StringFilter()) ->addFilter('metadata', new StringArrayFilter()) ->addFilter( 'title', (new StringFilter()) ->addCallback([StringFilter::class, 'nonEmpty']) ) ->addFilter('redirect_slug', new BoolFilter()) ->addFilter('save_btn', new StringFilter()) ->addFilter('slug', new StringFilter());

The filters are named after the type they enforce. StringFilter ensures the input is a string. StringArrayFilter ensures the input is a one-dimensional array of strings. Callbacks are supported; StringFilter::nonEmpty() just ensures that the string has a non-empty value.

The WhiteList filter is exactly what it says on the label: One of the given values must be selected, or else it will revert to the default value. It's like a switch-case that you don't have to retype (nor do you have to remain cognizant over break; statements).

Security Headers

By default, CMS Airship sends the following HTTP headers on every request:

X-Content-Type-Options: nosniff

X-Frame-Options: SAMEORIGIN

X-XSS-Protection: 1; mode=block

If you're behind HTTPS, CMS Airship will also send a Strict-Transport-Securty header, instructing your browser to never connect to that domain over insecure HTTP.

Additionally, CMS Airship sends a Content-Security-Policy header that is configurable from the web interface.

If you decide you want to protect your Airship against rogue certificate authorities, you can also configure an Public-Key-Pins header.

You can learn more about security headers here.

Secure File Uploads

We follow the steps outlined in this blog post. In particular:

Files are stored outside the web root.

Files are only read, never executed.

If the file type is known to contain executable code or could be used to facilitate phishing (e.g. HTML files), it will instead be served as a text file (MIME type: text/plain ).

Additionally, if a file's real MIME type isn't included in a very lean whitelist, accessing it forces it to be downloaded as an attachment rather than opened in the user's web browser. This helps protect against stored XSS attacks.

Static Page Caching

CMS Airship includes static page caching as a first-class feature. To use it, simply invoke $this->stasis() instead of $this->view() from within a Controller.

Static page caching is a defense against the slashdot effect and helps mitigate the impact of layer 7 DDoS attacks.

A typical response time for an Airship page load on modest hardware is 30 ms. With static page caching, it's 0.3 ms.

Cryptography

CMS Airship uses the Halite cryptography library, whose underlying cryptography is powered by libsodium. If you're familiar with these tools, no further explanation is necessary. Otherwise, learn about Halite and libsodium.

CMS Airship is the Secure-By-Default Content Management System

We hope that reading this post gives everyone a greater appreciation for the security engineering work that went into our free software CMS. A lot of care went into ensuring that Airship would be secure, and that it would be easy to make any third-party extensions developed for Airship be secure.

If you need a team that can carefully construct a secure application for your business, get in touch. We consult.