PHP Classes

File: src/CSPBuilder.php

Recommend this page to a friend!
  Classes of Scott Arciszewski  >  PHP CSP Header Builder  >  src/CSPBuilder.php  >  Download  
File: src/CSPBuilder.php
Role: Class source
Content type: text/plain
Description: Class source
Class: PHP CSP Header Builder
Generate Content Security Policy headers
Author: By
Last change:
Date: 3 years ago
Size: 25,260 bytes
 

Contents

Class file image Download
<?php
declare(strict_types=1);
namespace ParagonIE\CSPBuilder;

use \ParagonIE\ConstantTime\Base64;
use \Psr\Http\Message\MessageInterface;

/**
 * Class CSPBuilder
 * @package ParagonIE\CSPBuilder
 */
class CSPBuilder
{
    const FORMAT_APACHE = 'apache';
    const FORMAT_NGINX = 'nginx';

    /**
     * @var array
     */
    private $policies = [];

    /**
     * @var array<int, string>
     */
    private $requireSRIFor = [];

    /**
     * @var bool
     */
    private $needsCompile = true;

    /**
     * @var string
     */
    private $compiled = '';

    /**
     * @var bool
     */
    private $reportOnly = false;

    /**
     * @var bool
     */
    protected $supportOldBrowsers = true;

    /**
     * @var bool
     */
    protected $httpsTransformOnHttpsConnections = true;

    /**
     * @var string[]
     */
    private static $directives = [
        'base-uri',
        'default-src',
        'child-src',
        'connect-src',
        'font-src',
        'form-action',
        'frame-ancestors',
        'frame-src',
        'img-src',
        'media-src',
        'object-src',
        'plugin-types',
        'manifest-src',
        'script-src',
        'style-src',
        'worker-src'
    ];

    /**
     * @param array $policy
     */
    public function __construct(array $policy = [])
    {
        $this->policies = $policy;
    }

    /**
     * Compile the current policies into a CSP header
     *
     * @return string
     * @throws \TypeError
     */
    public function compile(): string
    {
        $ruleKeys = \array_keys($this->policies);
        if (\in_array('report-only', $ruleKeys)) {
            $this->reportOnly = !!$this->policies['report-only'];
        } else {
            $this->reportOnly = false;
        }

        $compiled = [];

        foreach (self::$directives as $dir) {
            if (\in_array($dir, $ruleKeys)) {
                if (empty($ruleKeys)) {
                    if ($dir === 'base-uri') {
                        continue;
                    }
                }
                $compiled []= $this->compileSubgroup(
                    $dir,
                    $this->policies[$dir]
                );
            }
        }

        if (!empty($this->policies['report-uri'])) {
            if (!\is_string($this->policies['report-uri'])) {
                throw new \TypeError('report-uri policy somehow not a string');
            }
            if ($this->supportOldBrowsers) {
                $compiled [] = 'report-uri ' . $this->policies['report-uri'] . '; ';
            }
            $compiled []= 'report-to ' . $this->policies['report-uri'] . '; ';
        }
        if (!empty($this->policies['upgrade-insecure-requests'])) {
            $compiled []= 'upgrade-insecure-requests';
        }

        $this->compiled = \implode('', $compiled);
        $this->needsCompile = false;
        return $this->compiled;
    }

    /**
     * Add a source to our allow white-list
     *
     * @param string $directive
     * @param string $path
     *
     * @return self
     */
    public function addSource(string $directive, string $path): self
    {
        switch ($directive) {
            case 'child':
            case 'frame':
            case 'frame-src':
                if ($this->supportOldBrowsers) {
                    $this->policies['child-src']['allow'][] = $path;
                    $this->policies['frame-src']['allow'][] = $path;
                    return $this;
                }
                $directive = 'child-src';
                break;
            case 'connect':
            case 'socket':
            case 'websocket':
                $directive = 'connect-src';
                break;
            case 'font':
            case 'fonts':
                $directive = 'font-src';
                break;
            case 'form':
            case 'forms':
                $directive = 'form-action';
                break;
            case 'ancestor':
            case 'parent':
                $directive = 'frame-ancestors';
                break;
            case 'img':
            case 'image':
            case 'image-src':
                $directive = 'img-src';
                break;
            case 'media':
                $directive = 'media-src';
                break;
            case 'object':
                $directive = 'object-src';
                break;
            case 'js':
            case 'javascript':
            case 'script':
            case 'scripts':
                $directive = 'script-src';
                break;
            case 'style':
            case 'css':
            case 'css-src':
                $directive = 'style-src';
                break;
            case 'worker':
                $directive = 'worker-src';
                break;
        }
        $this->policies[$directive]['allow'][] = $path;
        return $this;
    }

