<?php
/**
 * @package     Joomla.Plugin
 * @subpackage  System.HttpHeaders
 *
 * @copyright   (C) 2018 Open Source Matters, Inc. <https://www.joomla.org>
 * @license     GNU General Public License version 2 or later; see LICENSE.txt
 */

defined('_JEXEC') or die;

use Joomla\CMS\Application\CMSApplication;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Uri\Uri;
use Joomla\Database\DatabaseDriver;
use Joomla\Event\SubscriberInterface;

/**
 * Plugin class for HTTP Headers
 *
 * @since  4.0.0
 */
class PlgSystemHttpHeaders extends CMSPlugin implements SubscriberInterface
{
	/**
	 * Application object.
	 *
	 * @var    CMSApplication
	 * @since  4.0.0
	 */
	protected $app;

	/**
	 * Database object.
	 *
	 * @var    DatabaseDriver
	 * @since  4.0.0
	 */
	protected $db;

	/**
	 * The generated csp nonce value
	 *
	 * @var    string
	 * @since  4.0.0
	 */
	private $cspNonce;

	/**
	 * The list of the supported HTTP headers
	 *
	 * @var    array
	 * @since  4.0.0
	 */
	private $supportedHttpHeaders = [
		'strict-transport-security',
		'content-security-policy',
		'content-security-policy-report-only',
		'x-frame-options',
		'referrer-policy',
		'expect-ct',
		'feature-policy',
		'cross-origin-opener-policy',
		'report-to',
		'permissions-policy',
	];

	/**
	 * The list of valid directives based on: https://www.w3.org/TR/CSP3/#csp-directives
	 *
	 * @var    array
	 * @since  4.0.0
	 */
	private $validDirectives = [
		'child-src',
		'connect-src',
		'default-src',
		'font-src',
		'frame-src',
		'img-src',
		'manifest-src',
		'media-src',
		'prefetch-src',
		'object-src',
		'script-src',
		'script-src-elem',
		'script-src-attr',
		'style-src',
		'style-src-elem',
		'style-src-attr',
		'worker-src',
		'base-uri',
		'plugin-types',
		'sandbox',
		'form-action',
		'frame-ancestors',
		'navigate-to',
		'report-uri',
		'report-to',
		'block-all-mixed-content',
		'upgrade-insecure-requests',
		'require-sri-for',
	];

	/**
	 * The list of directives without a value
	 *
	 * @var    array
	 * @since  4.0.0
	 */
	private $noValueDirectives = [
		'block-all-mixed-content',
		'upgrade-insecure-requests',
	];

	/**
	 * The list of directives supporting nonce
	 *
	 * @var    array
	 * @since  4.0.0
	 */
	private $nonceDirectives = [
		'script-src',
		'style-src',
	];

	/**
	 * Constructor.
	 *
	 * @param   object  &$subject  The object to observe.
	 * @param   array   $config    An optional associative array of configuration settings.
	 *
	 * @since   4.0.0
	 */
	public function __construct(&$subject, $config)
	{
		parent::__construct($subject, $config);

		// Nonce generation
		$this->cspNonce = base64_encode(bin2hex(random_bytes(64)));
		$this->app->set('csp_nonce', $this->cspNonce);
	}

	/**
	 * Returns an array of events this subscriber will listen to.
	 *
	 * @return  array
	 *
	 * @since   4.0.0
	 */
	public static function getSubscribedEvents(): array
	{
		return [
			'onAfterInitialise' => 'setHttpHeaders',
			'onAfterRender'     => 'applyHashesToCspRule',
		];
	}

