View source
<?php
namespace Drupal\csp\Form;
use Drupal\Component\Plugin\Exception\PluginException;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\csp\Csp;
use Drupal\csp\LibraryPolicyBuilder;
use Drupal\csp\ReportingHandlerPluginManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
class CspSettingsForm extends ConfigFormBase {
private $libraryPolicyBuilder;
private $reportingHandlerPluginManager;
private static $keywordDirectiveMap = [
'report-sample' => [
'default-src',
'script-src',
'script-src-attr',
'script-src-elem',
'style-src',
'style-src-attr',
'style-src-elem',
],
'strict-dynamic' => [
'default-src',
'script-src',
],
'unsafe-allow-redirects' => [
'navigate-to',
],
'unsafe-eval' => [
'default-src',
'script-src',
'style-src',
],
'unsafe-hashes' => [
'default-src',
'script-src',
'script-src-attr',
'style-src',
'style-src-attr',
],
'unsafe-inline' => [
'default-src',
'script-src',
'script-src-attr',
'script-src-elem',
'style-src',
'style-src-attr',
'style-src-elem',
],
];
public function getFormId() {
return 'csp_settings';
}
protected function getEditableConfigNames() {
return [
'csp.settings',
];
}
public function __construct(ConfigFactoryInterface $config_factory, LibraryPolicyBuilder $libraryPolicyBuilder, ReportingHandlerPluginManager $reportingHandlerPluginManager, MessengerInterface $messenger) {
parent::__construct($config_factory);
$this->libraryPolicyBuilder = $libraryPolicyBuilder;
$this->reportingHandlerPluginManager = $reportingHandlerPluginManager;
$this
->setMessenger($messenger);
}
public static function create(ContainerInterface $container) {
return new static($container
->get('config.factory'), $container
->get('csp.library_policy_builder'), $container
->get('plugin.manager.csp_reporting_handler'), $container
->get('messenger'));
}
private function getConfigurableDirectives() {
$directives = array_diff(Csp::getDirectiveNames(), [
'report-uri',
'report-to',
'referrer',
'require-sri-for',
]);
return $directives;
}
private function getKeywordOptions($directive) {
$allKeywords = [
'unsafe-inline',
'unsafe-eval',
'unsafe-hashes',
'unsafe-allow-redirects',
'strict-dynamic',
'report-sample',
];
return array_filter($allKeywords, function ($keyword) use ($directive) {
return !array_key_exists($keyword, self::$keywordDirectiveMap) || in_array($directive, self::$keywordDirectiveMap[$keyword]);
});
}
public function buildForm(array $form, FormStateInterface $form_state) {
$reportingHandlerPluginDefinitions = $this->reportingHandlerPluginManager
->getDefinitions();
$config = $this
->config('csp.settings');
$autoDirectives = $this->libraryPolicyBuilder
->getSources();
$form['#attached']['library'][] = 'csp/admin';
$form['policies'] = [
'#type' => 'vertical_tabs',
'#title' => $this
->t('Policies'),
];
$directiveNames = $this
->getConfigurableDirectives();
$enforceOnlyDirectives = [
'upgrade-insecure-requests',
'sandbox',
];
$policyTypes = [
'report-only' => $this
->t('Report Only'),
'enforce' => $this
->t('Enforced'),
];
foreach ($policyTypes as $policyTypeKey => $policyTypeName) {
$form[$policyTypeKey] = [
'#type' => 'details',
'#title' => $policyTypeName,
'#group' => 'policies',
'#tree' => TRUE,
];
if ($config
->get($policyTypeKey . '.enable')) {
$form['policies']['#default_tab'] = 'edit-' . $policyTypeKey;
}
$form[$policyTypeKey]['enable'] = [
'#type' => 'checkbox',
'#title' => $this
->t("Enable '@type'", [
'@type' => $policyTypeName,
]),
'#default_value' => $config
->get($policyTypeKey . '.enable') ?: FALSE,
];
$form[$policyTypeKey]['directives'] = [
'#type' => 'fieldset',
'#title' => $this
->t('Directives'),
'#description_display' => 'before',
'#tree' => TRUE,
];
foreach ($directiveNames as $directiveName) {
$directiveSchema = Csp::getDirectiveSchema($directiveName);
$form[$policyTypeKey]['directives'][$directiveName] = [
'#type' => 'container',
'#access' => $policyTypeKey == 'enforce' || !in_array($directiveName, $enforceOnlyDirectives),
];
$form[$policyTypeKey]['directives'][$directiveName]['enable'] = [
'#type' => 'checkbox',
'#title' => $directiveName,
];
if (!empty($autoDirectives[$directiveName])) {
$form[$policyTypeKey]['directives'][$directiveName]['enable']['#title'] .= ' <span class="csp-directive-auto">auto</span>';
}
if ($config
->get($policyTypeKey)) {
$form[$policyTypeKey]['directives'][$directiveName]['enable']['#default_value'] = !is_null($config
->get($policyTypeKey . '.directives.' . $directiveName));
}
else {
if (in_array($directiveName, [
'script-src',
'script-src-attr',
'script-src-elem',
'style-src',
'style-src-attr',
'style-src-elem',
'frame-ancestors',
]) || isset($autoDirectives[$directiveName])) {
$form[$policyTypeKey]['directives'][$directiveName]['enable']['#default_value'] = TRUE;
}
}
$form[$policyTypeKey]['directives'][$directiveName]['options'] = [
'#type' => 'container',
'#states' => [
'visible' => [
':input[name="' . $policyTypeKey . '[directives][' . $directiveName . '][enable]"]' => [
'checked' => TRUE,
],
],
],
];
if (!in_array($directiveSchema, [
Csp::DIRECTIVE_SCHEMA_SOURCE_LIST,
Csp::DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST,
])) {
continue;
}
$sourceListBase = $config
->get($policyTypeKey . '.directives.' . $directiveName . '.base');
$form[$policyTypeKey]['directives'][$directiveName]['options']['base'] = [
'#type' => 'radios',
'#parents' => [
$policyTypeKey,
'directives',
$directiveName,
'base',
],
'#options' => [
'self' => "Self",
'none' => "None",
'any' => "Any",
'' => '<em>n/a</em>',
],
'#default_value' => $sourceListBase !== NULL ? $sourceListBase : 'self',
];
if (isset($autoDirectives[$directiveName])) {
unset($form[$policyTypeKey]['directives'][$directiveName]['options']['base']['#options']['none']);
}
if ($directiveSchema == Csp::DIRECTIVE_SCHEMA_SOURCE_LIST) {
$form[$policyTypeKey]['directives'][$directiveName]['options']['flags_wrapper'] = [
'#type' => 'container',
'#states' => [
'visible' => [
[
':input[name="' . $policyTypeKey . '[directives][' . $directiveName . '][base]"]' => [
'!value' => 'none',
],
],
],
],
];
$keywordOptions = self::getKeywordOptions($directiveName);
$keywordOptions = array_combine($keywordOptions, array_map(function ($keyword) {
return "<code>'" . $keyword . "'</code>";
}, $keywordOptions));
$form[$policyTypeKey]['directives'][$directiveName]['options']['flags_wrapper']['flags'] = [
'#type' => 'checkboxes',
'#parents' => [
$policyTypeKey,
'directives',
$directiveName,
'flags',
],
'#options' => $keywordOptions,
'#default_value' => $config
->get($policyTypeKey . '.directives.' . $directiveName . '.flags') ?: [],
];
}
if (!empty($autoDirectives[$directiveName])) {
$form[$policyTypeKey]['directives'][$directiveName]['options']['auto'] = [
'#type' => 'textarea',
'#parents' => [
$policyTypeKey,
'directives',
$directiveName,
'auto',
],
'#title' => 'Auto Sources',
'#value' => implode(' ', $autoDirectives[$directiveName]),
'#disabled' => TRUE,
];
}
$form[$policyTypeKey]['directives'][$directiveName]['options']['sources'] = [
'#type' => 'textarea',
'#parents' => [
$policyTypeKey,
'directives',
$directiveName,
'sources',
],
'#title' => $this
->t('Additional Sources'),
'#description' => $this
->t('Additional domains or protocols to allow for this directive, separated by a space.'),
'#default_value' => implode(' ', $config
->get($policyTypeKey . '.directives.' . $directiveName . '.sources') ?: []),
'#states' => [
'visible' => [
[
':input[name="' . $policyTypeKey . '[directives][' . $directiveName . '][base]"]' => [
'!value' => 'none',
],
],
],
],
];
}
$form[$policyTypeKey]['directives']['child-src']['options']['note'] = [
'#type' => 'markup',
'#markup' => '<em>' . $this
->t('Instead of child-src, nested browsing contexts and workers should use the frame-src and worker-src directives, respectively.') . '</em>',
'#weight' => -10,
];
if ($policyTypeKey === 'enforce') {
$form[$policyTypeKey]['directives']['block-all-mixed-content']['#states'] = [
'disabled' => [
[
':input[name="' . $policyTypeKey . '[directives][upgrade-insecure-requests][enable]"]' => [
'checked' => TRUE,
],
],
],
];
}
$form[$policyTypeKey]['directives']['plugin-types']['#states'] = [
'visible' => [
[
':input[name="' . $policyTypeKey . '[directives][object-src][base]"]' => [
'!value' => 'none',
],
':input[name="' . $policyTypeKey . '[directives][object-src][enable]"]' => [
'checked' => TRUE,
],
],
'or',
[
':input[name="' . $policyTypeKey . '[directives][object-src][enable]"]' => [
'checked' => FALSE,
],
],
],
];
$form[$policyTypeKey]['directives']['plugin-types']['options']['mime-types'] = [
'#type' => 'textfield',
'#parents' => [
$policyTypeKey,
'directives',
'plugin-types',
'mime-types',
],
'#title' => $this
->t('MIME Types'),
'#default_value' => implode(' ', $config
->get($policyTypeKey . '.directives.plugin-types') ?: []),
'#description' => $this
->t('The <code>plugin-types</code> directive has been deprecated. <code>object-src</code> should be used to restrict embedded objects.'),
];
$form[$policyTypeKey]['directives']['sandbox']['options']['keys'] = [
'#type' => 'checkboxes',
'#parents' => [
$policyTypeKey,
'directives',
'sandbox',
'keys',
],
'#options' => [
'allow-forms' => '<code>allow-forms</code>',
'allow-modals' => '<code>allow-modals</code>',
'allow-orientation-lock' => '<code>allow-orientation-lock</code>',
'allow-pointer-lock' => '<code>allow-pointer-lock</code>',
'allow-popups' => '<code>allow-popups</code>',
'allow-popups-to-escape-sandbox' => '<code>allow-popups-to-escape-sandbox</code>',
'allow-presentation' => '<code>allow-presentation</code>',
'allow-same-origin' => '<code>allow-same-origin</code>',
'allow-scripts' => '<code>allow-scripts</code>',
'allow-top-navigation' => '<code>allow-top-navigation</code>',
'allow-top-navigation-by-user-activation' => '<code>allow-top-navigation-by-user-activation</code>',
],
'#default_value' => $config
->get($policyTypeKey . '.directives.sandbox') ?: [],
];
$form[$policyTypeKey]['reporting'] = [
'#type' => 'fieldset',
'#title' => $this
->t('Reporting'),
'#tree' => TRUE,
];
$form[$policyTypeKey]['reporting']['handler'] = [
'#type' => 'radios',
'#title' => $this
->t('Handler'),
'#options' => [],
'#default_value' => $config
->get($policyTypeKey . '.reporting.plugin') ?: 'none',
];
foreach ($reportingHandlerPluginDefinitions as $reportingHandlerPluginDefinition) {
$reportingHandlerOptions = [
'type' => $policyTypeKey,
];
if ($config
->get($policyTypeKey . '.reporting.plugin') == $reportingHandlerPluginDefinition['id']) {
$reportingHandlerOptions += $config
->get($policyTypeKey . '.reporting.options') ?: [];
}
try {
$reportingHandlerPlugin = $this->reportingHandlerPluginManager
->createInstance($reportingHandlerPluginDefinition['id'], $reportingHandlerOptions);
} catch (PluginException $e) {
watchdog_exception('csp', $e);
continue;
}
$form[$policyTypeKey]['reporting']['handler']['#options'][$reportingHandlerPluginDefinition['id']] = $reportingHandlerPluginDefinition['label'];
$form[$policyTypeKey]['reporting'][$reportingHandlerPluginDefinition['id']] = $reportingHandlerPlugin
->getForm([
'#type' => 'item',
'#description' => $reportingHandlerPluginDefinition['description'],
'#states' => [
'visible' => [
':input[name="' . $policyTypeKey . '[reporting][handler]"]' => [
'value' => $reportingHandlerPluginDefinition['id'],
],
],
],
'#CspReportingHandlerPlugin' => $reportingHandlerPlugin,
]);
}
$form[$policyTypeKey]['clear'] = [
'#type' => 'submit',
'#value' => $this
->t('Reset @policyType policy to default values', [
'@policyType' => $policyTypeName,
]),
'#cspPolicyType' => $policyTypeKey,
'#button_type' => 'danger',
'#submit' => [
'::submitClearPolicy',
],
];
}
if (empty($form_state
->getUserInput())) {
$enabledPolicies = array_filter(array_keys($policyTypes), function ($policyTypeKey) use ($config) {
return $config
->get($policyTypeKey . '.enable');
});
if (empty($enabledPolicies)) {
$this
->messenger()
->addWarning($this
->t('No policies are currently enabled.'));
}
foreach ($policyTypes as $policyTypeKey => $policyTypeName) {
if (!$config
->get($policyTypeKey . '.enable')) {
continue;
}
foreach ($directiveNames as $directive) {
if ($directiveSources = $config
->get($policyTypeKey . '.directives.' . $directive . '.sources')) {
$hashAlgoMatch = '(' . implode('|', Csp::HASH_ALGORITHMS) . ')-[\\w+/_-]+=*';
$hasHashSource = array_reduce($directiveSources, function ($return, $value) use ($hashAlgoMatch) {
return $return || preg_match("<^'" . $hashAlgoMatch . "'\$>", $value);
}, FALSE);
if ($hasHashSource) {
$this
->messenger()
->addWarning($this
->t('%policy %directive has a hash source configured, which may block functionality that relies on inline code.', [
'%policy' => $policyTypeName,
'%directive' => $directive,
]));
}
}
}
if (!is_null($config
->get($policyTypeKey . '.directives.plugin-types')) && !$config
->get($policyTypeKey . '.directives.object-src')) {
$this
->messenger()
->addWarning($this
->t('The <code>plugin-types</code> directive has been deprecated. <code>object-src</code> should be used to restrict embedded objects.'));
}
foreach ([
'script-src',
'style-src',
] as $directive) {
foreach ([
'-attr',
'-elem',
] as $subdirective) {
if ($config
->get($policyTypeKey . '.directives.' . $directive . $subdirective)) {
foreach (Csp::getDirectiveFallbackList($directive . $subdirective) as $fallbackDirective) {
if ($config
->get($policyTypeKey . '.directives.' . $fallbackDirective)) {
continue 2;
}
}
$this
->messenger()
->addWarning($this
->t('%policy %directive is enabled without a fallback directive for non-supporting browsers.', [
'%policy' => $policyTypeName,
'%directive' => $directive . $subdirective,
]));
}
}
}
}
}
return parent::buildForm($form, $form_state);
}
public function validateForm(array &$form, FormStateInterface $form_state) {
foreach ([
'report-only',
'enforce',
] as $policyTypeKey) {
$directiveNames = $this
->getConfigurableDirectives();
foreach ($directiveNames as $directiveName) {
if ($directiveSources = $form_state
->getValue([
$policyTypeKey,
'directives',
$directiveName,
'sources',
])) {
$sourcesArray = preg_split('/,?\\s+/', $directiveSources);
$hasNonceSource = array_reduce($sourcesArray, function ($return, $value) {
return $return || preg_match("<^'nonce->", $value);
}, FALSE);
if ($hasNonceSource) {
$form_state
->setError($form[$policyTypeKey]['directives'][$directiveName]['options']['sources'], $this
->t('<a href=":docUrl">Nonces must be a unique value for each request</a>, so cannot be set in configuration.', [
':docUrl' => 'https://www.w3.org/TR/CSP3/#security-considerations',
]));
}
$hashAlgoMatch = '(' . implode('|', Csp::HASH_ALGORITHMS) . ')-[\\w+/_-]+=*';
$hasInvalidSource = array_reduce($sourcesArray, function ($return, $value) use ($hashAlgoMatch) {
return $return || !(preg_match('<^([a-z]+:)?$>', $value) || static::isValidHost($value) || preg_match("<^'(" . $hashAlgoMatch . ")'\$>", $value));
}, FALSE);
if ($hasInvalidSource) {
$form_state
->setError($form[$policyTypeKey]['directives'][$directiveName]['options']['sources'], $this
->t('Invalid domain or protocol provided.'));
}
}
}
if ($form_state
->getValue([
$policyTypeKey,
'directives',
'plugin-types',
'enable',
])) {
$invalidTypes = array_reduce(preg_split('/,?\\s+/', $form_state
->getValue([
$policyTypeKey,
'directives',
'plugin-types',
'mime-types',
], '')), function ($return, $value) {
return $return || !preg_match('<^([\\w-]+/[\\w-]+)?$>', $value);
}, FALSE);
if ($invalidTypes) {
$form_state
->setError($form[$policyTypeKey]['directives']['plugin-types']['options']['mime-types'], $this
->t('Invalid MIME-Type provided.'));
}
}
if ($reportingHandlerPluginId = $form_state
->getValue([
$policyTypeKey,
'reporting',
'handler',
])) {
$form[$policyTypeKey]['reporting'][$reportingHandlerPluginId]['#CspReportingHandlerPlugin']
->validateForm($form[$policyTypeKey]['reporting'][$reportingHandlerPluginId], $form_state);
}
else {
$form_state
->setError($form[$policyTypeKey]['reporting']['handler'], $this
->t('Reporting Handler is required for enabled policies.'));
}
}
parent::validateForm($form, $form_state);
}
protected static function isValidHost($url) {
return (bool) preg_match("\n /^ # Start at the beginning of the text\n (?:[a-z][a-z0-9\\-.+]+:\\/\\/)? # Scheme (optional)\n (?:\n (?: # A domain name or a IPv4 address\n (?:\\*\\.)? # Wildcard prefix (optional)\n (?:(?:[a-z0-9\\-\\.]|%[0-9a-f]{2})+\\.)+\n (?:[a-z0-9\\-\\.]|%[0-9a-f]{2})+\n )\n |(?:\\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\\]) # or a well formed IPv6 address\n |localhost\n )\n (?::(?:[0-9]+|\\*))? # Server port number or wildcard (optional)\n (?:[\\/|\\?]\n (?:[\\w#!:\\.\\+=&@\$'~*,;\\/\\(\\)\\[\\]\\-]|%[0-9a-f]{2}) # The path (optional)\n *)?\n \$/xi", $url);
}
public function submitClearPolicy(array &$form, FormStateInterface $form_state) {
$submitElement = $form_state
->getTriggeringElement();
$this
->config('csp.settings')
->clear($submitElement['#cspPolicyType'])
->save();
}
public function submitForm(array &$form, FormStateInterface $form_state) {
$config = $this
->config('csp.settings');
$directiveNames = $this
->getConfigurableDirectives();
foreach ([
'report-only',
'enforce',
] as $policyTypeKey) {
$config
->clear($policyTypeKey);
$policyFormData = $form_state
->getValue($policyTypeKey);
$config
->set($policyTypeKey . '.enable', !empty($policyFormData['enable']));
foreach ($directiveNames as $directiveName) {
if (empty($policyFormData['directives'][$directiveName])) {
continue;
}
$directiveFormData = $policyFormData['directives'][$directiveName];
$directiveOptions = [];
if (empty($directiveFormData['enable'])) {
continue;
}
$directiveSchema = Csp::getDirectiveSchema($directiveName);
if ($directiveSchema === Csp::DIRECTIVE_SCHEMA_BOOLEAN) {
$directiveOptions = TRUE;
}
elseif ($directiveSchema === Csp::DIRECTIVE_SCHEMA_MEDIA_TYPE_LIST) {
if ($directiveName == 'plugin-types' && $policyFormData['directives']['object-src']['enable'] && $policyFormData['directives']['object-src']['base'] == 'none') {
continue;
}
$directiveOptions = array_filter(preg_split('/,?\\s+/', $directiveFormData['mime-types']));
}
elseif (in_array($directiveSchema, [
Csp::DIRECTIVE_SCHEMA_TOKEN_LIST,
Csp::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST,
])) {
$directiveOptions = array_keys(array_filter($directiveFormData['keys']));
}
elseif (in_array($directiveSchema, [
Csp::DIRECTIVE_SCHEMA_SOURCE_LIST,
Csp::DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST,
])) {
if ($directiveFormData['base'] !== 'none') {
if (!empty($directiveFormData['sources'])) {
$directiveOptions['sources'] = array_filter(preg_split('/,?\\s+/', $directiveFormData['sources']));
}
if ($directiveSchema == Csp::DIRECTIVE_SCHEMA_SOURCE_LIST) {
$directiveFormData['flags'] = array_filter($directiveFormData['flags']);
if (!empty($directiveFormData['flags'])) {
$directiveOptions['flags'] = array_keys($directiveFormData['flags']);
}
}
}
$directiveOptions['base'] = $directiveFormData['base'];
}
if (!empty($directiveOptions) || in_array($directiveSchema, [
Csp::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST,
Csp::DIRECTIVE_SCHEMA_MEDIA_TYPE_LIST,
])) {
$config
->set($policyTypeKey . '.directives.' . $directiveName, $directiveOptions);
}
}
$reportHandlerPluginId = $form_state
->getValue([
$policyTypeKey,
'reporting',
'handler',
]);
$config
->set($policyTypeKey . '.reporting', [
'plugin' => $reportHandlerPluginId,
]);
$reportHandlerOptions = $form_state
->getValue([
$policyTypeKey,
'reporting',
$reportHandlerPluginId,
]);
if ($reportHandlerOptions) {
$config
->set($policyTypeKey . '.reporting.options', $reportHandlerOptions);
}
}
$config
->save();
parent::submitForm($form, $form_state);
}
}