    /**
     * Add a directive if it doesn't already exist
     *
     * If it already exists, do nothing
     *
     * @param string $key
     * @param mixed $value
     *
     * @return self
     */
    public function addDirective(string $key, $value = null): self
    {
        if ($value === null) {
            if (!isset($this->policies[$key])) {
                $this->policies[$key] = true;
            }
        } elseif (empty($this->policies[$key])) {
            $this->policies[$key] = $value;
        }
        return $this;
    }

    /**
     * Add a plugin type to be added
     *
     * @param string $mime
     * @return self
     */
    public function allowPluginType(string $mime = 'text/plain'): self
    {
        $this->policies['plugin-types']['types'] []= $mime;

        $this->needsCompile = true;
        return $this;
    }

    /**
     * Disable old browser support (e.g. Safari)
     *
     * @return self
     */
    public function disableOldBrowserSupport(): self
    {
        $this->needsCompile = ($this->needsCompile || $this->supportOldBrowsers !== false);
        $this->supportOldBrowsers = false;
        return $this;
    }

    /**
     * Enable old browser support (e.g. Safari)
     *
     * This is enabled by default
     *
     * @return self
     */
    public function enableOldBrowserSupport(): self
    {
        $this->needsCompile = ($this->needsCompile || $this->supportOldBrowsers !== true);
        $this->supportOldBrowsers = true;
        return $this;
    }

    /**
     * This just passes the array to the constructor, but hopefully will save
     * someone in a hurry from a moment of frustration.
     *
     * @param array $array
     * @return self
     */
    public static function fromArray(array $array = []): self
    {
        return new CSPBuilder($array);
    }

    /**
     * Factory method - create a new CSPBuilder object from a JSON data
     *
     * @param string $data
     * @return self
     * @throws \Exception
     */
    public static function fromData($data = ''): self
    {
        $array = \json_decode($data, true);

        if (!\is_array($array)) {
            throw new \Exception('Is not array valid');
        }

        return new CSPBuilder($array);
    }

    /**
     * Factory method - create a new CSPBuilder object from a JSON file
     *
     * @param string $filename
     * @return self
     * @throws \Exception
     */
    public static function fromFile(string $filename = ''): self
    {
        if (!\file_exists($filename)) {
            throw new \Exception($filename.' does not exist');
        }
        $contents = \file_get_contents($filename);
        if (!\is_string($contents)) {
            throw new \Exception('Could not read file contents');
        }
        return self::fromData($contents);
    }

    /**
     * Get the formatted CSP header
     *
     * @return string
     */
    public function getCompiledHeader(): string
    {
        if ($this->needsCompile) {
            $this->compile();
        }
        return $this->compiled;
    }

    /**
     * Get an associative array of headers to return.
     *
     * @param bool $legacy
     * @return array<string, string>
     */
    public function getHeaderArray(bool $legacy = true): array
    {
        if ($this->needsCompile) {
            $this->compile();
        }
        $return = [];
        foreach ($this->getHeaderKeys($legacy) as $key) {
            $return[(string) $key] = $this->compiled;
        }
        return $return;
    }

    /**
     * @return array<int, array{0:string, 1:string}>
     */
    public function getRequireHeaders(): array
    {
        $headers = [];
        foreach ($this->requireSRIFor as $directive) {
            $headers[] = [
                'Content-Security-Policy',
                'require-sri-for ' . $directive
            ];
        }
        return $headers;
    }

