bnomei/kirby3-security-headers

View on GitHub
classes/SecurityHeaders.php

Summary

Maintainability
B
4 hrs
Test Coverage
<?php

declare(strict_types=1);

namespace Bnomei;

use Kirby\Data\Json;
use Kirby\Data\Yaml;
use Kirby\Toolkit\A;
use Kirby\Filesystem\F;
use Kirby\Filesystem\Mime;
use ParagonIE\CSPBuilder\CSPBuilder;
use function header;

final class SecurityHeaders
{
    /*
     * @var array
     */
    private $options;

    /*
     * @var ParagonIE\CSPBuilder\CSPBuilder
     */
    private $cspBuilder;

    /*
     * @var array
     */
    private $nonces;

    public function __construct(array $options = [])
    {
        $isPanel = strpos(
            kirby()->request()->url()->toString(),
            kirby()->urls()->panel()
        ) !== false;
        $isApi = strpos(
            kirby()->request()->url()->toString(),
            kirby()->urls()->api()
        ) !== false;
        $panelHasNonces =  method_exists(kirby(), 'nonce');

        $enabled = !kirby()->system()->isLocal();
        if ($isPanel || $isApi) {
            $enabled = false;
        }

        $defaults = [
            'debug' => option('debug'),
            'enabled' => option('bnomei.securityheaders.enabled', $enabled),
            'headers' => option('bnomei.securityheaders.headers'),
            'seed' => option('bnomei.securityheaders.seed'),
            'panel' => $isPanel,
            'panelnonces' => $panelHasNonces ? ['panel' => kirby()->nonce()] : [],
            'loader' => option('bnomei.securityheaders.loader'),
            'setter' => option('bnomei.securityheaders.setter'),
        ];
        $this->options = array_merge($defaults, $options);
        $this->nonces = [];

        foreach ($this->options as $key => $call) {
            if (is_callable($call) && in_array($key, ['loader', 'enabled', 'headers', 'seed'])) {
                $this->options[$key] = $call();
            }
        }
    }

    /**
     * @return array|misc
     */
    public function option(?string $key = null)
    {
        if ($key) {
            return A::get($this->options, $key);
        }
        return $this->options;
    }

    /**
     * @return string|null
     */
    public function getNonce(string $key): ?string
    {
        return A::get($this->nonces, $key);
    }

    /**
     * @param string
     */
    public function setNonce(string $key): string
    {
        $nonceArr = [$key, time(), filemtime(__FILE__), kirby()->roots()->assets()];
        shuffle($nonceArr);
        $nonce = base64_encode(sha1(implode('', $nonceArr)));

        $this->nonces[$key] = $nonce;
        return $nonce;
    }

    /**
     * @return mixed
     */
    public function csp()
    {
        return $this->cspBuilder;
    }

    /**
     * @param null $data
     * @return CSPBuilder
     */
    public function load($data = null): CSPBuilder
    {
        if (is_null($data)) {
            $data = $this->option('loader');
        }

        if (is_string($data) && F::exists($data)) {
            $mime = F::mime($data);
            $data = F::read($data);
            if (in_array($mime, A::get(Mime::types(), 'json'))) {
                $data = Json::decode($data);
            } elseif (A::get(Mime::types(), 'yaml') && in_array($mime, A::get(Mime::types(), 'yaml'))) {
                $data = Yaml::decode($data);
            }
        }
        if (is_array($data)) {
            $this->cspBuilder = CSPBuilder::fromArray($data);
        } else {
            $this->cspBuilder = new CSPBuilder();
        }

        // add nonce for self
        if ($self = $this->option('seed')) {
            $nonce = $this->setNonce((string) $self);
            $this->cspBuilder->nonce('script-src', $nonce);
            $this->cspBuilder->nonce('style-src', $nonce);
        }

        // add panel nonces
        if ($this->option('panel')) {
            $panelnonces = $this->option('panelnonces');
            foreach ($panelnonces as $nonce) {
                $this->cspBuilder->nonce('img-src', $nonce);
                $this->cspBuilder->nonce('script-src', $nonce);
                $this->cspBuilder->nonce('style-src', $nonce);
            }
        }

        return $this->cspBuilder;
    }

    /**
     *
     */
    public function applySetter()
    {
        // additional setters
        $csp = $this->option('setter');
        if (is_callable($csp)) {
            $csp($this);
        }
    }

    /**
     * @return bool
     */
    public function sendHeaders(): bool
    {
        if ($this->option('enabled') === false) {
            return false;
        }

        if ($this->option('debug') && $this->option('enabled') !== 'force') {
            return false;
        }

        // from config
        $headers = $this->option('headers');
        foreach ($headers as $key => $value) {
            $value = is_array($value) ? implode('; ', $value) : $value;
            header($key . ': ' . $value);
        }

        // from cspbuilder
        if ($this->cspBuilder) {
            $this->cspBuilder->sendCSPHeader();
        }
        return true;
    }

    /**
     * @param string $filepath
     * @return bool
     */
    public function saveApache(string $filepath): bool
    {
        $this->cspBuilder->saveSnippet($filepath, CSPBuilder::FORMAT_APACHE);
        return F::exists($filepath);
    }

    /**
     * @param string $filepath
     * @return bool
     */
    public function saveNginx(string $filepath): bool
    {
        $this->cspBuilder->saveSnippet($filepath, CSPBuilder::FORMAT_NGINX);
        return F::exists($filepath);
    }

    /*
     * @var SecurityHeaders
     */
    private static $singleton;

    /**
     * @param array $options
     * @return SecurityHeaders
     * @codeCoverageIgnore
     */
    public static function singleton(array $options = []): SecurityHeaders
    {
        if (self::$singleton) {
            return self::$singleton;
        }

        $sec = new SecurityHeaders($options);
        $sec->load();
        $sec->applySetter();
        self::$singleton = $sec;

        return self::$singleton;
    }
}