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', '

' . - _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' - ) + )) . "

" ) ); @@ -55,17 +55,23 @@ public function updateSettingsFields(FieldList $fields) 'Root.CSP', DropdownField::create( 'CspPolicyID', - 'Content Security Policy', + _t( + 'ContentSecurityPolicy.CONTENT_SECURITY_POLICY_CHOOSE', + 'Content Security Policy', + ), $available_policies->map('ID', 'Title') )->setEmptyString('') ->setDescription( - _t( + nl2br(htmlspecialchars(_t( 'ContentSecurityPolicy.ADDITION_SECURITY_POLICY', - 'Choose an additional Content Security Policy to apply on this page only.
Adding additional policies can only further restrict the capabilities of the protected resource.' - ) - ) + "Choose an additional Content Security Policy to apply on this page only." + . "\n" + . "Adding additional policies can only further restrict the capabilities of the protected resource." + ))) + ) ); } + return $fields; } @@ -81,15 +87,12 @@ private function checkCanRun() } // Configured controllers with no CSP - if(Policy::controllerWithoutCsp($controller)) { - return false; - } - - return true; + return !Policy::controllerWithoutCsp($controller); } /** * Extension hook, see {@link SilverStripe\CMS\Model\SiteTree::MetaTags} + * @deprecated Delivery of CSP via metatags will be removed in a future major version * @returns void */ public function MetaTags(&$tags) @@ -102,6 +105,7 @@ public function MetaTags(&$tags) * Note that reporting is ignored/disallowed when using a meta tag. Only the header Content-Security-Policy is allowed. * In your template this can be called directly by adding $CspMetaTags if you don't use $MetaTags * See https://github.com/w3c/webappsec-csp/issues/348 for a good discussion on this and possible inclusion of CSPRO in metatags + * @deprecated Delivery of CSP via metatags will be removed in a future major version * @returns string */ public function CspMetaTags() @@ -126,8 +130,8 @@ public function CspMetaTags() } // check for a specific page based policy - if ($this->owner instanceof SiteTree) { - $page_policy = Policy::getPagePolicy($this->owner, $is_live, Policy::POLICY_DELIVERY_METHOD_METATAG); + if ($this->getOwner() instanceof SiteTree) { + $page_policy = Policy::getPagePolicy($this->getOwner(), $is_live, Policy::POLICY_DELIVERY_METHOD_METATAG); if (!empty($page_policy->ID) && ($data = $page_policy->getPolicyData(true))) { $tags[] = HTML::createTag('meta', [ 'http-equiv' => $data['header'], diff --git a/src/Jobs/PruneViolationReportsJob.php b/src/Jobs/PruneViolationReportsJob.php index d45f5be..79130ac 100644 --- a/src/Jobs/PruneViolationReportsJob.php +++ b/src/Jobs/PruneViolationReportsJob.php @@ -2,38 +2,48 @@ namespace NSWDPC\Utilities\ContentSecurityPolicy; -use Symbiote\QueuedJobs\Services\QueuedJob; use Symbiote\QueuedJobs\Services\QueuedJobService; use Symbiote\QueuedJobs\Services\AbstractQueuedJob; use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Config\Configurable; use SilverStripe\ORM\DB; use DateTime; -use Exception; /** * Remove violation reports older than a set time */ class PruneViolationReportsJob extends AbstractQueuedJob { - private static $older_than = 1;//hour + use Configurable; + + //hour + private static int $older_than = 1; public function __construct($older_than = 0) { - if (!$older_than) { + if (!$older_than || $older_than <= 0) { $older_than = Config::inst()->get(self::class, 'older_than'); } - $this->older_than = (int)abs($older_than); + + $this->older_than_hours = (int)abs($older_than); } public function getTitle() { - return sprintf(_t('ContentSecurityPolicy.PRUNE_REPORTS_JOBTITLE', 'Remove CSP violation reports older than %d hour'), $this->older_than); + return _t('ContentSecurityPolicy.PRUNE_REPORTS_JOBTITLE', 'Remove CSP violation reports older than {count} hour(s)', ['count' => $this->older_than_hours]); + } + + #[\Override] + public function setup() + { + parent::setup(); + $this->totalSteps = 1; } public function getRecordCount() { - $query = "SELECT COUNT(ID) AS RecordCount FROM \"CspViolationReport\""; - if($result = DB::query($query)) { + $query = 'SELECT COUNT(ID) AS RecordCount FROM "CspViolationReport"'; + if ($result = DB::query($query)) { $row = $result->record(); return $row['RecordCount'] ?? 0; } else { @@ -43,31 +53,29 @@ public function getRecordCount() public function process() { - $older_than = (int)abs($this->older_than); - if (!$older_than) { + $older_than = abs($this->older_than_hours); + if ($older_than === 0) { $older_than = 1; } - $this->older_than = $older_than; + $this->older_than_hours = $older_than; $pre_count = $this->getRecordCount(); $dt = new DateTime(); $now = $dt->format('Y-m-d H:i:s'); - $query = "DELETE FROM \"CspViolationReport\" WHERE \"Created\" < ? - INTERVAL ? HOUR"; - $result = DB::prepared_query($query, [$now, $this->older_than]); + $query = 'DELETE FROM "CspViolationReport" WHERE "Created" < ? - INTERVAL ? HOUR'; + DB::prepared_query($query, [$now, $this->older_than_hours]); $post_count = $this->getRecordCount(); $removed = $pre_count - $post_count; $removed_string = ($removed . '/' . $pre_count); - $message = sprintf(_t('ContentSecurityPolicy.REMOVED_COUNT_REPORTS', 'Removed %s reports(s)'), $removed_string); + $message = _t('ContentSecurityPolicy.REMOVED_COUNT_REPORTS', 'Removed {count} reports(s)', ['count' => $removed_string]); $this->addMessage($message); - - $this->totalSteps = $this->currentStep = $post_count - $pre_count; + $this->currentStep = 1; $this->isComplete = true; - return; } /** @@ -75,9 +83,10 @@ public function process() */ public function afterComplete() { - $job = new PruneViolationReportsJob($this->older_than); + $job = new PruneViolationReportsJob($this->older_than_hours); $dt = new DateTime(); - $dt->modify('+' . $this->older_than . ' hour'); + $dt->modify('+' . $this->older_than_hours . ' hour'); + singleton(QueuedJobService::class)->queueJob($job, $dt->format('Y-m-d H:i:s')); } } diff --git a/src/Middleware/CSPMiddleware.php b/src/Middleware/CSPMiddleware.php index 48b82f5..13d5835 100644 --- a/src/Middleware/CSPMiddleware.php +++ b/src/Middleware/CSPMiddleware.php @@ -1,4 +1,5 @@ getHeader('Content-Type'); - if(!( strpos( strtolower($content_type), self::CONTENT_TYPE_HTML ) === 0) ) { + if (!str_starts_with(strtolower($content_type), self::CONTENT_TYPE_HTML)) { // only apply to text/html documents return false; } - $policy = $response->getHeader( Policy::HEADER_CSP ); - if(!$policy) { + $policy = $response->getHeader(Policy::HEADER_CSP); + if (!$policy) { // check for a CSPRO header - $policy = $response->getHeader( Policy::HEADER_CSP_REPORT_ONLY ); + $policy = $response->getHeader(Policy::HEADER_CSP_REPORT_ONLY); } + return $policy; } /** * Apply the Content Security Policy changes, if any are required. * If the middleware is not enabled, no changes are applied - * @return \SilverStripe\Control\HTTPResponse */ - protected function applyCSP(HTTPRequest $request, callable $delegate) : HTTPResponse { + protected function applyCSP(HTTPRequest $request, callable $delegate): HTTPResponse + { $response = $delegate($request); // check if enabled - if( Config::inst()->get( Policy::class, 'nonce_injection_method' ) != Policy::NONCE_INJECT_VIA_MIDDLEWARE ) { + if (Config::inst()->get(Policy::class, 'nonce_injection_method') != Policy::NONCE_INJECT_VIA_MIDDLEWARE) { return $response; } // get the policy in use $policy = $this->getPolicy($response); - if(!$policy) { + if (!$policy) { // no policy is set return $response; } \libxml_use_internal_errors(true); $body = $response->getBody(); - if(!$body) { + if (!$body) { return $response; } // apply nonce to these tags $tags = ['script','style']; $dom = new DOMDocument(); - $dom->loadHTML( $body , LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); - foreach($tags as $tag) { + $dom->loadHTML($body, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD); + foreach ($tags as $tag) { $elements = $dom->getElementsByTagName($tag); Nonce::addToElements($elements); } + $html = $dom->saveHTML(); $response->setBody($html); \libxml_clear_errors(); diff --git a/src/Models/Directive.php b/src/Models/Directive.php index ed2deb9..fbb4e71 100644 --- a/src/Models/Directive.php +++ b/src/Models/Directive.php @@ -8,7 +8,6 @@ use SilverStripe\Forms\CompositeField; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\TextField; -use SilverStripe\Forms\TextareaField; use SilverStripe\Forms\DropdownField; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionProvider; @@ -18,38 +17,45 @@ /** * A Content Security Policy directive, can be used by multiple {@link Policy} + * @property ?string $Key + * @property mixed $Rules + * @property bool $Enabled + * @property bool $IncludeSelf + * @property bool $UnsafeInline + * @property bool $AllowDataUri + * @property bool $UseNonce + * @property bool $ReportSample + * @property bool $HasNone + * @method \SilverStripe\ORM\ManyManyList<\NSWDPC\Utilities\ContentSecurityPolicy\Policy> Policies() */ class Directive extends DataObject implements PermissionProvider { - /** * @config */ - private static $table_name = 'CspDirective'; + private static string $table_name = 'CspDirective'; /** * @config */ - private static $singular_name = 'Directive'; + private static string $singular_name = 'Directive'; /** * @config */ - private static $plural_name = 'Directives'; + private static string $plural_name = 'Directives'; /** * Default sort ordering - * @var string * @config */ - private static $default_sort = 'Key ASC'; + private static string $default_sort = 'Key ASC'; /** * Database fields - * @var array * @config */ - private static $db = [ + private static array $db = [ 'Key' => 'Varchar(255)', 'Rules' => 'MultiValueField', 'Enabled' => 'Boolean', @@ -63,10 +69,9 @@ class Directive extends DataObject implements PermissionProvider /** * Database indexes - * @var array * @config */ - private static $indexes = [ + private static array $indexes = [ 'Enabled' => true, 'Key' => true ]; @@ -74,27 +79,25 @@ class Directive extends DataObject implements PermissionProvider /** * Defines summary fields commonly used in table columns * as a quick overview of the data for this dataobject - * @var array * @config */ - private static $summary_fields = [ + private static array $summary_fields = [ 'ID' => '#', 'Key' => 'Name', 'DirectiveValue' => 'Value', - 'Enabled.Nice' =>'Enabled', + 'Enabled.Nice' => 'Enabled', 'Policies.Count' => 'Policies', - 'IncludeSelf.Nice' =>'Include \'self\'', - 'UnsafeInline.Nice' =>'Unsafe Inline', - 'AllowDataUri.Nice' =>'Allow Data URI', + 'IncludeSelf.Nice' => "Include 'self'", + 'UnsafeInline.Nice' => 'Unsafe Inline', + 'AllowDataUri.Nice' => 'Allow Data URI', 'UseNonce.Nice' => 'Use Nonce' ]; /** * Searchable Fields - * @var array * @config */ - private static $searchable_fields = [ + private static array $searchable_fields = [ 'Key' => PartialMatchFilter::class, 'Enabled' => ExactMatchFilter::class, 'IncludeSelf' => ExactMatchFilter::class, @@ -105,13 +108,13 @@ class Directive extends DataObject implements PermissionProvider /** * Many_many relationship - * @var array * @config */ - private static $belongs_many_many = [ + private static array $belongs_many_many = [ 'Policies' => Policy::class, ]; + #[\Override] public function getTitle() { return substr($this->Key . " " . $this->getDirectiveValue(true), 0, 100) . "..."; @@ -120,11 +123,11 @@ public function getTitle() /** * The text here is taken from: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy */ - public function possibleKeys() + public function possibleKeys(): array { $keys = [ 'default-src' => _t('ContentSecurityPolicy.DIRECTIVE_DEFAULT_SRC', 'the fallback for all directives'), - 'base-uri' => _t('ContentSecurityPolicy.DIRECTIVE_BASE_URI', 'restricts the URLs which can be used in a document\'s element'), + 'base-uri' => _t('ContentSecurityPolicy.DIRECTIVE_BASE_URI', "restricts the URLs which can be used in a document's element"), 'frame-src' => _t('ContentSecurityPolicy.DIRECTIVE_FRAME_SRC', 'specifies valid sources for nested browsing contexts loading using elements such as and