	/**
	 * The `applyHashesToCspRule` method makes sure the csp hashes are added to the csp header when enabled
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 */
	public function applyHashesToCspRule(): void
	{
		// CSP is only relevant on html pages. Let's early exit here.
		if ($this->app->getDocument()->getType() !== 'html')
		{
			return;
		}

		$scriptHashesEnabled = (int) $this->params->get('script_hashes_enabled', 0);
		$styleHashesEnabled  = (int) $this->params->get('style_hashes_enabled', 0);

		// Early exit when both options are disabled
		if (!$scriptHashesEnabled && !$styleHashesEnabled)
		{
			return;
		}

		$headData     = $this->app->getDocument()->getHeadData();
		$scriptHashes = [];
		$styleHashes  = [];

		if ($scriptHashesEnabled)
		{
			// Generate the hashes for the script-src
			$inlineScripts = is_array($headData['script']) ? $headData['script'] : [];

			foreach ($inlineScripts as $type => $scripts)
			{
				foreach ($scripts as $hash => $scriptContent)
				{
					$scriptHashes[] = "'sha256-" . base64_encode(hash('sha256', $scriptContent, true)) . "'";
				}
			}
		}

		if ($styleHashesEnabled)
		{
			// Generate the hashes for the style-src
			$inlineStyles = is_array($headData['style']) ? $headData['style'] : [];

			foreach ($inlineStyles as $type => $styles)
			{
				foreach ($styles as $hash => $styleContent)
				{
					$styleHashes[] = "'sha256-" . base64_encode(hash('sha256', $styleContent, true)) . "'";
				}
			}
		}

		// Replace the hashes in the csp header when set.
		$headers = $this->app->getHeaders();

		foreach ($headers as $id => $headerConfiguration)
		{
			if (strtolower($headerConfiguration['name']) === 'content-security-policy'
				|| strtolower($headerConfiguration['name']) === 'content-security-policy-report-only')
			{
				$newHeaderValue = $headerConfiguration['value'];

				if (!empty($scriptHashes))
				{
					$newHeaderValue = str_replace('{script-hashes}', implode(' ', $scriptHashes), $newHeaderValue);
				}
				else
				{
					$newHeaderValue = str_replace('{script-hashes}', '', $newHeaderValue);
				}

				if (!empty($styleHashes))
				{
					$newHeaderValue = str_replace('{style-hashes}', implode(' ', $styleHashes), $newHeaderValue);
				}
				else
				{
					$newHeaderValue = str_replace('{style-hashes}', '', $newHeaderValue);
				}

				$this->app->setHeader($headerConfiguration['name'], $newHeaderValue, true);
			}
		}
	}

	/**
	 * The `setHttpHeaders` method handle the setting of the configured HTTP Headers
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 */
	public function setHttpHeaders(): void
	{
		// Set the default header when they are enabled
		$this->setStaticHeaders();

		// Handle CSP Header configuration
		$cspEnabled = (int) $this->params->get('contentsecuritypolicy', 0);
		$cspClient  = (string) $this->params->get('contentsecuritypolicy_client', 'site');

		// Check whether CSP is enabled and enabled by the current client
		if ($cspEnabled && ($this->app->isClient($cspClient) || $cspClient === 'both'))
		{
			$this->setCspHeader();
		}

		if ($this->app->get('block_floc', 1))
		{
			$headers = $this->app->getHeaders();

			$notPresent = true;

			foreach ($headers as $header)
			{
				if (strtolower($header['name']) === 'permissions-policy')
				{
					// Append interest-cohort if the Permissions-Policy is not set
					if (strpos($header['value'], 'interest-cohort') === false)
					{
						$this->app->setHeader('Permissions-Policy', $header['value'] . ', interest-cohort=()', true);
					}

					$notPresent = false;

					break;
				}
			}

			if ($notPresent)
			{
				$this->app->setHeader('Permissions-Policy', 'interest-cohort=()');
			}
		}
	}

	/**
	 * Set the CSP header when enabled
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 */
	private function setCspHeader(): void
	{
		$cspReadOnly = (int) $this->params->get('contentsecuritypolicy_report_only', 1);
		$cspHeader   = $cspReadOnly === 0 ? 'content-security-policy' : 'content-security-policy-report-only';

		// In custom mode we compile the header from the values configured
		$cspValues                 = $this->params->get('contentsecuritypolicy_values', []);
		$nonceEnabled              = (int) $this->params->get('nonce_enabled', 0);
		$scriptHashesEnabled       = (int) $this->params->get('script_hashes_enabled', 0);
		$strictDynamicEnabled      = (int) $this->params->get('strict_dynamic_enabled', 0);
		$styleHashesEnabled        = (int) $this->params->get('style_hashes_enabled', 0);
		$frameAncestorsSelfEnabled = (int) $this->params->get('frame_ancestors_self_enabled', 1);
		$frameAncestorsSet         = false;

		foreach ($cspValues as $cspValue)
		{
			// Handle the client settings foreach header
			if (!$this->app->isClient($cspValue->client) && $cspValue->client != 'both')
			{
				continue;
			}

			// Handle non value directives
			if (in_array($cspValue->directive, $this->noValueDirectives))
			{
				$newCspValues[] = trim($cspValue->directive);

				continue;
			}

			// We can only use this if this is a valid entry
			if (in_array($cspValue->directive, $this->validDirectives)
				&& !empty($cspValue->value))
			{
				if (in_array($cspValue->directive, $this->nonceDirectives) && $nonceEnabled)
				{
					// Append the nonce
					$cspValue->value = str_replace('{nonce}', "'nonce-" . $this->cspNonce . "'", $cspValue->value);
				}

				// Append the script hashes placeholder
				if ($scriptHashesEnabled && strpos($cspValue->directive, 'script-src') === 0)
				{
					$cspValue->value = '{script-hashes} ' . $cspValue->value;
				}

				// Append the style hashes placeholder
				if ($styleHashesEnabled && strpos($cspValue->directive, 'style-src') === 0)
				{
					$cspValue->value = '{style-hashes} ' . $cspValue->value;
				}

				if ($cspValue->directive === 'frame-ancestors')
				{
					$frameAncestorsSet = true;
				}

				// Add strict-dynamic to the script-src directive when enabled
				if ($strictDynamicEnabled
					&& $cspValue->directive === 'script-src'
					&& strpos($cspValue->value, 'strict-dynamic') === false)
				{
					$cspValue->value .= " 'strict-dynamic' ";
				}

				$newCspValues[] = trim($cspValue->directive) . ' ' . trim($cspValue->value);
			}
		}

		if ($frameAncestorsSelfEnabled && !$frameAncestorsSet)
		{
			$newCspValues[] = "frame-ancestors 'self'";
		}

		if (empty($newCspValues))
		{
			return;
		}

		$this->app->setHeader($cspHeader, trim(implode('; ', $newCspValues)));
	}

