Taking Content Security Policy to the Extreme! - Policies on a Per-page Basis


Saturday 19th January 2019

For about two years at the time of writing, my website has had a Content Security Policy in order to lock-down and restrict the locations that content such as images and stylesheets can be loaded from. I had used Apache configurations in order to set a more relaxed policy for specific pages that require it, however this solution is not ideal as it becomes challenging to manage when used with larger websites with many different pages, each requiring a different policy.

I have now developed some useful PHP code that allows me to easily set a default policy for the entire website, and then override individual parts of the policy on specific pages where it is required. I've released the code to the public domain under the Unlicense, so you are welcome to use it for your own projects!

Skip to Section:

Taking Content Security Policy to the Extreme! - Policies on a Per-page Basis
┣━━ The Code
┣━━ Other Uses for the Code
┣━━ Implementing the Code with Apache and PHP
┗━━ Conclusion

The Code

If you want to dive straight in, put the code below in a file on your server that can be included using the PHP include() function:

<?php function content_security_policy($overrides = []) {
    $defaultPolicy = [
        "default-src" => "'self'",
        "block-all-mixed-content"
    ];
    $header = "Content-Security-Policy: ";
    foreach(array_merge($defaultPolicy, $overrides) as $directive => $value) {
        if(is_string($directive)) {
            $header .= $directive . " ";
        }
        $header .= $value . "; ";
    }
    header(rtrim($header, "; "));
} ?>

I have released the above PHP code to the public domain using the Unlicense.

This code defines the content_security_policy($overrides = []) function. Calling this function without passing it any arguments serves the default Content-Security-Policy header as defined in the $defaultPolicy array in the code:

Content-Security-Policy: default-src 'none'; block-all-mixed-content

However, you can override individual Content Security Policy directives by passing them in with the $overrides array. Directives and values in this array will either override or be added to the default policy. This allows you to make page-specific overrides to your Content Security Policy, in order set a tighter or more relaxed policy in the exact places that it's needed.

For example:

content_security_policy(["script-src" => "'none'"]);

This will serve the default policy, but the script-src directive will be overridden. In this case, script-src is not defined in the default policy, so it is simply added to the end, resulting in:

Content-Security-Policy: default-src 'self'; block-all-mixed-content; script-src 'none'

However, if you override a directive in the default policy:

content_security_policy(["default-src" => "'none'"]);

...the directive will be overridden in the resulting policy:

Content-Security-Policy: default-src 'none'; block-all-mixed-content

