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