diff --git a/config/schema/csp.schema.yml b/config/schema/csp.schema.yml index 543c37d11d2e165803724c68879da62bab9b8c94..4de66eb983fb8f12048e59230ac42a3e737ff085 100644 --- a/config/schema/csp.schema.yml +++ b/config/schema/csp.schema.yml @@ -200,7 +200,9 @@ csp_directive.trusted-types: sequence: type: string constraints: - Regex: <^[a-z0-9#=_/@.%-]+$>i + Regex: + pattern: <^[a-z0-9#=_/@.%-]+$>i + message: '%value is not a valid trusted types policy name' csp_directive.require-trusted-types-for: type: sequence label: require-trusted-types-for @@ -224,9 +226,16 @@ csp_reporting_handler.report-uri-com: subdomain: type: string label: 'Subdomain' + constraints: + NotBlank: [] + Regex: + # Custom domains must be 4-30 characters, but generated domains are 32. + pattern: <^([a-z\d]{4,30}|[a-z\d]{32})$>i + message: Subdomain must be 4-30 alphanumeric characters. wizard: type: boolean label: 'Wizard' + requiredKey: false csp_reporting_handler.uri: type: mapping label: 'URI' diff --git a/src/Form/CspSettingsForm.php b/src/Form/CspSettingsForm.php index cc0ae460227b0abfec49d460623a91df450db8d4..94385dc25b4937eb0f2ebad821f4dd6010ba741c 100644 --- a/src/Form/CspSettingsForm.php +++ b/src/Form/CspSettingsForm.php @@ -6,12 +6,13 @@ use Drupal\Component\Plugin\Exception\PluginException; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\TypedConfigManagerInterface; use Drupal\Core\Form\ConfigFormBase; +use Drupal\Core\Form\ConfigTarget; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\ToConfig; use Drupal\Core\Messenger\MessengerInterface; -use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\csp\Csp; use Drupal\csp\LibraryPolicyBuilder; -use Drupal\csp\Plugin\Validation\Constraint\SourceConstraint; use Drupal\csp\ReportingHandlerPluginManager; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\Validator\ConstraintViolationInterface; @@ -154,7 +155,7 @@ class CspSettingsForm extends ConfigFormBase { * @return string[] * An array of keywords. */ - private function getKeywordOptions($directive): array { + private function getKeywordOptions(string $directive): array { return array_keys(array_filter( self::$keywordDirectiveMap, function ($directives) use ($directive) { @@ -202,7 +203,10 @@ class CspSettingsForm extends ConfigFormBase { $form[$policyTypeKey]['enable'] = [ '#type' => 'checkbox', '#title' => $this->t("Enable '@type'", ['@type' => $policyTypeName]), - '#default_value' => $config->get($policyTypeKey . '.enable') ?: FALSE, + '#config_target' => new ConfigTarget( + 'csp.settings', + $policyTypeKey . '.enable', + ), ]; $form[$policyTypeKey]['directives'] = [ @@ -218,6 +222,21 @@ class CspSettingsForm extends ConfigFormBase { $form[$policyTypeKey]['directives'][$directiveName] = [ '#type' => 'container', '#access' => $policyTypeKey == 'enforce' || !in_array($directiveName, $enforceOnlyDirectives), + '#config_target' => new ConfigTarget( + 'csp.settings', + $policyTypeKey . '.directives.' . $directiveName, + toConfig: match($directiveSchema) { + Csp::DIRECTIVE_SCHEMA_BOOLEAN => self::booleanToConfig(...), + Csp::DIRECTIVE_SCHEMA_TOKEN_LIST, + Csp::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST => self::tokenListToConfig(...), + Csp::DIRECTIVE_SCHEMA_ALLOW_BLOCK => self::allowBlockToConfig(...), + Csp::DIRECTIVE_SCHEMA_SOURCE_LIST, + Csp::DIRECTIVE_SCHEMA_ANCESTOR_SOURCE_LIST => self::sourceListToConfig(...), + Csp::DIRECTIVE_SCHEMA_TRUSTED_TYPES => self::trustedTypesToConfig(...), + Csp::DIRECTIVE_SCHEMA_TRUSTED_TYPES_SINK_GROUPS => self::sinkGroupsToConfig(...), + default => self::directiveToConfig(...), + }, + ), ]; $form[$policyTypeKey]['directives'][$directiveName]['enable'] = [ @@ -322,6 +341,11 @@ class CspSettingsForm extends ConfigFormBase { '#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') ?: []), + '#config_target' => new ConfigTarget( + 'csp.settings', + $policyTypeKey . '.directives.' . $directiveName . '.sources', + toConfig: fn() => ToConfig::NoOp, + ), '#states' => [ 'visible' => [ [':input[name="' . $policyTypeKey . '[directives][' . $directiveName . '][base]"]' => ['!value' => 'none']], @@ -409,6 +433,11 @@ class CspSettingsForm extends ConfigFormBase { '#parents' => [$policyTypeKey, 'directives', 'trusted-types', 'policy-names'], '#title' => $this->t('Policy Names'), '#default_value' => implode(' ', $config->get($policyTypeKey . '.directives.trusted-types.policy-names') ?? []), + '#config_target' => new ConfigTarget( + 'csp.settings', + $policyTypeKey . '.directives.trusted-types.policy-names', + toConfig: fn() => ToConfig::NoOp, + ), '#states' => [ '!visible' => [ [ @@ -439,6 +468,11 @@ class CspSettingsForm extends ConfigFormBase { '#type' => 'fieldset', '#title' => $this->t('Reporting'), '#tree' => TRUE, + '#config_target' => new ConfigTarget( + 'csp.settings', + $policyTypeKey . '.reporting', + toConfig: self::reportingToConfig(...), + ), ]; $form[$policyTypeKey]['reporting']['handler'] = [ '#type' => 'radios', @@ -518,6 +552,12 @@ class CspSettingsForm extends ConfigFormBase { } foreach ($directiveNames as $directive) { + if ( + !str_starts_with($directive, 'script-src') + && !str_starts_with($directive, 'style-src') + ) { + continue; + } if (($directiveSources = $config->get($policyTypeKey . '.directives.' . $directive . '.sources'))) { // '{hashAlgorithm}-{base64-value}' @@ -569,60 +609,7 @@ class CspSettingsForm extends ConfigFormBase { * {@inheritdoc} */ public function validateForm(array &$form, FormStateInterface $form_state): void { - - /** @var \Symfony\Component\Validator\Validator\RecursiveValidator $sourceValidator */ - $sourceValidator = \Drupal::service('validation.basic_recursive_validator_factory')->createValidator(); - foreach (['report-only', 'enforce'] as $policyTypeKey) { - $directiveNames = $this->getConfigurableDirectives(); - foreach ($directiveNames as $directiveName) { - if (($directiveSources = $form_state->getValue([$policyTypeKey, 'directives', $directiveName, 'sources']))) { - $validationContext = $sourceValidator->startContext(); - $sourcesArray = preg_split('/,?\s+/', $directiveSources); - foreach ($sourcesArray as $source) { - $validationContext->validate($source, new SourceConstraint()); - } - if ($validationContext->getViolations()->count()) { - $form_state->setError( - $form[$policyTypeKey]['directives'][$directiveName]['options']['sources'], - new PluralTranslatableMarkup( - $validationContext->getViolations()->count(), - 'Invalid source value for %directive: %value', - 'Invalid source values for %directive: %value', - [ - '%directive' => $directiveName, - '%value' => implode(', ', array_map( - function (ConstraintViolationInterface $violation): string { - return $violation->getInvalidValue(); - }, - iterator_to_array($validationContext->getViolations()) - )), - ] - ) - ); - } - } - } - - // Check that trusted types policy names are valid. - // See https://w3c.github.io/trusted-types/dist/spec/#trusted-types-csp-directive. - if ( - $form_state->getValue([$policyTypeKey, 'directives', 'trusted-types', 'enable']) - && $form_state->getValue([$policyTypeKey, 'directives', 'trusted-types', 'base']) === '' - && ($trusted_type_policy_names = trim($form_state->getValue([$policyTypeKey, 'directives', 'trusted-types', 'policy-names']))) - ) { - $trusted_type_policy_names_array = preg_split('/,?\s+/', $trusted_type_policy_names); - $invalid_policy_names = array_filter($trusted_type_policy_names_array, function ($expression) { - return !preg_match('<^[a-z0-9#=_/@.%-]+$>i', $expression); - }); - if (!empty($invalid_policy_names)) { - $form_state->setError( - $form[$policyTypeKey]['directives']['trusted-types']['options']['policy-names'], - $this->t('Invalid Trusted Types policy names: "%types".', ['%types' => implode(' ', $invalid_policy_names)]) - ); - } - } - if (($reportingHandlerPluginId = $form_state->getValue([$policyTypeKey, 'reporting', 'handler']))) { if ($this->reportingHandlerPluginManager->hasDefinition($reportingHandlerPluginId)) { $this->reportingHandlerPluginManager->createInstance($reportingHandlerPluginId, ['type' => $policyTypeKey]) @@ -641,6 +628,62 @@ class CspSettingsForm extends ConfigFormBase { parent::validateForm($form, $form_state); } + /** + * {@inheritdoc} + * + * @param string $form_element_name + * The form element for which to format multiple violation messages. + * @param array<ConstraintViolationInterface> $violations + * The list of constraint violations that apply to this form element. + */ + protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup { + /** @var array{("report-only"|"enforce"), string, string, string} $elementKeys */ + $elementKeys = explode('][', $form_element_name); + + $messageArgs = fn () => [ + '%policy' => match($elementKeys[0]) { + 'report-only' => $this->t('Report Only'), + 'enforce' => $this->t('Enforced'), + }, + '%directive' => $elementKeys[2], + '%value' => implode(', ', array_map( + function (ConstraintViolationInterface $violation): string { + return $violation->getInvalidValue(); + }, + $violations + )), + ]; + + if (str_ends_with($form_element_name, 'sources')) { + return $this->formatPlural( + count($violations), + 'Invalid %policy %directive source: %value', + 'Invalid %policy %directive sources: %value', + $messageArgs() + ); + } + elseif (str_ends_with($form_element_name, 'trusted-types][policy-names')) { + return $this->formatPlural( + count($violations), + 'Invalid %policy Trusted Types Policy Name: %value', + 'Invalid %policy Trusted Types Policy Names: %value', + $messageArgs() + ); + } + + // Drupal ^11.0.6 returns MarkupInterface|\Stringable, which need conversion + // on prior versions to respect TranslatableMarkup type hint. + $parent = parent::formatMultipleViolationsMessage($form_element_name, $violations); + // @phpstan-ignore-next-line + if ($parent instanceof TranslatableMarkup) { + return $parent; + } + else { + // phpcs:ignore Drupal.Semantics.FunctionT.NotLiteralString + return $this->t((string) $parent); + } + } + /** * Submit handler for clear policy buttons. * @@ -658,97 +701,151 @@ class CspSettingsForm extends ConfigFormBase { } /** - * {@inheritdoc} + * Convert form state to config array for directive options. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string, mixed>|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. */ - public function submitForm(array &$form, FormStateInterface $form_state): void { - $config = $this->config('csp.settings'); + private static function directiveToConfig(array $value): array|ToConfig { + if (!$value['enable']) { + return ToConfig::DeleteKey; + } + unset($value['enable']); - $directiveNames = $this->getConfigurableDirectives(); - foreach (['report-only', 'enforce'] as $policyTypeKey) { - $config->clear($policyTypeKey); + return $value; + } - $policyFormData = $form_state->getValue($policyTypeKey); + /** + * Convert form state to config value for a boolean directive. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return bool|\Drupal\Core\Form\ToConfig + * Directive config value, or a ToConfig enum value. + */ + private static function booleanToConfig(array $value): bool|ToConfig { + return !empty($value['enable']) ? TRUE : ToConfig::DeleteKey; + } - $config->set($policyTypeKey . '.enable', !empty($policyFormData['enable'])); + /** + * Convert form state to config value for a token list directive. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string>|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. + */ + private static function tokenListToConfig(array $value): array|ToConfig { + if (!$value['enable']) { + return ToConfig::DeleteKey; + } + unset($value['enable']); - foreach ($directiveNames as $directiveName) { - if (empty($policyFormData['directives'][$directiveName])) { - continue; - } + return array_keys(array_filter($value['keys'])); + } - $directiveFormData = $policyFormData['directives'][$directiveName]; - $directiveOptions = []; + /** + * Convert form state to config value for an allow/block directive. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return string|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. + */ + private static function allowBlockToConfig(array $value): string|ToConfig { + return !empty($value['enable']) ? $value['value'] : ToConfig::DeleteKey; + } - if (empty($directiveFormData['enable'])) { - continue; - } + /** + * Convert form state to config array for source list directive. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string, mixed>|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. + */ + private static function sourceListToConfig(array $value): array|ToConfig { + if (!$value['enable']) { + return ToConfig::DeleteKey; + } + unset($value['enable']); - $directiveSchema = Csp::getDirectiveSchema($directiveName); + if ($value['base'] === 'none') { + return ['base' => 'none']; + } - if ($directiveSchema === Csp::DIRECTIVE_SCHEMA_BOOLEAN) { - $directiveOptions = TRUE; - } - elseif (in_array($directiveSchema, [ - Csp::DIRECTIVE_SCHEMA_TOKEN_LIST, - Csp::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST, - ])) { - $directiveOptions = array_keys(array_filter($directiveFormData['keys'])); - } - elseif ($directiveSchema === Csp::DIRECTIVE_SCHEMA_ALLOW_BLOCK) { - $directiveOptions = $directiveFormData['value']; - } - 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']); - } - } - } + $value['sources'] = array_filter(preg_split('/,?\s+/', $value['sources'])); - $directiveOptions['base'] = $directiveFormData['base']; - } - elseif ($directiveSchema == Csp::DIRECTIVE_SCHEMA_TRUSTED_TYPES) { - $directiveOptions['base'] = $directiveFormData['base']; - $directiveOptions['allow-duplicates'] = $directiveFormData['base'] !== 'none' && $directiveFormData['allow-duplicates']; - $directiveOptions['policy-names'] = $directiveOptions['base'] === '' ? - array_filter(preg_split('/,?\s+/', $directiveFormData['policy-names'])) : - []; - } - elseif ($directiveSchema == Csp::DIRECTIVE_SCHEMA_TRUSTED_TYPES_SINK_GROUPS) { - // Script is currently the only valid value, so set it when directive - // is enabled. - // See https://w3c.github.io/trusted-types/dist/spec/#require-trusted-types-for-csp-directive. - $directiveOptions = ['script']; - } + if (array_key_exists('flags', $value)) { + $value['flags'] = array_keys(array_filter($value['flags'])); + } - if ( - !empty($directiveOptions) - || - $directiveSchema === Csp::DIRECTIVE_SCHEMA_OPTIONAL_TOKEN_LIST - ) { - $config->set($policyTypeKey . '.directives.' . $directiveName, $directiveOptions); - } - } + return array_filter($value); + } - $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); - } + /** + * Convert form state to config array for trusted types directive. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string, mixed>|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. + */ + private static function trustedTypesToConfig(array $value): array|ToConfig { + if (!$value['enable']) { + return ToConfig::DeleteKey; } + unset($value['enable']); + + $value['allow-duplicates'] = $value['base'] !== 'none' && $value['allow-duplicates']; + $value['policy-names'] = $value['base'] === '' ? + array_filter(preg_split('/,?\s+/', $value['policy-names'])) : + []; + + return $value; + } + + /** + * Convert form state to config array for trusted types sink groups. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string>|\Drupal\Core\Form\ToConfig + * Directive config array, or a ToConfig enum value. + */ + private static function sinkGroupsToConfig(array $value): array|ToConfig { + // Script is currently the only valid value, so set it when enabled. + // See https://w3c.github.io/trusted-types/dist/spec/#require-trusted-types-for-csp-directive. + return !empty($value['enable']) ? ['script'] : ToConfig::DeleteKey; + } - $config->save(); + /** + * Convert form state to config array for reporting options. + * + * @param array<string, mixed> $value + * The submitted form values. + * + * @return array<string, mixed> + * Reporting config array. + */ + private static function reportingToConfig(array $value): array { + $return = [ + 'plugin' => $value['handler'], + ]; + if (!empty($value[$value['handler']])) { + $return['options'] = $value[$value['handler']]; + } - parent::submitForm($form, $form_state); + return $return; } } diff --git a/src/Plugin/CspReportingHandler/None.php b/src/Plugin/CspReportingHandler/None.php index c1f4c5404793e2e5cd68baa71691fbdcd7e0fe37..910f7e56f58d51b4c3d167d111a4e08cbde77b0a 100644 --- a/src/Plugin/CspReportingHandler/None.php +++ b/src/Plugin/CspReportingHandler/None.php @@ -2,8 +2,6 @@ namespace Drupal\csp\Plugin\CspReportingHandler; -use Drupal\Core\Form\FormStateInterface; -use Drupal\csp\Csp; use Drupal\csp\Plugin\ReportingHandlerBase; /** @@ -17,25 +15,4 @@ use Drupal\csp\Plugin\ReportingHandlerBase; */ class None extends ReportingHandlerBase { - /** - * {@inheritdoc} - */ - public function getForm(array $form): array { - return $form; - } - - /** - * {@inheritdoc} - */ - public function validateForm(array &$form, FormStateInterface $form_state): void { - - } - - /** - * {@inheritdoc} - */ - public function alterPolicy(Csp $policy): void { - - } - } diff --git a/src/Plugin/CspReportingHandler/ReportUri.php b/src/Plugin/CspReportingHandler/ReportUri.php index 78f1db467bf8726731bc20ff153e45ac37d8c59a..b84c5edb6ce357a00b00de7f51fd991516a2e644 100644 --- a/src/Plugin/CspReportingHandler/ReportUri.php +++ b/src/Plugin/CspReportingHandler/ReportUri.php @@ -2,7 +2,8 @@ namespace Drupal\csp\Plugin\CspReportingHandler; -use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Form\ConfigTarget; +use Drupal\Core\Form\ToConfig; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\csp\Csp; use Drupal\csp\Plugin\ReportingHandlerBase; @@ -34,6 +35,11 @@ class ReportUri extends ReportingHandlerBase { ':url' => 'https://report-uri.com/account/setup/', ]), '#default_value' => $this->configuration['subdomain'] ?? '', + '#config_target' => new ConfigTarget( + 'csp.settings', + $this->configuration['type'] . '.reporting.options.subdomain', + toConfig: fn() => ToConfig::NoOp, + ), '#states' => [ 'required' => [ ':input[name="' . $this->configuration['type'] . '[enable]"]' => ['checked' => TRUE], @@ -58,17 +64,6 @@ class ReportUri extends ReportingHandlerBase { return $form; } - /** - * {@inheritdoc} - */ - public function validateForm(array &$form, FormStateInterface $form_state): void { - $subdomain = $form_state->getValue($form['subdomain']['#parents']); - // Custom domains must be 4-30 characters, but generated domains are 32. - if (!preg_match('/^[a-z\d]{4,32}$/i', $subdomain)) { - $form_state->setError($form['subdomain'], 'Must be 4-30 alphanumeric characters.'); - } - } - /** * {@inheritdoc} */ diff --git a/src/Plugin/Validation/Constraint/SourceConstraintValidator.php b/src/Plugin/Validation/Constraint/SourceConstraintValidator.php index 0fc1e1a856ece0755d9375783e5cbac0090c730a..832ad0fc2cc9eb3aec685d29c7dcfe37a2cb9d8f 100644 --- a/src/Plugin/Validation/Constraint/SourceConstraintValidator.php +++ b/src/Plugin/Validation/Constraint/SourceConstraintValidator.php @@ -23,11 +23,13 @@ class SourceConstraintValidator extends ConstraintValidator { if (!$constraint->allowNonce && self::isValidNonce($value)) { $this->context->buildViolation("Nonce sources are not valid.") ->setInvalidValue($value) + ->setCause('nonce') ->addViolation(); } elseif (!$constraint->allowHash && self::isValidHash($value)) { $this->context->buildViolation("Hash sources are not valid.") ->setInvalidValue($value) + ->setCause('hash') ->addViolation(); } elseif ( @@ -36,8 +38,8 @@ class SourceConstraintValidator extends ConstraintValidator { && !($constraint->allowHash && self::isValidHash($value)) && !($constraint->allowNonce && self::isValidNonce($value)) ) { - $this->context->buildViolation('"@value" is not a valid source') - ->setParameter('@value', $value) + $this->context->buildViolation('"%value" is not a valid source') + ->setParameter('%value', $value) ->setInvalidValue($value) ->addViolation(); }