26 private LoggerInterface $log;
31 private const MAX_POST_SIZE = 8192;
40 parent::__construct( $main, $action );
41 $this->urlUtils = $urlUtils;
49 $logname = $reportOnly ?
'csp-report-only' :
'csp';
50 $this->log = LoggerFactory::getInstance( $logname );
51 $userAgent = $this->
getRequest()->getHeader(
'user-agent' );
53 $this->verifyPostBodyOk();
54 $report = $this->getReport();
55 $flags = $this->getFlags( $report, $userAgent );
57 $warningText = $this->generateLogLine( $flags, $report );
58 $this->logReport( $flags, $warningText, [
60 'csp-report' => $report,
61 'method' => __METHOD__,
62 'user_id' => $this->
getUser()->getId() ?:
'logged-out',
63 'user-agent' => $userAgent,
75 private function logReport( $flags, $logLine, $context ) {
76 if ( in_array(
'false-positive', $flags ) ) {
78 $this->log->debug( $logLine, $context );
81 $this->log->warning( $logLine, $context );
92 private function getFlags( $report, $userAgent ) {
102 $flags[] =
'report-only';
107 ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
108 $report[
'blocked-uri'] ===
"self"
111 isset( $report[
'blocked-uri'] ) &&
112 $this->matchUrlPattern( $report[
'blocked-uri'], $falsePositives )
115 isset( $report[
'source-file'] ) &&
116 $this->matchUrlPattern( $report[
'source-file'], $falsePositives )
122 $flags[] =
'false-positive';
132 private function matchUrlPattern(
$url, array $patterns ) {
133 if ( isset( $patterns[
$url ] ) ) {
137 $bits = $this->urlUtils->parse(
$url );
142 unset( $bits[
'user'], $bits[
'pass'], $bits[
'query'], $bits[
'fragment'] );
144 $serverUrl = UrlUtils::assemble( $bits );
145 if ( isset( $patterns[$serverUrl] ) ) {
150 foreach ( $patterns as $pattern => $val ) {
154 if ( str_ends_with( $pattern,
'/' ) && str_starts_with(
$url, $pattern ) ) {
167 private function verifyPostBodyOk() {
169 $contentType = $req->getHeader(
'content-type' );
170 if ( $contentType !==
'application/json'
171 && $contentType !==
'application/csp-report'
173 $this->error(
'wrongformat', __METHOD__ );
175 if ( $req->getHeader(
'content-length' ) > self::MAX_POST_SIZE ) {
176 $this->error(
'toobig', __METHOD__ );
185 private function getReport() {
186 $postBody = $this->
getRequest()->getRawInput();
187 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
189 $this->error(
'toobig', __METHOD__ );
191 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
192 if ( !$status->isGood() ) {
193 $msg = $status->getMessages()[0]->getKey();
194 $this->error( $msg, __METHOD__ );
197 $report = $status->getValue();
199 if ( !isset( $report[
'csp-report'] ) ) {
200 $this->error(
'missingkey', __METHOD__ );
202 return $report[
'csp-report'];
212 private function generateLogLine( $flags, $report ) {
215 $flagText =
'[' . implode(
', ', $flags ) .
']';
218 $blockedOrigin = isset( $report[
'blocked-uri'] )
219 ? $this->originFromUrl( $report[
'blocked-uri'] )
221 $page = $report[
'document-uri'] ??
'n/a';
222 $line = isset( $report[
'line-number'] )
223 ?
':' . $report[
'line-number']
226 ' Received CSP report: <' . $blockedOrigin .
'>' .
227 ' blocked from being loaded on <' . $page .
'>' . $line;
234 private function originFromUrl(
$url ) {
235 $bits = $this->urlUtils->parse(
$url ) ?? [];
236 unset( $bits[
'user'], $bits[
'pass'], $bits[
'query'], $bits[
'fragment'] );
239 return UrlUtils::assemble( $bits );
248 private function error( $code, $method ): never {
249 $this->log->info(
'Error reading CSP report: ' . $code, [
251 'user-agent' => $this->
getRequest()->getHeader(
'user-agent' )
255 [
'apierror-csp-report',
wfEscapeWikiText( $code ) ],
'cspreport-' . $code, [], 400
263 ParamValidator::PARAM_TYPE =>
'boolean',
264 ParamValidator::PARAM_DEFAULT => false
267 ParamValidator::PARAM_TYPE =>
'string',
268 ParamValidator::PARAM_DEFAULT =>
'internal',
269 ParamValidator::PARAM_REQUIRED => false