From d3e0d120a3c10007a1be95a067f21e3e99186577 Mon Sep 17 00:00:00 2001 From: Oliver Hader <oliver@typo3.org> Date: Tue, 23 May 2023 16:58:10 +0200 Subject: [PATCH] [BUGFIX] Show source file and user agent in CSP backend module The CSP backend module is not showing the optional source-file of the reported violation. This information references to the actual asset that caused the violation. In addition, visualizing the user-agent helps to identifiy and reproduce possible flaws. In case, `effective-directive` (preferred W3C property) is not given, but `violated-directive` (legacy W3C property) is, that value is taken - this still can happen in old browser versions. Resolves: #100912 Releases: main, 12.4 Change-Id: Idf9482d234292a15a4114c474ddb2b5316d21a87 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/79562 Tested-by: core-ci <typo3@b13.com> Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org> --- .../backend/security/element/csp-reports.ts | 42 ++++++--- .../Modules/content-security-policy.xlf | 6 ++ .../security/element/csp-reports.js | 84 +++++++++--------- .../AbstractContentSecurityPolicyReporter.php | 8 +- .../Processing/HandlerTrait.php | 4 +- .../Reporting/Report.php | 19 +--- .../Reporting/ReportDetails.php | 46 ++++++++++ .../Reporting/ReportTest.php | 87 +++++++++++++++++++ 8 files changed, 224 insertions(+), 72 deletions(-) create mode 100644 typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php create mode 100644 typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Reporting/ReportTest.php diff --git a/Build/Sources/TypeScript/backend/security/element/csp-reports.ts b/Build/Sources/TypeScript/backend/security/element/csp-reports.ts index f294684e2496..9f24ad70b835 100644 --- a/Build/Sources/TypeScript/backend/security/element/csp-reports.ts +++ b/Build/Sources/TypeScript/backend/security/element/csp-reports.ts @@ -37,6 +37,10 @@ interface SummarizedCspReport { count: number, attributes: CspReportAttribute[], mutationHashes: string[]; + meta: { + addr: string, + agent: string, + }; details: { // https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP#violation_report_syntax disposition: 'enforce' | 'report', @@ -54,6 +58,7 @@ interface SummarizedCspReport { */ violatedDirective: string, + sourceFile?: string, scriptSample?: string, columnNumber?: number, lineNumber?: number, @@ -188,7 +193,7 @@ export class CspReports extends LitElement { <td>${report.scope}</td> <td> <span class="badge bg-warning">${report.count}</span> - ${report.details.violatedDirective} + ${report.details.effectiveDirective} </td> <td>${this.shortenUri(report.details.blockedUri)}</td> <td>${report.attributes.join(', ')}</td> @@ -262,21 +267,25 @@ export class CspReports extends LitElement { <dt>${ lll('label.directive') || 'Directive'} / ${ lll('label.disposition') || 'Disposition'}</dt> <dd>${report.details.effectiveDirective} / ${report.details.disposition}</dd> + <dt>${ lll('label.document_uri') || 'Document URI'}</dt> + <dd>${report.details.documentUri} ${this.renderCodeLocation(report)}</dd> + + ${report.details.sourceFile && report.details.sourceFile !== report.details.documentUri ? html` + <dt>${ lll('label.source_file') || 'Source File'}</dt> + <dd>${report.details.sourceFile}</dd> + ` : nothing} + <dt>${ lll('label.blocked_uri') || 'Blocked URI'}</dt> <dd>${report.details.blockedUri}</dd> - <dt>${ lll('label.document_uri') || 'Document URI'}</dt> - <dd> - ${report.details.documentUri} - ${report.details.lineNumber ? html` - (${report.details.lineNumber}:${report.details.columnNumber}) - ` : nothing}</dd> - ${report.details.scriptSample ? html` <dt>${ lll('label.sample') || 'Sample'}</dt> - <dd><code> - <pre>${report.details.scriptSample}</pre> - </code></dd> + <dd><code>${report.details.scriptSample}</code></dd> + ` : nothing} + + ${report.meta.agent ? html` + <dt>${ lll('label.user_agent') || 'User Agent'}</dt> + <dd><code>${report.meta.agent}</code></dd> ` : nothing} <dt>${ lll('label.uuid') || 'UUID'}</dt> @@ -326,6 +335,17 @@ export class CspReports extends LitElement { ` : nothing }`; } + private renderCodeLocation(report: SummarizedCspReport): TemplateResult|symbol { + if (!report.details.lineNumber) { + return nothing + } + const parts = [report.details.lineNumber]; + if (report.details.columnNumber) { + parts.push(report.details.columnNumber); + } + return html`(${parts.join(':')})`; + } + private selectReport(report: SummarizedCspReport): void { this.suggestions = []; if (report !== null && this.selectedReport !== report) { diff --git a/typo3/sysext/backend/Resources/Private/Language/Modules/content-security-policy.xlf b/typo3/sysext/backend/Resources/Private/Language/Modules/content-security-policy.xlf index 25186828de85..d73df1dc98c7 100644 --- a/typo3/sysext/backend/Resources/Private/Language/Modules/content-security-policy.xlf +++ b/typo3/sysext/backend/Resources/Private/Language/Modules/content-security-policy.xlf @@ -63,9 +63,15 @@ <trans-unit id="module.label.sample" resname="module.label.sample"> <source>Sample</source> </trans-unit> + <trans-unit id="module.label.user_agent" resname="module.label.user_agent"> + <source>User Agent</source> + </trans-unit> <trans-unit id="module.label.uuid" resname="module.label.uuid"> <source>UUID</source> </trans-unit> + <trans-unit id="module.label.source_file" resname="module.label.source_file"> + <source>Source File</source> + </trans-unit> <trans-unit id="module.label.summary" resname="module.label.summary"> <source>Summary</source> </trans-unit> diff --git a/typo3/sysext/backend/Resources/Public/JavaScript/security/element/csp-reports.js b/typo3/sysext/backend/Resources/Public/JavaScript/security/element/csp-reports.js index 403ff4e7364c..d6f291fd3c03 100644 --- a/typo3/sysext/backend/Resources/Public/JavaScript/security/element/csp-reports.js +++ b/typo3/sysext/backend/Resources/Public/JavaScript/security/element/csp-reports.js @@ -10,7 +10,7 @@ * * The TYPO3 project - inspiring people to share! */ -var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s<3?e:null===o?o=Object.getOwnPropertyDescriptor(e,i):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(t,e,i,o);else for(var r=t.length-1;r>=0;r--)(l=t[r])&&(n=(s<3?l(n):s>3?l(e,i,n):l(e,i))||n);return s>3&&n&&Object.defineProperty(e,i,n),n};import{customElement,property,state}from"lit/decorators.js";import{html,LitElement,nothing}from"lit";import{classMap}from"lit/directives/class-map.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import{styleTag,lll}from"@typo3/core/lit-helper.js";!function(t){t.fixable="fixable",t.irrelevant="irrelevant",t.suspicious="suspicious"}(CspReportAttribute||(CspReportAttribute={}));let CspReports=class extends LitElement{constructor(){super(...arguments),this.selectedScope=null,this.reports=[],this.selectedReport=null,this.suggestions=[]}createRenderRoot(){return this}connectedCallback(){super.connectedCallback(),this.fetchReports()}render(){return html` +var CspReportAttribute,__decorate=function(e,t,i,o){var l,s=arguments.length,n=s<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,i):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,o);else for(var r=e.length-1;r>=0;r--)(l=e[r])&&(n=(s<3?l(n):s>3?l(t,i,n):l(t,i))||n);return s>3&&n&&Object.defineProperty(t,i,n),n};import{customElement,property,state}from"lit/decorators.js";import{html,LitElement,nothing}from"lit";import{classMap}from"lit/directives/class-map.js";import AjaxRequest from"@typo3/core/ajax/ajax-request.js";import{styleTag,lll}from"@typo3/core/lit-helper.js";!function(e){e.fixable="fixable",e.irrelevant="irrelevant",e.suspicious="suspicious"}(CspReportAttribute||(CspReportAttribute={}));let CspReports=class extends LitElement{constructor(){super(...arguments),this.selectedScope=null,this.reports=[],this.selectedReport=null,this.suggestions=[]}createRenderRoot(){return this}connectedCallback(){super.connectedCallback(),this.fetchReports()}render(){return html` ${styleTag` .infolist-container { container-type: inline-size; @@ -91,17 +91,17 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s ${0===this.reports.length?html` <tr><td colspan="5">${lll("label.label.noEntriesAvailable")||"No entries available."}</td></tr> `:nothing} - ${this.reports.map((t=>html` - <tr class=${classMap({"table-info":this.selectedReport===t})} data-mutation-group=${t.mutationHashes.join("-")} - @click=${()=>this.selectReport(t)}> - <td>${t.created}</td> - <td>${t.scope}</td> + ${this.reports.map((e=>html` + <tr class=${classMap({"table-info":this.selectedReport===e})} data-mutation-group=${e.mutationHashes.join("-")} + @click=${()=>this.selectReport(e)}> + <td>${e.created}</td> + <td>${e.scope}</td> <td> - <span class="badge bg-warning">${t.count}</span> - ${t.details.violatedDirective} + <span class="badge bg-warning">${e.count}</span> + ${e.details.effectiveDirective} </td> - <td>${this.shortenUri(t.details.blockedUri)}</td> - <td>${t.attributes.join(", ")}</td> + <td>${this.shortenUri(e.details.blockedUri)}</td> + <td>${e.attributes.join(", ")}</td> </tr> `))} </tbody> @@ -126,13 +126,13 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s </span> ${lll("label.all")||"ALL"} </button> - ${this.scopes.map((t=>html` + ${this.scopes.map((e=>html` <li> - <button class="dropdown-item dropdown-item-spaced" title="${t}" @click=${()=>this.selectScope(t)}> - <span class="${t===this.selectedScope?"text-primary":""}"> - <typo3-backend-icon identifier="${t===this.selectedScope?"actions-dot":"empty-empty"}" size="small"></typo3-backend-icon> + <button class="dropdown-item dropdown-item-spaced" title="${e}" @click=${()=>this.selectScope(e)}> + <span class="${e===this.selectedScope?"text-primary":""}"> + <typo3-backend-icon identifier="${e===this.selectedScope?"actions-dot":"empty-empty"}" size="small"></typo3-backend-icon> </span> - ${t} + ${e} </button> </li>`))} </ul> @@ -148,7 +148,7 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s </div> </div> </div> - `}`}renderSelectedReport(){const t=this.selectedReport;return html`${t?html` + `}`}renderSelectedReport(){const e=this.selectedReport;return html`${e?html` <div class="infolist-info-record"> <div class="card mb-0"> <div class="card-header"> @@ -157,30 +157,34 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s <div class="card-body"> <dl> <dt>${lll("label.directive")||"Directive"} / ${lll("label.disposition")||"Disposition"}</dt> - <dd>${t.details.effectiveDirective} / ${t.details.disposition}</dd> - - <dt>${lll("label.blocked_uri")||"Blocked URI"}</dt> - <dd>${t.details.blockedUri}</dd> + <dd>${e.details.effectiveDirective} / ${e.details.disposition}</dd> <dt>${lll("label.document_uri")||"Document URI"}</dt> - <dd> - ${t.details.documentUri} - ${t.details.lineNumber?html` - (${t.details.lineNumber}:${t.details.columnNumber}) - `:nothing}</dd> + <dd>${e.details.documentUri} ${this.renderCodeLocation(e)}</dd> + + ${e.details.sourceFile&&e.details.sourceFile!==e.details.documentUri?html` + <dt>${lll("label.source_file")||"Source File"}</dt> + <dd>${e.details.sourceFile}</dd> + `:nothing} - ${t.details.scriptSample?html` + <dt>${lll("label.blocked_uri")||"Blocked URI"}</dt> + <dd>${e.details.blockedUri}</dd> + + ${e.details.scriptSample?html` <dt>${lll("label.sample")||"Sample"}</dt> - <dd><code> - <pre>${t.details.scriptSample}</pre> - </code></dd> + <dd><code>${e.details.scriptSample}</code></dd> + `:nothing} + + ${e.meta.agent?html` + <dt>${lll("label.user_agent")||"User Agent"}</dt> + <dd><code>${e.meta.agent}</code></dd> `:nothing} <dt>${lll("label.uuid")||"UUID"}</dt> - <dd><code>${t.uuid}</code></dd> + <dd><code>${e.uuid}</code></dd> <dt>${lll("label.summary")||"Summary"}</dt> - <dd><code>${t.summary}</code></dd> + <dd><code>${e.summary}</code></dd> </dl> </div> ${this.suggestions.length>0?html` @@ -188,16 +192,16 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s <h3>${lll("label.suggestions")||"Suggestions"}</h3> </div> `:nothing} - ${this.suggestions.map((e=>html` + ${this.suggestions.map((t=>html` <div class="card-body"> - <h4>${e.label||e.identifier}</h4> - ${e.collection.mutations.map((t=>html` + <h4>${t.label||t.identifier}</h4> + ${t.collection.mutations.map((e=>html` <p> - <i>${t.mode}</i> - <code>${t.directive}: ${t.sources.join(" ")}</code> + <i>${e.mode}</i> + <code>${e.directive}: ${e.sources.join(" ")}</code> </p> `))} - <button class="btn btn-primary" @click=${()=>this.invokeMutateReportAction(t,e)}> + <button class="btn btn-primary" @click=${()=>this.invokeMutateReportAction(e,t)}> <typo3-backend-icon identifier="actions-check" size="small"></typo3-backend-icon> ${lll("button.apply")||"Apply"} </button> @@ -209,15 +213,15 @@ var CspReportAttribute,__decorate=function(t,e,i,o){var l,s=arguments.length,n=s <typo3-backend-icon identifier="actions-close" size="small"></typo3-backend-icon> ${lll("button.close")||"Close"} </button> - <button class="btn btn-default" @click=${()=>this.invokeMuteReportAction(t)}> + <button class="btn btn-default" @click=${()=>this.invokeMuteReportAction(e)}> <typo3-backend-icon identifier="actions-ban" size="small"></typo3-backend-icon> ${lll("button.mute")||"Mute"} </button> - <button class="btn btn-default" @click=${()=>this.invokeDeleteReportAction(t)}> + <button class="btn btn-default" @click=${()=>this.invokeDeleteReportAction(e)}> <typo3-backend-icon identifier="actions-delete" size="small"></typo3-backend-icon> ${lll("button.delete")||"Delete"} </button> </div> </div> </div> - `:nothing}`}selectReport(t){this.suggestions=[],null!==t&&this.selectedReport!==t?(this.selectedReport=t,this.invokeHandleReportAction(t).then((t=>this.suggestions=t))):this.selectedReport=null}selectScope(t){this.selectedScope=t,this.fetchReports()}fetchReports(){this.invokeFetchReportsAction().then((t=>this.reports=t))}filterReports(...t){t.includes(this.selectedReport?.uuid)&&(this.selectedReport=null),this.reports=this.reports.filter((e=>!t.includes(e.uuid)))}invokeFetchReportsAction(){return new AjaxRequest(this.controlUri).post({action:"fetchReports",scope:this.selectedScope||""}).then((t=>t.resolve("application/json")))}invokeHandleReportAction(t){return new AjaxRequest(this.controlUri).post({action:"handleReport",uuid:t.uuid}).then((t=>t.resolve("application/json")))}invokeMutateReportAction(t,e){const i=this.reports.filter((t=>t.mutationHashes.includes(e.hash))).map((t=>t.summary));return new AjaxRequest(this.controlUri).post({action:"mutateReport",scope:t.scope,hmac:e.hmac,suggestion:e,summaries:i}).then((t=>t.resolve("application/json"))).then((t=>this.filterReports(...t.uuids)))}invokeMuteReportAction(t){new AjaxRequest(this.controlUri).post({action:"muteReport",summaries:[t.summary]}).then((t=>t.resolve("application/json"))).then((t=>this.filterReports(...t.uuids)))}invokeDeleteReportAction(t){new AjaxRequest(this.controlUri).post({action:"deleteReport",summaries:[t.summary]}).then((t=>t.resolve("application/json"))).then((t=>this.filterReports(...t.uuids)))}invokeDeleteReportsAction(){new AjaxRequest(this.controlUri).post({action:"deleteReports",scope:this.selectedScope||""}).then((t=>t.resolve("application/json"))).then((()=>this.fetchReports())).then((()=>this.selectReport(null)))}shortenUri(t){if("inline"===t)return t;try{return new URL(t).hostname}catch(e){return t}}};__decorate([property({type:Array})],CspReports.prototype,"scopes",void 0),__decorate([property({type:String})],CspReports.prototype,"controlUri",void 0),__decorate([state()],CspReports.prototype,"selectedScope",void 0),__decorate([state()],CspReports.prototype,"reports",void 0),__decorate([state()],CspReports.prototype,"selectedReport",void 0),__decorate([state()],CspReports.prototype,"suggestions",void 0),CspReports=__decorate([customElement("typo3-backend-security-csp-reports")],CspReports);export{CspReports}; \ No newline at end of file + `:nothing}`}renderCodeLocation(e){if(!e.details.lineNumber)return nothing;const t=[e.details.lineNumber];return e.details.columnNumber&&t.push(e.details.columnNumber),html`(${t.join(":")})`}selectReport(e){this.suggestions=[],null!==e&&this.selectedReport!==e?(this.selectedReport=e,this.invokeHandleReportAction(e).then((e=>this.suggestions=e))):this.selectedReport=null}selectScope(e){this.selectedScope=e,this.fetchReports()}fetchReports(){this.invokeFetchReportsAction().then((e=>this.reports=e))}filterReports(...e){e.includes(this.selectedReport?.uuid)&&(this.selectedReport=null),this.reports=this.reports.filter((t=>!e.includes(t.uuid)))}invokeFetchReportsAction(){return new AjaxRequest(this.controlUri).post({action:"fetchReports",scope:this.selectedScope||""}).then((e=>e.resolve("application/json")))}invokeHandleReportAction(e){return new AjaxRequest(this.controlUri).post({action:"handleReport",uuid:e.uuid}).then((e=>e.resolve("application/json")))}invokeMutateReportAction(e,t){const i=this.reports.filter((e=>e.mutationHashes.includes(t.hash))).map((e=>e.summary));return new AjaxRequest(this.controlUri).post({action:"mutateReport",scope:e.scope,hmac:t.hmac,suggestion:t,summaries:i}).then((e=>e.resolve("application/json"))).then((e=>this.filterReports(...e.uuids)))}invokeMuteReportAction(e){new AjaxRequest(this.controlUri).post({action:"muteReport",summaries:[e.summary]}).then((e=>e.resolve("application/json"))).then((e=>this.filterReports(...e.uuids)))}invokeDeleteReportAction(e){new AjaxRequest(this.controlUri).post({action:"deleteReport",summaries:[e.summary]}).then((e=>e.resolve("application/json"))).then((e=>this.filterReports(...e.uuids)))}invokeDeleteReportsAction(){new AjaxRequest(this.controlUri).post({action:"deleteReports",scope:this.selectedScope||""}).then((e=>e.resolve("application/json"))).then((()=>this.fetchReports())).then((()=>this.selectReport(null)))}shortenUri(e){if("inline"===e)return e;try{return new URL(e).hostname}catch(t){return e}}};__decorate([property({type:Array})],CspReports.prototype,"scopes",void 0),__decorate([property({type:String})],CspReports.prototype,"controlUri",void 0),__decorate([state()],CspReports.prototype,"selectedScope",void 0),__decorate([state()],CspReports.prototype,"reports",void 0),__decorate([state()],CspReports.prototype,"selectedReport",void 0),__decorate([state()],CspReports.prototype,"suggestions",void 0),CspReports=__decorate([customElement("typo3-backend-security-csp-reports")],CspReports);export{CspReports}; \ No newline at end of file diff --git a/typo3/sysext/core/Classes/Middleware/AbstractContentSecurityPolicyReporter.php b/typo3/sysext/core/Classes/Middleware/AbstractContentSecurityPolicyReporter.php index 5db43da5e835..f190e28108f7 100644 --- a/typo3/sysext/core/Classes/Middleware/AbstractContentSecurityPolicyReporter.php +++ b/typo3/sysext/core/Classes/Middleware/AbstractContentSecurityPolicyReporter.php @@ -22,6 +22,7 @@ use Psr\Http\Server\MiddlewareInterface; use TYPO3\CMS\Core\Http\Uri; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\PolicyProvider; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportDetails; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportRepository; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\ReportStatus; use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Scope; @@ -53,8 +54,9 @@ abstract class AbstractContentSecurityPolicyReporter implements MiddlewareInterf 'agent' => $normalizedParams->getHttpUserAgent(), ]; $requestTime = (int)($request->getQueryParams()['requestTime'] ?? 0); - $details = json_decode($payload, true)['csp-report'] ?? []; - $details = $this->anonymizeDetails($details); + $originalDetails = json_decode($payload, true)['csp-report'] ?? []; + $originalDetails = $this->anonymizeDetails($originalDetails); + $details = new ReportDetails($originalDetails); $summary = $this->generateReportSummary($scope, $details); $report = new Report( $scope, @@ -67,7 +69,7 @@ abstract class AbstractContentSecurityPolicyReporter implements MiddlewareInterf $this->reportRepository->add($report); } - protected function generateReportSummary(Scope $scope, array $details): string + protected function generateReportSummary(Scope $scope, ReportDetails $details): string { return GeneralUtility::hmac( json_encode([ diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php index 1a472376b78a..d2ef14b20728 100644 --- a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Processing/HandlerTrait.php @@ -27,7 +27,7 @@ trait HandlerTrait private function resolveBlockedUri(Report $report): ?UriInterface { try { - return new Uri($report?->details['blockedUri'] ?? ''); + return new Uri($report?->details['blocked-uri'] ?? ''); } catch (\InvalidArgumentException) { return null; } @@ -39,6 +39,6 @@ trait HandlerTrait */ private function resolveEffectiveDirective(Report $report): ?Directive { - return Directive::tryFrom($report?->details['effectiveDirective'] ?? ''); + return Directive::tryFrom($report?->details['effective-directive'] ?? ''); } } diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/Report.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/Report.php index faca9db3bcc1..13e3223cda95 100644 --- a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/Report.php +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/Report.php @@ -29,29 +29,16 @@ class Report implements \JsonSerializable public readonly \DateTimeImmutable $created; public readonly \DateTimeImmutable $changed; - protected static function toCamelCase(string $value): string - { - return lcfirst(str_replace('-', '', ucwords($value, '-'))); - } - public static function fromArray(array $array): static { $meta = json_decode($array['meta'] ?? '', true, 16, JSON_THROW_ON_ERROR); - $meta = array_combine( - array_map(self::toCamelCase(...), array_keys($meta)), - array_values($meta) - ); $details = json_decode($array['details'] ?? '', true, 16, JSON_THROW_ON_ERROR); - $details = array_combine( - array_map(self::toCamelCase(...), array_keys($details)), - array_values($details) - ); return new static( Scope::from($array['scope'] ?? ''), ReportStatus::from($array['status'] ?? 0), $array['request_time'] ?? 0, $meta ?: [], - $details ?: [], + new ReportDetails($details ?: []), $array['summary'] ?? '', UuidV4::fromString($array['uuid'] ?? ''), new \DateTimeImmutable('@' . ($array['created'] ?? '0')), @@ -64,7 +51,7 @@ class Report implements \JsonSerializable public readonly ReportStatus $status, public readonly int $requestTime, public readonly array $meta, - public readonly array $details, + public readonly ReportDetails $details, public readonly string $summary = '', UuidV4 $uuid = null, \DateTimeImmutable $created = null, @@ -100,7 +87,7 @@ class Report implements \JsonSerializable 'scope' => (string)$this->scope, 'request_time' => $this->requestTime, 'meta' => json_encode($this->meta), - 'details' => json_encode($this->details), + 'details' => json_encode($this->details->getArrayCopy()), 'summary' => $this->summary, ]; } diff --git a/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php new file mode 100644 index 000000000000..3b619d9cd996 --- /dev/null +++ b/typo3/sysext/core/Classes/Security/ContentSecurityPolicy/Reporting/ReportDetails.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting; + +/** + * @internal + */ +class ReportDetails extends \ArrayObject implements \JsonSerializable +{ + public function __construct(array $array) + { + if (!empty($array['violated-directive']) && !isset($array['effective-directive'])) { + $array['effective-directive'] = $array['violated-directive']; + } + parent::__construct($array); + } + + public function jsonSerialize(): array + { + $details = $this->getArrayCopy(); + return array_combine( + array_map(self::toCamelCase(...), array_keys($details)), + array_values($details) + ); + } + + protected static function toCamelCase(string $value): string + { + return lcfirst(str_replace('-', '', ucwords($value, '-'))); + } +} diff --git a/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Reporting/ReportTest.php b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Reporting/ReportTest.php new file mode 100644 index 000000000000..3d2bc27428cb --- /dev/null +++ b/typo3/sysext/core/Tests/Unit/Security/ContentSecurityPolicy/Reporting/ReportTest.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Unit\Security\ContentSecurityPolicy\Reporting; + +use Symfony\Component\Uid\UuidV4; +use TYPO3\CMS\Core\Security\ContentSecurityPolicy\Reporting\Report; +use TYPO3\TestingFramework\Core\Unit\UnitTestCase; + +final class ReportTest extends UnitTestCase +{ + /** + * @test + */ + public function effectiveDirectiveIsTakenFromViolatedDirective(): void + { + $report = Report::fromArray([ + 'scope' => 'backend', + 'meta' => json_encode([]), + 'details' => json_encode([ + 'document-uri' => 'https://example.org/', + 'violated-directive' => 'script-src', + ]), + 'uuid' => $this->createUuidString(), + ]); + self::assertSame('script-src', $report->details['effective-directive']); + } + + /** + * @test + */ + public function toArrayUsesNativeDetailKeys(): void + { + $details = [ + 'document-uri' => 'https://example.org/', + 'effective-directive' => 'script-src', + ]; + $report = Report::fromArray([ + 'scope' => 'backend', + 'meta' => json_encode([]), + 'details' => json_encode($details), + 'uuid' => $this->createUuidString(), + ]); + self::assertSame($details, json_decode($report->toArray()['details'], true)); + } + + /** + * @test + */ + public function jsonEncodeUsesCamelCasedDetailKeys(): void + { + $details = [ + 'document-uri' => 'https://example.org/', + 'effective-directive' => 'script-src', + ]; + $report = Report::fromArray([ + 'scope' => 'backend', + 'meta' => json_encode([]), + 'details' => json_encode($details), + 'uuid' => $this->createUuidString(), + ]); + $expectation = [ + 'documentUri' => 'https://example.org/', + 'effectiveDirective' => 'script-src', + ]; + self::assertSame($expectation, json_decode(json_encode($report), true)['details']); + } + + private function createUuidString(): string + { + return (string)(new UuidV4()); + } +} -- GitLab