You can pass in as many overrides as you want - directives with associated values (such as script-src must be passed in with the format of "directive" => "value", whereas directives without a configuration value (such as block-all-mixed-content or upgrade-insecure-requests) can just be passed directly as "directive". Make sure to use commas to separate each element of the array.

The code will automatically take care of constructing the header, including adding semicolons between directives (except for the last one), and will also serve the header using the PHP header() function.

Other Uses for the Code

A potential interesting use for this code is for implementing targeted policy violation reporting using the report-uri directive (will be report-to in the future). If there is a particular area of your site that was recently changed or is particularly susceptible to policy violations, you can only enable the reporting there in order to help ensure that more relevant reports are sent to your reporting endpoint (for example the Report URI service).

The code can also be easily modified in order to serve other similar headers, such as Feature-Policy.

Additionally, you could use it to serve other, more-general security headers such as Strict-Transport-Security, however it's unlikely that there is a valid reason to serve differing HSTS policies depending on which page you're visiting. If you can think of a good reason to do this, please let me know.

Another good use for this code would be for serving different Cache-Control headers for different pages. For example, pages with dynamic content could have caching reduced or disabled, while static pages still have caching enabled.

Implementing the Code with Apache and PHP

PHP:

In order to implement this code in a PHP application, the file containing the function needs to be included in every page, and then the function needs to be called once in order for the header to be served.

All HTTP response headers must be served before any of the actual HTML document - in other words, you can only use the PHP header() function before the <!DOCTYPE html> and <html>, etc.

The exact method of implementation will depend on how your site is designed, however you can simply include the file containing the function, and then call it, all on the first line of your code:

<?php include "response-headers.php"; content_security_policy(); ?>

If you have other existing includes at the top of your code, you could include the code in there if that would be appropriate. Just make sure that the function will be called before any of the response body (HTML) is written.

Apache + PHP:

The header code outlined in this article will only serve the header for documents where the function is called. This is great if every single PHP page calls the function, but what about images, stylesheets, scripts, error pages, etc? A Content-Security-Policy should be applied to all resources on your website, not just the pages themselves. While it's true that the policy for a page will cascade down to the subresources in most cases, often subresources will be loaded directly by the visitor, or there will be edge-cases causing the policy to not be applied or enforced.

In order to resolve this issue, you should configure Apache to serve a default, locked-down Content-Security-Policy on all resources returned by the server. However, Apache can then be configured to allow PHP documents to set their own Content-Security-Policy header in situations where the header is set using the code in this article.

There are some challenges with this setup due to the way that Apache handles HTTP response headers. For each request, Apache has two different 'lists' of headers - always and onsuccess. Headers set using Header always set are added to the always list, and headers set using Header set are added to the onsuccess list. onsuccess is the default value, which is why it can be omitted. You can still use Header onsuccess set if you want to.

Headers in the always list are always applied to the request, even if there is an error. However, headers in onsuccess are only applied to successful requests. The key point to note is that these are two distinct lists of headers which will both potentially be applied to the response for the request. This means that setting a particular header in the always list will not override a header with the same name in the onsuccess list, resulting in the response having a duplicate header.

When running PHP with mpm_event and proxy_fcgi:

When you have PHP set up using mpm_event and proxy_fcgi, headers are put into the always list, so no further configuration should be required.

When running PHP with mod_php and mpm_prefork:

Headers set using PHP with mod_php and mpm_prefork go into the onsuccess list, and they can be overridden by Apache. For example, if you set a header in PHP implemented with mod_php using the header() function, but there is also a header with the same name in the Apache configuration set using Header set, the header set with Apache will be given priority, and a single header will be returned. On the other hand, if you have a header set using header(), and then another with the same name set in the Apache configuration using Header always set, both headers will be returned, resulting in a duplicate.

It is important that your Content-Security-Policy is served on all pages, including your error pages and other unsuccessful requests, so the always list should be used. However, since mod_php will be setting a header in the onsuccess list, this will result in a duplicate header by default, which is not good.

In order to resolve this, you have to conditionally unset the Content-Security-Policy header in the always list for PHP files which will be setting their own headers. There are two parts to this configuration - a global fallback Content-Security-Policy header which is applied to all responses by default, and then an expression which conditionally unsets the global fallback header only for PHP files which serve their own custom header.

I have included a copy of my configuration for this below:

Header always set Content-Security-Policy: "default-src 'none'; base-uri 'none'; font-src 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self'; style-src 'self'; block-all-mixed-content"
<FilesMatch \.php$>
    Header always unset Content-Security-Policy "expr=%{REQUEST_STATUS} -in {'200', '400', '401', '403', '404', '500'}"
<FilesMatch \.php$>

This configuration will unset the Content-Security-Policy header in the always list for all requests to .php files which have the response code 200, 400, 401, 403, 404 or 500.

I have custom PHP error pages for 400, 401, 403, 404 and 500 errors, which is why the global override header is removed for those too.

Put this configuration in one of your Apache configuration files, then test the configuration using apachectl configtest. Then you can reload the server configuration using service apache2 reload (or whatever the equivalent is for your operating system). In order to test your headers, you can use the developer console in your browser, or a website such as Security Headers.

Conclusion

I am extremely happy with this addition to my website, as it has made it significantly easier to have fine-grain control over the Content Security Policy and which directives are used on which pages. Being able to make changes to it directly in the files for my site is a much better approach, as everything is clearly visible, easy to read and most importantly, tracked with Git.

Hopefully somebody else finds this code useful, please feel free to let me know if you've used it for your own site or modified it for your own purposes!

This article is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.