    /**
     * Add a new hash to the existing CSP
     *
     * @param string $directive
     * @param string $script
     * @param string $algorithm
     * @return self
     */
    public function hash(
        string $directive = 'script-src',
        string $script = '',
        string $algorithm = 'sha384'
    ): self {
        $ruleKeys = \array_keys($this->policies);
        if (\in_array($directive, $ruleKeys)) {
            $this->policies[$directive]['hashes'] []= [
                $algorithm => Base64::encode(
                    \hash($algorithm, $script, true)
                )
            ];
        }
        return $this;
    }

    /**
     * PSR-7 header injection.
     *
     * This will inject the header into your PSR-7 object. (Request, Response,
     * etc.) This method returns an instance of whatever you passed, so long
     * as it implements MessageInterface.
     *
     * @param \Psr\Http\Message\MessageInterface $message
     * @param bool $legacy
     * @return \Psr\Http\Message\MessageInterface
     */
    public function injectCSPHeader(MessageInterface $message, bool $legacy = false): MessageInterface
    {
        if ($this->needsCompile) {
            $this->compile();
        }
        foreach ($this->getRequireHeaders() as $header) {
            list ($key, $value) = $header;
            $message = $message->withAddedHeader($key, $value);
        }
        foreach ($this->getHeaderKeys($legacy) as $key) {
            $message = $message->withAddedHeader($key, $this->compiled);
        }
        return $message;
    }

    /**
     * Add a new nonce to the existing CSP. Returns the nonce generated.
     *
     * @param string $directive
     * @param string $nonce (if empty, it will be generated)
     * @return string
     * @throws \Exception
     */
    public function nonce(string $directive = 'script-src', string $nonce = ''): string
    {
        $ruleKeys = \array_keys($this->policies);
        if (!\in_array($directive, $ruleKeys)) {
            return '';
        }

        if (empty($nonce)) {
            $nonce = Base64::encode(\random_bytes(18));
        }
        $this->policies[$directive]['nonces'] []= $nonce;
        return $nonce;
    }

    /**
     * Add a new (pre-calculated) base64-encoded hash to the existing CSP
     *
     * @param string $directive
     * @param string $hash
     * @param string $algorithm
     * @return self
     */
    public function preHash(
        string $directive = 'script-src',
        string $hash = '',
        string $algorithm = 'sha384'
    ): self {
        $ruleKeys = \array_keys($this->policies);
        if (\in_array($directive, $ruleKeys)) {
            $this->policies[$directive]['hashes'] []= [
                $algorithm => $hash
            ];
        }
        return $this;
    }

    /**
     * @param string $directive
     * @return self
     */
    public function requireSRIFor(string $directive): self
    {
        if (!\in_array($directive, $this->requireSRIFor, true)) {
            $this->requireSRIFor[] = $directive;
        }
        return $this;
    }

    /**
     * Save CSP to a snippet file
     *
     * @param string $outputFile Output file name
     * @param string $format Which format are we saving in?
     * @return bool
     * @throws \Exception
     */
    public function saveSnippet(
        string $outputFile,
        string $format = self::FORMAT_NGINX
    ): bool {
        if ($this->needsCompile) {
            $this->compile();
        }

        // Are we doing a report-only header?
        $which = $this->reportOnly
            ? 'Content-Security-Policy-Report-Only'
            : 'Content-Security-Policy';

        switch ($format) {
            case self::FORMAT_NGINX:
                // In PHP < 7, implode() is faster than concatenation
                $output = \implode('', [
                    'add_header ',
                    $which,
                    ' "',
                    \rtrim($this->compiled, ' '),
                    '" always;',
                    "\n"
                ]);
                break;
            case self::FORMAT_APACHE:
                $output = \implode('', [
                    'Header add ',
                    $which,
                    ' "',
                    \rtrim($this->compiled, ' '),
                    '"',
                    "\n"
                ]);
                break;
            default:
                throw new \Exception('Unknown format: '.$format);
        }
        return \file_put_contents($outputFile, $output) !== false;
    }

    /**
     * Send the compiled CSP as a header()
     *
     * @param bool $legacy Send legacy headers?
     *
     * @return bool
     * @throws \Exception
     */
    public function sendCSPHeader(bool $legacy = true): bool
    {
        if (\headers_sent()) {
            throw new \Exception('Headers already sent!');
        }
        if ($this->needsCompile) {
            $this->compile();
        }
        foreach ($this->getRequireHeaders() as $header) {
            list ($key, $value) = $header;
            \header($key.': '.$value);
        }
        foreach ($this->getHeaderKeys($legacy) as $key) {
            \header($key.': '.$this->compiled);
        }
        return true;
    }

