Skip to content
This is the documentation for pieceofcake2/security-headers, a CakePHP 2.x plugin for pieceofcake2/cakephp. About this fork.

Security Headers Plugin Documentation ​

The Security Headers plugin adds the security headers to every response of an application with a dispatcher filter: the pages, the redirects, the error pages, the downloads and the files of the plugins and themes. They tell the browsers to restrict what a page can do: which scripts it runs, whether other sites can show it in a frame, and what it sends to other sites.

The headers that the web server or the CDN should send, and the ones that break pages easily, are off by default. The Content Security Policy is sent in report-only mode by default.

Installation ​

Requirements: PHP 8.0+, pieceofcake2/cakephp 2.12+.

bash
composer require pieceofcake2/security-headers

The plugin has the cakephp-plugin type, so it is installed into your plugins directory by composer/installers. Set installer-paths in your application's composer.json:

Layoutinstaller-paths
Modern (config/, src/, templates/, plugins/)"plugins/{$name}/": ["type:cakephp-plugin"]
Legacy (app/Config/, app/Plugin/)"app/Plugin/{$name}/": ["type:cakephp-plugin"]

Load the plugin and add SecurityHeaders.SecurityHeadersFilter to the dispatcher filters in config/bootstrap.php (legacy layout: app/Config/bootstrap.php). If you already use CakePlugin::loadAll(), loading the plugin is not necessary:

php
CakePlugin::load('SecurityHeaders');

Configure::write('Dispatcher.filters', [
    'AssetDispatcher',
    'CacheDispatcher',
    'SecurityHeaders.SecurityHeadersFilter',
]);

The filter runs before the other filters (its priority is 1, AssetDispatcher and CacheDispatcher have 9), wherever it is in the list, so the files of the plugins and the cached views get the headers too.

The files in webroot/ do not reach CakePHP: the web server sends them, with its own headers.

Settings ​

Change the settings with Configure::write('SecurityHeaders', [...]) in config/bootstrap.php. A setting is the value of its header, or false not to send it. The settings that you do not write keep their defaults. An unknown setting, a value of another type (e.g. an array for a header, or 1 for true) and an unknown directive of the policy throw an InvalidArgumentException, so that a typo does not leave a header with its default, or a directive that the browsers ignore, silently.

php
Configure::write('SecurityHeaders', [
    'xFrameOptions' => 'DENY',
    'contentSecurityPolicyAllow' => [
        'cdn' => ['script-src' => ['https://cdn.example.com']],
    ],
]);

On by default:

SettingHeaderDefaultWhat it does
contentSecurityPolicyContent-Security-Policydefault-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'The sources of the scripts, styles, images, frames and forms of the page. It is the main defense against XSS. An array of the directives and their sources, or the policy as a string.
contentSecurityPolicyAllow[]The other sites allowed by the policy: the classes of the services, e.g. GoogleTagManager::class, and the sources by directive with a name. See Allowing Other Sites.
contentSecurityPolicyNoncenullAdds a new nonce of each request to script-src, for the inline scripts that have it in their nonce attribute. By default (null), on when a preset of contentSecurityPolicyAllow needs it; true always, false never. See Inline Scripts and the Nonce.
contentSecurityPolicyReportOnlytrueSends the policy as Content-Security-Policy-Report-Only: the browsers report the violations in the console (and to reportUri) without blocking them. See Enforcing the Content Security Policy.
reportUrifalseA URL that receives the violation reports, added to the policy as report-uri.
xFrameOptionsX-Frame-OptionsSAMEORIGINWhether other sites can show the page in a frame (clickjacking). frame-ancestors of the policy replaces it in modern browsers; this one is for the old ones.
xContentTypeOptionsX-Content-Type-OptionsnosniffThe browsers do not guess the type of a response, e.g. run an uploaded text file as a script.
referrerPolicyReferrer-Policystrict-origin-when-cross-originOther sites get only the origin of the page in Referer, not its path and query, which can contain IDs or tokens.
permissionsPolicyPermissions-Policycamera=(), microphone=(), geolocation=()The browser features that the page and its frames cannot use.
crossOriginOpenerPolicyCross-Origin-Opener-Policysame-originA window of another site that the page opens, or that opens the page, cannot access it with window.opener.
crossOriginResourcePolicyCross-Origin-Resource-Policysame-originOther sites cannot load the responses, e.g. the images or the JSON, with <img> or <script>.
removePoweredByX-Powered-BytrueRemoves the PHP version that PHP adds when expose_php is on.

Off by default:

