diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..481f1c8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI + +on: + pull_request: null + +jobs: + Silverstripe: + name: 'Silverstripe (bundle)' + uses: nswdpc/ci-files/.github/workflows/silverstripe_53_83.yml@v-4 + PHPStan: + name: 'PHPStan (analyse)' + uses: nswdpc/ci-files/.github/workflows/phpstan.silverstripe_83.yml@v-4 + needs: Silverstripe diff --git a/.gitignore b/.gitignore index afc1cdc..8aeb513 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /vendor/ .DS_Store /.php-cs-fixer.cache +/public/ +/composer.lock diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php deleted file mode 100644 index f9b7107..0000000 --- a/.php-cs-fixer.dist.php +++ /dev/null @@ -1,21 +0,0 @@ -in(__DIR__); - -$config = new PhpCsFixer\Config(); -return $config->setRules([ - '@PSR2' => true, - 'array_indentation' => true, - 'array_syntax' => ['syntax' => 'short'], - 'blank_line_after_namespace' => true, - 'blank_line_after_opening_tag' => true, - 'full_opening_tag' => true, - 'no_closing_tag' => true, - ]) - ->setIndent(" ") - ->setFinder($finder); diff --git a/composer.json b/composer.json index 11471db..86e8dab 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,6 @@ "authors": [ { "name": "James Ellis", - "homepage": "https://dpc.nsw.gov.au", "role": "Developer" } ], @@ -40,7 +39,21 @@ "symbiote/silverstripe-multivaluefield": "^6" }, "require-dev": { + "cambis/silverstripe-rector": "^2", "phpunit/phpunit": "^9.5", + "cambis/silverstan": "^2", + "nswdpc/ci-files": "dev-v-4", + "phpstan/phpstan": "^2", + "rector/rector": "^2", + "phpstan/phpstan-phpunit": "^2", "friendsofphp/php-cs-fixer": "^3" + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "silverstripe/vendor-plugin": true, + "silverstripe/recipe-plugin": true, + "phpstan/extension-installer": true + } } } diff --git a/lang/en.yml b/lang/en.yml index 01bd2be..0602851 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -1,21 +1,21 @@ en: ContentSecurityPolicy: - ALTERNATE_REPORT_URI_DESCRIPTION: 'If not set and the sending of violation reports is enabled, reports will be directed to {internal_reporting_url} and will appear in the CSP/Reports screen.Sending reports back to your own website may cause performance degradation.' + ALTERNATE_REPORT_URI_DESCRIPTION: 'If not set and the sending of violation reports is enabled, reports will be directed to {internal_reporting_url} and will appear in the CSP/Reports screen. Sending reports back to your own website may cause performance degradation.' ALTERNATE_REPORT_URI_TITLE: 'Endpoint for report-uri violation reports' ALTERNATE_REPORT_TO_TITLE: 'Endpoint for Reporting API (report-to) violation reports' - ALTERNATE_REPORT_TO_URI_DESCRIPTION: 'For services that have a separate Reporting API endpoint.If not set and the sending of violation reports is enabled, reports will be directed to {internal_reporting_url} and will appear in the CSP/Reports screen.Sending reports back to your own website may cause performance degradation.' + ALTERNATE_REPORT_TO_URI_DESCRIPTION: 'For services that have a separate Reporting API endpoint. If not set and the sending of violation reports is enabled, reports will be directed to {internal_reporting_url} and will appear in the CSP/Reports screen. Sending reports back to your own website may cause performance degradation.' ALTERNATE_NEL_REPORT_URI_TITLE: 'NEL/Reporting API reporting URL that will accept Network Error Logging reports' ALTERNATE_NEL_REPORT_URI_EXTERNAL: 'You must use an external reporting service.' REPORT_VIA_META_TAG: 'Reporting violations is not supported when using the meta tag delivery method' SEND_VIOLATION_REPORTS: "Send violation reports to a reporting system" SEND_VIOLATION_REPORTS_REPORT_ONLY: "'Report Only' is on - it is wise to turn on sending violation reports" - USE_ON_PUBLISHED_SITE: 'When unchecked, this policy will be used on the draft site only' - USED_IN_MULTIPLE_POLICIES: 'This record is used in %d policies. Updating it will modify all linked policies' + USE_ON_PUBLISHED_SITE_DESCRIPTION: 'When unchecked, this policy will be used on the draft site only' + USED_IN_MULTIPLE_POLICIES: 'This record is used in {count} policies. Updating it will modify all linked policies' ADD_SELF_VALUE: "Adds the 'self' value to this directive" ADD_DATA_VALUE: "Adds the 'data:' value to this directive" ADD_UNSAFE_INLINE_VALUE: "Adds the 'unsafe-inline' value to this directive" SELECT_PREDEFINED_DIRECTIVE: '...or select a pre-defined directive' - PRUNE_REPORTS_JOBTITLE: 'Remove CSP violation reports older than %d hour' - REMOVED_COUNT_REPORTS: 'Removed %s reports(s)' + PRUNE_REPORTS_JOBTITLE: 'Remove CSP violation reports older than {count} hour(s)' + REMOVED_COUNT_REPORTS: 'Removed {count} reports(s)' MINIMUM_CSP_LEVEL: "Minimum CSP Level" MINIMUM_CSP_LEVEL_DESCRIPTION: "Setting a higher level will remove from the policy features deprecated in previous versions, such as the 'report-uri' directive. 2 is a good setting." diff --git a/src/Controllers/CspModelAdmin.php b/src/Controllers/CspModelAdmin.php index a8ca0c4..d29ecb0 100644 --- a/src/Controllers/CspModelAdmin.php +++ b/src/Controllers/CspModelAdmin.php @@ -10,28 +10,24 @@ class CspModelAdmin extends ModelAdmin { /** - * @var string * @config */ - private static $url_segment = 'content-security-policy'; + private static string $url_segment = 'content-security-policy'; /** - * @var string * @config */ - private static $menu_title = 'CSP'; + private static string $menu_title = 'CSP'; /** - * @var string * @config */ - private static $menu_icon_class = 'font-icon-block'; + private static string $menu_icon_class = 'font-icon-block'; /** - * @var array * @config */ - private static $managed_models = [ + private static array $managed_models = [ Policy::class, Directive::class, ViolationReport::class diff --git a/src/Controllers/ReportingEndpoint.php b/src/Controllers/ReportingEndpoint.php index 1f31f61..d07ca76 100644 --- a/src/Controllers/ReportingEndpoint.php +++ b/src/Controllers/ReportingEndpoint.php @@ -16,31 +16,27 @@ */ class ReportingEndpoint extends Controller { - /** * Whether reports are accepted by this endpoint - * @var bool * @config */ - private static $accept_reports = false; + private static bool $accept_reports = false; /** - * @var array * @config */ - private static $allowed_actions = [ + private static array $allowed_actions = [ 'report' ]; /** - * @var array * @config */ - private static $url_handlers = [ + private static array $url_handlers = [ 'v1/report' => 'report' ]; - public function index(HTTPRequest $request) + public function index(HTTPRequest $request): never { $this->returnHeader(); } @@ -48,15 +44,15 @@ public function index(HTTPRequest $request) /** * Return appropriate response header, only */ - private function returnHeader() + private function returnHeader(): never { header("HTTP/1.1 204 No Content"); exit; } - public static function getCurrentReportingUrl($include_host = true) : string + public static function getCurrentReportingUrl($include_host = true): string { - return ($include_host ? Director::absoluteBaseURL() : '/') . 'csp/v1/report'; + return ($include_host ? rtrim(Director::absoluteBaseURL(), '/') : '') . '/csp/v1/report'; } /** @@ -68,7 +64,7 @@ public function report(HTTPRequest $request) // collect the body try { - if(!self::config()->get('accept_reports')) { + if (!self::config()->get('accept_reports')) { throw new \Exception("This endpoint does not accept reports"); } @@ -78,27 +74,27 @@ public function report(HTTPRequest $request) $contentType = $request->getHeader('Content-Type'); $acceptedContentTypes = [ 'application/csp-report', 'application/reports+json' ]; - if(!in_array($contentType, $acceptedContentTypes)) { + if (!in_array($contentType, $acceptedContentTypes)) { throw new \Exception("The request does not have an accepted content type"); } $body = $request->getBody(); - if(!$body) { + if (!$body) { throw new \Exception("The body of the request is empty"); } $report = json_decode($body, true); - if(json_last_error() !== JSON_ERROR_NONE) { + if (json_last_error() !== JSON_ERROR_NONE) { throw new \Exception("CSP report JSON decode error: " . json_last_error_msg()); } - $violationReport = ViolationReport::create_report($report , $contentType); + $violationReport = ViolationReport::create_report($report, $contentType); - } catch (\Exception $e) { - Logger::log("ReportingEndpoint: " . $e->getMessage(), "NOTICE"); - } finally { - $this->returnHeader(); + } catch (\Exception $exception) { + Logger::log("ReportingEndpoint: " . $exception->getMessage(), "NOTICE"); } + $this->returnHeader(); + } } diff --git a/src/Extensions/ContentSecurityPolicyEnable.php b/src/Extensions/ContentSecurityPolicyEnable.php index a6693a8..3c20817 100644 --- a/src/Extensions/ContentSecurityPolicyEnable.php +++ b/src/Extensions/ContentSecurityPolicyEnable.php @@ -7,10 +7,11 @@ /** * Apply this to relevant controller types to enable CSP header delivery * @author James + * @extends \SilverStripe\Core\Extension<(\SilverStripe\Security\Security & static)> */ class ContentSecurityPolicyEnable extends Extension { - public function EnableContentSecurityPolicy() + public function EnableContentSecurityPolicy(): bool { return true; } diff --git a/src/Extensions/ControllerExtension.php b/src/Extensions/ControllerExtension.php index 92619b1..7245bbf 100644 --- a/src/Extensions/ControllerExtension.php +++ b/src/Extensions/ControllerExtension.php @@ -3,38 +3,33 @@ namespace NSWDPC\Utilities\ContentSecurityPolicy; use SilverStripe\Core\Extension; -use SilverStripe\Control\Director; -use SilverStripe\Core\Config\Config; -use SilverStripe\Admin\ModelAdmin; use SilverStripe\Control\HTTPResponse; use SilverStripe\Versioned\Versioned; -use SilverStripe\Admin\LeftAndMain; use SilverStripe\CMS\Controllers\ContentController; -use SilverStripe\CMS\Controllers\ModelAsController; use SilverStripe\CMS\Model\SiteTree; /** * Provides an extension method so that the Controller can set the relevant CSP header + * @extends \SilverStripe\Core\Extension<(\SilverStripe\Control\Controller & static)> */ class ControllerExtension extends Extension { - public function onAfterInit() { // No response handling - $response = $this->owner->getResponse(); + $response = $this->getOwner()->getResponse(); if ($response && !($response instanceof HTTPResponse)) { return; } // Don't go in a loop reporting to the Reporting Endpoint controller from the Reporting Endpoint controller! - if ($this->owner instanceof ReportingEndpoint) { + if ($this->getOwner() instanceof ReportingEndpoint) { return; } // check if a policy can be applied - if (!$canApply = Policy::checkCanApply($this->owner)) { + if (!$canApply = Policy::checkCanApply($this->getOwner())) { return; } @@ -42,8 +37,8 @@ public function onAfterInit() $stage = Versioned::get_stage(); $is_live = ($stage == Versioned::LIVE); - // only get enabled policy/directives - $enabled_policy = $enabled_directives = true; + // only get enabled directives + $enabled_directives = true; // Set the CSP nonce for this request Nonce::getNonce(); @@ -51,24 +46,24 @@ public function onAfterInit() $policy = Policy::getDefaultBasePolicy($is_live, Policy::POLICY_DELIVERY_METHOD_HEADER); // check for Page specific policy - if ($this->owner instanceof ContentController - && ($data = $this->owner->data()) + if ($this->getOwner() instanceof ContentController + && ($data = $this->getOwner()->data()) && $data instanceof SiteTree) { - $page_policy = Policy::getPagePolicy($data, $is_live, Policy::POLICY_DELIVERY_METHOD_HEADER); - if (!empty($page_policy->ID)) { - if (!empty($policy->ID)) { - /** - * HTTPResponse can't handle header names that are duplicated (which is allowed in the HTTP spec) - * Workaround is to set the page policy for merging when getPolicyData() is called - * Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Multiple_content_security_policies - * Ref: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 - */ - $policy->setMergeFromPolicy($page_policy); - } else { - // the page policy is *the* policy - $policy = $page_policy; - } + $page_policy = Policy::getPagePolicy($data, $is_live, Policy::POLICY_DELIVERY_METHOD_HEADER); + if (!empty($page_policy->ID)) { + if (!empty($policy->ID)) { + /** + * HTTPResponse can't handle header names that are duplicated (which is allowed in the HTTP spec) + * Workaround is to set the page policy for merging when getPolicyData() is called + * Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Multiple_content_security_policies + * Ref: https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2 + */ + $policy->setMergeFromPolicy($page_policy); + } else { + // the page policy is *the* policy + $policy = $page_policy; } + } } // Add the policy/reporting header values @@ -82,6 +77,7 @@ public function onAfterInit() Policy::getReportingEndpointsHeader($data['reporting_endpoints']) ); } + if (!empty($data['nel'])) { // NEL is enabled $response->addHeader( @@ -93,10 +89,9 @@ public function onAfterInit() json_encode($data['nel']) ); } + // the relevant CSP-header with its values $response->addHeader($data['header'], $data['policy_string']); } - - return; } } diff --git a/src/Extensions/NonceRequirements_Backend.php b/src/Extensions/NonceRequirements_Backend.php index 1922fd9..84b9ad8 100644 --- a/src/Extensions/NonceRequirements_Backend.php +++ b/src/Extensions/NonceRequirements_Backend.php @@ -2,33 +2,25 @@ namespace NSWDPC\Utilities\ContentSecurityPolicy; -use SilverStripe\Dev\Deprecation; use SilverStripe\View\Requirements_Backend; -use SilverStripe\Core\Config\Config; use SilverStripe\View\HTML; class NonceRequirements_Backend extends Requirements_Backend { - /** * @inheritdoc */ + #[\Override] public function includeInHTML($content) { - if (func_num_args() > 1) { - Deprecation::notice( - '5.0', - '$templateFile argument is deprecated. includeInHTML takes a sole $content parameter now.' - ); - $content = func_get_arg(1); - } // Skip if content isn't injectable, or there is nothing to inject $tagsAvailable = preg_match('#css || $this->javascript || $this->customCSS || $this->customScript || $this->customHeadTags; - if (!$tagsAvailable || !$hasFiles) { + if ($tagsAvailable === 0 || $tagsAvailable === false || !$hasFiles) { return $content; } + $requirements = ''; $jsRequirements = ''; @@ -39,21 +31,25 @@ public function includeInHTML($content) foreach ($this->getJavascript() as $file => $attributes) { // Build html attributes $htmlAttributes = [ - 'type' => isset($attributes['type']) ? $attributes['type'] : "application/javascript", + 'type' => $attributes['type'] ?? "application/javascript", 'src' => $this->pathForFile($file), ]; if (!empty($attributes['async'])) { $htmlAttributes['async'] = 'async'; } + if (!empty($attributes['defer'])) { $htmlAttributes['defer'] = 'defer'; } + if (!empty($attributes['integrity'])) { $htmlAttributes['integrity'] = $attributes['integrity']; } + if (!empty($attributes['crossorigin'])) { $htmlAttributes['crossorigin'] = $attributes['crossorigin']; } + $tag = 'script'; Nonce::addToAttributes($tag, $htmlAttributes); $jsRequirements .= HTML::createTag($tag, $htmlAttributes); @@ -85,12 +81,15 @@ public function includeInHTML($content) if (!empty($params['media'])) { $htmlAttributes['media'] = $params['media']; } + if (!empty($params['integrity'])) { $htmlAttributes['integrity'] = $params['integrity']; } + if (!empty($params['crossorigin'])) { $htmlAttributes['crossorigin'] = $params['crossorigin']; } + $tag = 'link'; Nonce::addToAttributes($tag, $htmlAttributes); $requirements .= HTML::createTag($tag, $htmlAttributes); @@ -127,6 +126,7 @@ public function includeInHTML($content) } else { $content = $this->insertTagsIntoHead($jsRequirements, $content); } + return $content; } diff --git a/src/Extensions/SiteTreeExtension.php b/src/Extensions/SiteTreeExtension.php index db7fd58..fad36b3 100644 --- a/src/Extensions/SiteTreeExtension.php +++ b/src/Extensions/SiteTreeExtension.php @@ -16,37 +16,37 @@ /** * Provides an extension method so that the SiteTree can gather the CSP meta tag if that is set + * @property int $CspPolicyID + * @method \NSWDPC\Utilities\ContentSecurityPolicy\Policy CspPolicy() + * @extends \SilverStripe\Core\Extension<(\SilverStripe\CMS\Model\SiteTree & static)> */ class SiteTreeExtension extends Extension { - /** * Has_one relationship - * @var array * @config */ - private static $has_one = [ + private static array $has_one = [ 'CspPolicy' => Policy::class, // a page can have a CSP ]; /** * Update Fields - * @return FieldList */ - public function updateSettingsFields(FieldList $fields) + public function updateSettingsFields(FieldList $fields): FieldList { $available_policies = Policy::get()->sort('Title ASC')->filter('Enabled', 1)->exclude('IsBasePolicy', 1); - if($available_policies->count() == 0) { + if ($available_policies->count() == 0) { $fields->removeByName('CspPolicyID'); $fields->addFieldToTab( 'Root.CSP', LiteralField::create( 'CspPolicyNoneFound', '
{internal_reporting_url}
' . - _t( + htmlspecialchars(_t( 'ContentSecurityPolicy.NO_AVAILABLE_EXTRA_POLICIES', 'There are no extra Content Security Polices. To fix this, define a new policy in the CSP administration area or ask an administrator to do this and it will appear here' - ) + )) . "
' - . _t('ContentSecurityPolicy.DIRECTIVE_HELPER', - 'Prior to adding a directive, you should consult the ' + . htmlspecialchars(_t( + 'ContentSecurityPolicy.DIRECTIVE_HELPER', + 'Prior to adding a directive, you should consult the ' . 'Content Security Policy MDN documentation at ' . 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy' - ) + )) . '
' ), 'Key' ); - $fields->dataFieldByName('IncludeSelf')->setDescription(_t('ContentSecurityPolicy.ADD_SELF_VALUE', "Adds the 'self' value to this directive")); - $fields->dataFieldByName('AllowDataUri')->setDescription(_t('ContentSecurityPolicy.ADD_DATA_VALUE', "Adds the 'data:' value to this directive")); - $fields->dataFieldByName('UnsafeInline')->setDescription(_t('ContentSecurityPolicy.ADD_UNSAFE_INLINE_VALUE', "Adds the 'unsafe-inline' value to this directive.")); - $fields->dataFieldByName('Enabled')->setDescription(_t('ContentSecurityPolicy.ENABLED_DIRECTIVE', "Enables this directive within linked policies")); - $fields->dataFieldByName('ReportSample')->setDescription(_t('ContentSecurityPolicy.REPORT_SAMPLE', "Adds the 'report-sample' value to this directive. Only applicable to script-src* and style-src* violations. Will send a snippet of code that caused the violation to the reporting URL.")); + $fields->dataFieldByName('IncludeSelf')->setDescription(htmlspecialchars(_t('ContentSecurityPolicy.ADD_SELF_VALUE', "Adds the 'self' value to this directive"))); + $fields->dataFieldByName('AllowDataUri')->setDescription(htmlspecialchars(_t('ContentSecurityPolicy.ADD_DATA_VALUE', "Adds the 'data:' value to this directive"))); + $fields->dataFieldByName('UnsafeInline')->setDescription(htmlspecialchars(_t('ContentSecurityPolicy.ADD_UNSAFE_INLINE_VALUE', "Adds the 'unsafe-inline' value to this directive."))); + $fields->dataFieldByName('Enabled')->setDescription(htmlspecialchars(_t('ContentSecurityPolicy.ENABLED_DIRECTIVE', "Enables this directive within linked policies"))); + $fields->dataFieldByName('ReportSample')->setDescription(htmlspecialchars(_t('ContentSecurityPolicy.REPORT_SAMPLE', "Adds the 'report-sample' value to this directive. Only applicable to script-src* and style-src* violations. Will send a snippet of code that caused the violation to the reporting URL."))); $fields->dataFieldByName('HasNone') ->setDescription( - _t( + htmlspecialchars(_t( 'ContentSecurityPolicy.NONE_VALUE_DESCRIPTION', "When enabled, elements controlled by this directive will not be allowed to load any resources." - . "" - . "This value will be ignored by web browsers if other source expressions such as 'self' or URLs are present in the directive." - ) + . " This value will be ignored by web browsers if other source expressions such as 'self' or URLs are present in the directive." + )) )->setTitle( _t( 'ContentSecurityPolicy.NONE_VALUE_TITLE', @@ -225,7 +230,18 @@ public function getCMSFields() if ($policies > 1) { $fields->addFieldToTab( 'Root.Main', - LiteralField::create('MultiplePolicies', "
" . sprintf(_t('ContentSecurityPolicy.USED_IN_MULTIPLE_POLICIES', 'This record is used in %d policies. Updating it will modify all linked policies'), $policies) . "
' + . htmlspecialchars(_t( + 'ContentSecurityPolicy.USED_IN_MULTIPLE_POLICIES', + 'This record is used in {count} policies. Updating it will modify all linked policies', + [ + 'count' => $policies + ] + )) + . "
;
,
This policy has the following duplicate directives: ' - . htmlspecialchars(implode(", ", $keys)) - . ". Redundant directives should be unlinked or merged.
' + . htmlspecialchars(_t( + 'ContentSecurityPolicy.DUPLICATE_DIRECTIVES_WARNING', + 'This policy has the following duplicate directives: {duplicateDirectives}. Redundant directives should be unlinked or merged.', + [ + 'duplicateDirectives' => implode(", ", $keys) + ] + )) + . "