    /**
     * Allow/disallow unsafe-eval within a given directive.
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setAllowUnsafeEval(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['unsafe-eval'] = $allow;
        return $this;
    }

    /**
     * Allow/disallow unsafe-inline within a given directive.
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setAllowUnsafeInline(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['unsafe-inline'] = $allow;
        return $this;
    }

    /**
     * Allow/disallow blob: URIs for a given directive
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setBlobAllowed(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['blob'] = $allow;
        return $this;
    }

    /**
     * Allow/disallow data: URIs for a given directive
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setDataAllowed(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['data'] = $allow;
        return $this;
    }

    /**
     * Set a directive.
     *
     * This lets you overwrite a complex directive entirely (e.g. script-src)
     * or set a top-level directive (e.g. report-uri).
     *
     * @param string $key
     * @param mixed $value
     *
     * @return self
     */
    public function setDirective(string $key, $value = []): self
    {
        $this->policies[$key] = $value;
        return $this;
    }

    /**
     * Allow/disallow filesystem: URIs for a given directive
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setFileSystemAllowed(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['filesystem'] = $allow;
        return $this;
    }

    /**
     * Allow/disallow mediastream: URIs for a given directive
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setMediaStreamAllowed(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['mediastream'] = $allow;
        return $this;
    }

    /**
     * Allow/disallow self URIs for a given directive
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setSelfAllowed(string $directive = '', bool $allow = false): self
    {
        if (!\in_array($directive, self::$directives)) {
            throw new \Exception('Directive ' . $directive . ' does not exist');
        }
        $this->policies[$directive]['self'] = $allow;
        return $this;
    }

    /**
     * @see CSPBuilder::setAllowUnsafeEval()
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setUnsafeEvalAllowed(string $directive = '', bool $allow = false): self
    {
        return $this->setAllowUnsafeEval($directive, $allow);
    }

    /**
     * @see CSPBuilder::setAllowUnsafeInline()
     *
     * @param string $directive
     * @param bool $allow
     * @return self
     * @throws \Exception
     */
    public function setUnsafeInlineAllowed(string $directive = '', bool $allow = false): self
    {
        return $this->setAllowUnsafeInline($directive, $allow);
    }

    /**
     * Set strict-dynamic for a given directive.
     *
     * @param string $directive
     * @param bool $allow
     *
     * @return self
     * @throws \Exception
     */
    public function setStrictDynamic(string $directive = '', bool $allow = false): self
    {
        $this->policies[$directive]['strict-dynamic'] = $allow;
        return $this;
    }

    /**
     * Set the Report URI to the desired string. This also sets the 'report-to'
     * component of the CSP header for CSP Level 3 compatibility.
     *
     * @param string $url
     * @return self
     */
    public function setReportUri(string $url = ''): self
    {
        $this->policies['report-uri'] = $url;
        return $this;
    }