SettingHeaderExampleWhy it is off
strictTransportSecurityStrict-Transport-Securitymax-age=31536000; includeSubDomainsFor the web server or the CDN. See below.
cacheControlCache-Controlno-storeFor the web server or the CDN, and for the actions: it would replace the Cache-Control of every response, also of the files that should be cached, and the actions set their own with $this->response->cache() or disableCache(). For the pages with a session, see Pages with a session.
crossOriginEmbedderPolicyCross-Origin-Embedder-Policyrequire-corpBreaks the images, scripts and frames of other sites that do not send Cross-Origin-Resource-Policy or CORS headers. Only needed for SharedArrayBuffer and precise timers.
xXssProtectionX-XSS-Protection0Obsolete: the modern browsers ignore it, and the filter of the old ones could be abused. If a scanner asks for it, send 0.

The plugin does not remove Server, which the web server sends: turn it off in the web server (server_tokens off; in nginx, ServerTokens Prod and ServerSignature Off in Apache).

Allowing Other Sites ​

To allow the scripts, images or frames of other sites, list them in contentSecurityPolicyAllow: the classes of the services, and the sources by directive with a name of your choice. The policy keeps its other directives and sources:

php
use PieceOfCake2\SecurityHeaders\ContentSecurityPolicy\GoogleAnalytics;
use PieceOfCake2\SecurityHeaders\ContentSecurityPolicy\GoogleTagManager;
use PieceOfCake2\SecurityHeaders\ContentSecurityPolicy\YouTube;

Configure::write('SecurityHeaders', [
    'contentSecurityPolicyAllow' => [
        GoogleTagManager::class,
        GoogleAnalytics::class,
        'cdn' => [
            'script-src' => ['https://cdn.example.com'],
        ],
        YouTube::class,
    ],
]);

A directive that the policy does not have starts from the sources of the one that the browsers use instead, as default-src for script-src, so that 'self' stays allowed. 'none' is removed when you add sources to a directive. With the default policy, cdn and YouTube::class above give:

text
default-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none';
script-src 'self' https://cdn.example.com https://www.youtube.com; img-src 'self' https://i.ytimg.com;
frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com

The plugin has presets, classes for the tags, the conversion tags and the embeds of Google, Adobe, Meta (Facebook, Instagram), X, TikTok, LINE, YouTube, Yahoo! JAPAN and Microsoft. See Presets for each of them, its sources and its notes: some add 'unsafe-inline' to style-src or 'unsafe-eval' to script-src, as their scripts need.

To use the sources of a service in several applications, write a class of your own that implements Sources, e.g. in src/Lib/, and list it in the same way:

php
use PieceOfCake2\SecurityHeaders\ContentSecurityPolicy\Sources;

class StripeSources implements Sources
{
    /**
     * The sources of Stripe.js
     *
     * @return array<string, array<string>>
     */
    public function sources(): array
    {
        return [
            'script-src' => ['https://js.stripe.com'],
            'frame-src' => ['https://js.stripe.com'],
        ];
    }
}

The plugin creates the class without arguments. If the code of the service has an inline script, implement NeedsNonce instead of Sources: the class then turns on the nonce (Inline Scripts and the Nonce). A class that does not implement Sources or needs arguments, or a value without a name, throws an InvalidArgumentException.

To write the whole policy instead, set contentSecurityPolicy, as an array or as a string:

php
Configure::write('SecurityHeaders', [
    'contentSecurityPolicy' => [
        'default-src' => ["'self'"],
        'img-src' => ["'self'", 'data:'],
        'frame-ancestors' => ["'none'"],
        'form-action' => ["'self'"],
        'base-uri' => ["'self'"],
        'object-src' => ["'none'"],
    ],
]);

For some actions only, use addSources() of the component.

Inline Scripts and the Nonce ​

The policy blocks the inline scripts: the <script> blocks, the attributes such as onclick, and the javascript: links. It allows a <script> block whose nonce attribute has the nonce of the response, a random value that changes with each response.

The nonce is on when a preset whose code has an inline script is in contentSecurityPolicyAllow, e.g. GoogleTagManager ("Turns on the nonce" on the Presets page), or with 'contentSecurityPolicyNonce' => true. false turns it off.

Load the helper in AppController:

php
public $helpers = ['SecurityHeaders.SecurityHeaders'];

Then give the nonce to the inline scripts of the views and the layouts:

php
<!-- In a view or a layout -->
<script nonce="<?= h($this->SecurityHeaders->nonce()) ?>">
    window.dataLayer = window.dataLayer || [];