	/**
	 * Get the configured static headers.
	 *
	 * @return  array  We return the array of static headers with its values.
	 *
	 * @since   4.0.0
	 */
	private function getStaticHeaderConfiguration(): array
	{
		$staticHeaderConfiguration = [];

		// X-frame-options
		if ($this->params->get('xframeoptions', 1) === 1)
		{
			$staticHeaderConfiguration['x-frame-options#both'] = 'SAMEORIGIN';
		}

		// Referrer-policy
		$referrerPolicy = (string) $this->params->get('referrerpolicy', 'strict-origin-when-cross-origin');

		if ($referrerPolicy !== 'disabled')
		{
			$staticHeaderConfiguration['referrer-policy#both'] = $referrerPolicy;
		}

		// Cross-Origin-Opener-Policy
		$coop = (string) $this->params->get('coop', 'same-origin');

		if ($coop !== 'disabled')
		{
			$staticHeaderConfiguration['cross-origin-opener-policy#both'] = $coop;
		}

		// Generate the strict-transport-security header and make sure the site is SSL
		if ($this->params->get('hsts', 0) === 1 && Uri::getInstance()->isSsl() === true)
		{
			$hstsOptions   = [];
			$hstsOptions[] = 'max-age=' . (int) $this->params->get('hsts_maxage', 31536000);

			if ($this->params->get('hsts_subdomains', 0) === 1)
			{
				$hstsOptions[] = 'includeSubDomains';
			}

			if ($this->params->get('hsts_preload', 0) === 1)
			{
				$hstsOptions[] = 'preload';
			}

			$staticHeaderConfiguration['strict-transport-security#both'] = implode('; ', $hstsOptions);
		}

		// Generate the additional headers
		$additionalHttpHeaders = $this->params->get('additional_httpheader', []);

		foreach ($additionalHttpHeaders as $additionalHttpHeader)
		{
			// Make sure we have a key and a value
			if (empty($additionalHttpHeader->key) || empty($additionalHttpHeader->value))
			{
				continue;
			}

			// Make sure the header is a valid and supported header
			if (!in_array(strtolower($additionalHttpHeader->key), $this->supportedHttpHeaders))
			{
				continue;
			}

			// Make sure we do not add one header twice but we support to set a different header per client.
			if (isset($staticHeaderConfiguration[$additionalHttpHeader->key . '#' . $additionalHttpHeader->client])
				|| isset($staticHeaderConfiguration[$additionalHttpHeader->key . '#both']))
			{
				continue;
			}

			// Allow the custom csp headers to use the random $cspNonce in the rules
			if (in_array(strtolower($additionalHttpHeader->key), ['content-security-policy', 'content-security-policy-report-only']))
			{
				$additionalHttpHeader->value = str_replace('{nonce}', "'nonce-" . $this->cspNonce . "'", $additionalHttpHeader->value);
			}

			$staticHeaderConfiguration[$additionalHttpHeader->key . '#' . $additionalHttpHeader->client] = $additionalHttpHeader->value;
		}

		return $staticHeaderConfiguration;
	}

	/**
	 * Set the static headers when enabled
	 *
	 * @return  void
	 *
	 * @since   4.0.0
	 */
	private function setStaticHeaders(): void
	{
		$staticHeaderConfiguration = $this->getStaticHeaderConfiguration();

		if (empty($staticHeaderConfiguration))
		{
			return;
		}

		foreach ($staticHeaderConfiguration as $headerAndClient => $value)
		{
			$headerAndClient = explode('#', $headerAndClient);
			$header = $headerAndClient[0];
			$client = isset($headerAndClient[1]) ? $headerAndClient[1] : 'both';

			if (!$this->app->isClient($client) && $client != 'both')
			{
				continue;
			}

			$this->app->setHeader($header, $value, true);
		}
	}
}