    /**
     * Compile a subgroup into a policy string
     *
     * @param string $directive
     * @param mixed $policies
     *
     * @return string
     */
    protected function compileSubgroup(string $directive, $policies = []): string
    {
        if ($policies === '*') {
            // Don't even waste the overhead adding this to the header
            return '';
        } elseif (empty($policies)) {
            if ($directive === 'plugin-types') {
                return '';
            }
            return $directive." 'none'; ";
        }
        $ret = $directive.' ';
        if ($directive === 'plugin-types') {
            // Expects MIME types, not URLs
            return $ret . \implode(' ', $policies['allow']).'; ';
        }
        if (!empty($policies['self'])) {
            $ret .= "'self' ";
        }

        if (!empty($policies['allow'])) {
            foreach ($policies['allow'] as $url) {
                $url = \filter_var($url, FILTER_SANITIZE_URL);
                if ($url !== false) {
                    if ($this->supportOldBrowsers) {
                        if (\strpos($url, '://') === false) {
                            if (($this->isHTTPSConnection() && $this->httpsTransformOnHttpsConnections)
                                || !empty($this->policies['upgrade-insecure-requests'])) {
                                // We only want HTTPS connections here.
                                $ret .= 'https://'.$url.' ';
                            } else {
                                $ret .= 'https://'.$url.' http://'.$url.' ';
                            }
                        }
                    }
                    if (($this->isHTTPSConnection() && $this->httpsTransformOnHttpsConnections)
                        || !empty($this->policies['upgrade-insecure-requests'])) {
                        $ret .= \str_replace('http://', 'https://', $url).' ';
                    } else {
                        $ret .= $url.' ';
                    }
                }
            }
        }

        if (!empty($policies['hashes'])) {
            foreach ($policies['hashes'] as $hash) {
                foreach ($hash as $algo => $hashval) {
                    $ret .= \implode('', [
                        "'",
                        \preg_replace('/[^A-Za-z0-9]/', '', $algo),
                        '-',
                        \preg_replace('/[^A-Za-z0-9\+\/=]/', '', $hashval),
                        "' "
                    ]);
                }
            }
        }

        if (!empty($policies['nonces'])) {
            foreach ($policies['nonces'] as $nonce) {
                $ret .= \implode('', [
                    "'nonce-",
                    \preg_replace('/[^A-Za-z0-9\+\/=]/', '', $nonce),
                    "' "
                ]);
            }
        }

        if (!empty($policies['types'])) {
            foreach ($policies['types'] as $type) {
                $ret .= $type.' ';
            }
        }

        if (!empty($policies['unsafe-inline'])) {
            $ret .= "'unsafe-inline' ";
        }
        if (!empty($policies['unsafe-eval'])) {
            $ret .= "'unsafe-eval' ";
        }
        if (!empty($policies['blob'])) {
            $ret .= "blob: ";
        }
        if (!empty($policies['data'])) {
            $ret .= "data: ";
        }
        if (!empty($policies['mediastream'])) {
            $ret .= "mediastream: ";
        }
        if (!empty($policies['filesystem'])) {
            $ret .= "filesystem: ";
        }
        if (!empty($policies['strict-dynamic'])) {
            $ret .= "'strict-dynamic' ";
        }
        if (!empty($policies['unsafe-hashed-attributes'])) {
            $ret .= "'unsafe-hashed-attributes' ";
        }
        return \rtrim($ret, ' ').'; ';
    }

    /**
     * Get an array of header keys to return
     *
     * @param bool $legacy
     * @return array
     */
    protected function getHeaderKeys(bool $legacy = true): array
    {
        // We always want this
        $return = [
            $this->reportOnly
                ? 'Content-Security-Policy-Report-Only'
                : 'Content-Security-Policy'
        ];

        // If we're supporting legacy devices, include these too:
        if ($legacy) {
            $return []= $this->reportOnly
                ? 'X-Content-Security-Policy-Report-Only'
                : 'X-Content-Security-Policy';
            $return []= $this->reportOnly
                ? 'X-Webkit-CSP-Report-Only'
                : 'X-Webkit-CSP';
        }
        return $return;
    }

    /**
     * Is this user currently connected over HTTPS?
     *
     * @return bool
     */
    protected function isHTTPSConnection(): bool
    {
        if (!empty($_SERVER['HTTPS'])) {
            return $_SERVER['HTTPS'] !== 'off';
        }
        return false;
    }

    /**
     * Disable that HTTP sources get converted to HTTPS if the connection is such.
     *
     * @return self
     */
    public function disableHttpsTransformOnHttpsConnections(): self
    {
        $this->needsCompile = ($this->needsCompile || $this->httpsTransformOnHttpsConnections !== false);
        $this->httpsTransformOnHttpsConnections = false;

        return $this;
    }

    /**
     * Enable that HTTP sources get converted to HTTPS if the connection is such.
     *
     * This is enabled by default
     *
     * @return self
     */
    public function enableHttpsTransformOnHttpsConnections(): self
    {
        $this->needsCompile = ($this->needsCompile || $this->httpsTransformOnHttpsConnections !== true);
        $this->httpsTransformOnHttpsConnections = true;

        return $this;
    }
}
For more information send a message to info at phpclasses dot org.