</script>
<?= $this->Html->scriptBlock('console.log(1);', ['nonce' => $this->SecurityHeaders->nonce()]) ?>
  • Attributes and javascript: links cannot have a nonce: move their code to a script file, e.g. with addEventListener(). The application skeleton (pieceofcake2/app, as that of CakePHP 5) has two: the flash messages hide on a click with an onclick attribute, and the error layout has a javascript:history.back() link. They stop working when the policy is enforced.
  • Without the nonce, the guides of the services allow the inline scripts with 'unsafe-inline', which allows the inline scripts of an XSS too. Add it to contentSecurityPolicyAllow only if you cannot add the nonce.
  • Caches. A nonce is new for each response. A page cached with its HTML, by CacheHelper or by a CDN, has the nonce of another response, so its inline scripts are blocked.

Headers for the Web Server or the CDN ​

Some headers belong to the web server, the load balancer or the CDN, and are off by default:

  • Strict-Transport-Security (HSTS) tells the browsers to use only HTTPS for the site. It must be sent on every response, also the files of webroot/, and only over HTTPS. Behind a load balancer or a CDN that terminates TLS, the application sees HTTP and cannot tell. When you turn it on in the plugin, it is sent only when $request->is('ssl') is true.
  • Cache-Control of the static files and the cached pages belongs to the CDN and the web server.

Turn them on in the plugin only if the application is served without a web server or a CDN that can send them.

Enforcing the Content Security Policy ​

The default policy allows only the scripts, styles and images of the site itself: inline <script> blocks, style attributes, onclick handlers and the files of CDNs are blocked when it is enforced. Most applications need some changes first, so it is sent as Content-Security-Policy-Report-Only by default:

  1. Open the pages of the application with the console of the browser open, and fix or allow what is reported. Move inline scripts to files, or add the sites that you need.
  2. Optionally, collect the reports of the users' browsers with reportUri.
  3. When nothing is reported any more, enforce the policy:
php
Configure::write('SecurityHeaders', [
    'contentSecurityPolicyReportOnly' => false,
]);

Changing the Headers in an Action ​

Use SecurityHeadersComponent to change a header for some actions, e.g. a page that a partner site shows in a frame:

php
class WidgetsController extends AppController
{
    public $components = ['SecurityHeaders.SecurityHeaders'];

    /**
     * Shown in a frame of the partner site
     *
     * @return void
     */
    public function embed(): void
    {
        $this->SecurityHeaders->set('Content-Security-Policy', "frame-ancestors 'self' https://partner.example.com");
        $this->SecurityHeaders->disable('X-Frame-Options');
    }

    /**
     * Shows a video of YouTube
     *
     * @return void
     */
    public function video(): void
    {
        $this->SecurityHeaders->addSources('frame-src', 'https://www.youtube-nocookie.com');
    }
}
  • addSources($directive, $sources) adds sources to a directive of the Content Security Policy of the settings, as contentSecurityPolicyAllow does.
  • set($name, $value) replaces the header of the settings.
  • disable($name) does not send the header in this response.

Write the name of the header that the filter sends: in report-only mode, it is Content-Security-Policy-Report-Only.

Notes ​

  • Popups of other sites. With Cross-Origin-Opener-Policy: same-origin, a popup that you open for a login or a payment on another site cannot reach your page with window.opener. If such a flow breaks, use same-origin-allow-popups.

  • Resources for other sites. With Cross-Origin-Resource-Policy: same-origin, other sites cannot load your images or files with <img> or <script>, e.g. images that you embed in emails or on another domain of yours. Use same-site or cross-origin, for the whole application or for those actions with the component. The requests with CORS, e.g. fetch() of an API, are not affected.

  • Error pages. The error pages get a response of their own, so the filter also sends the headers with header(). A header of the same name that the application or CakePHP sends later replaces it.

  • Pages with a session. CakePHP 2 gives PHP the cache limiter must-revalidate by default, for Internet Explorer 8 and before. PHP does not know this value and sends no cache header, so the pages of a logged-in user have no Cache-Control: a browser can show them again after the logout, and a shared cache can store them. Use the cache limiter of PHP, as CakePHP 5 does, in config/core.php:

    php
    Configure::write('Session.cacheLimiter', 'nocache');

    PHP then sends Cache-Control: no-store, no-cache, must-revalidate with the pages that start a session.

Without the Plugin ​

To send the headers yourself, add them to the response in AppController::beforeFilter(). They are not sent with the files of the plugins and themes and the views cached by CacheDispatcher.

php
/**
 * Adds the security headers to the response
 *
 * @return void
 */
public function beforeFilter(): void
{
    parent::beforeFilter();
    $this->response->header([
        'Content-Security-Policy' => "default-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'; object-src 'none'",
        'X-Frame-Options' => 'SAMEORIGIN',
        'X-Content-Type-Options' => 'nosniff',
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
    ]);
}

Or send them from the web server for every response, e.g. in nginx:

nginx
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

See LICENSE for copyright and license information. Not affiliated with the Cake Software Foundation.