MediaWiki master
ApiCSPReport.php
Go to the documentation of this file.
1<?php
9namespace MediaWiki\Api;
10
16use Psr\Log\LoggerInterface;
18
24class ApiCSPReport extends ApiBase {
25
26 private LoggerInterface $log;
27
31 private const MAX_POST_SIZE = 8192;
32
33 public function __construct(
34 ApiMain $main,
35 string $action,
36 ) {
37 parent::__construct( $main, $action );
38 }
39
43 public function execute() {
44 $reportOnly = $this->getParameter( 'reportonly' );
45 $logname = $reportOnly ? 'csp-report-only' : 'csp';
46 $this->log = LoggerFactory::getInstance( $logname );
47 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
48
49 $this->verifyPostBodyOk();
50 $report = $this->getReport();
51 $flags = $this->getFlags( $report, $userAgent );
52
53 $this->logReport(
54 '[{flags}] Received CSP report: <{blockedOrigin}> blocked from being loaded on <{documentUri}>:{line}',
55 [
56 'flags' => implode( ', ', $flags ),
57 'blockedOrigin' => isset( $report['blocked-uri'] ) ?
58 $this->originFromUrl( $report['blocked-uri'] ) : 'n/a',
59 'documentUri' => $report['document-uri'] ?? 'n/a',
60 'line' => $report['line-number'] ?? '',
61 // XXX Is it ok to put untrusted data into log??
62 'csp-report' => $report,
63 'method' => __METHOD__,
64 'user_id' => $this->getUser()->getId() ?: 'logged-out',
65 'user-agent' => $userAgent,
66 'source' => $this->getParameter( 'source' ),
67 ],
68 $flags
69 );
70 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
71 }
72
79 private function logReport( $logLine, $context, $flags ) {
80 if ( in_array( 'false-positive', $flags ) ) {
81 // These reports probably don't matter much
82 $this->log->debug( $logLine, $context );
83 } else {
84 // Normal report.
85 $this->log->warning( $logLine, $context );
86 }
87 }
88
96 private function getFlags( $report, $userAgent ) {
97 $reportOnly = $this->getParameter( 'reportonly' );
98 $source = $this->getParameter( 'source' );
99 $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
100
101 $flags = [];
102 if ( $source !== 'internal' ) {
103 $flags[] = 'source=' . $source;
104 }
105 if ( $reportOnly ) {
106 $flags[] = 'report-only';
107 }
108
109 if (
110 (
111 ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
112 $report['blocked-uri'] === "self"
113 ) ||
114 (
115 isset( $report['blocked-uri'] ) &&
116 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
117 ) ||
118 (
119 isset( $report['source-file'] ) &&
120 $this->matchUrlPattern( $report['source-file'], $falsePositives )
121 )
122 ) {
123 // False positive due to:
124 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
125
126 $flags[] = 'false-positive';
127 }
128 return $flags;
129 }
130
136 private function matchUrlPattern( $url, array $patterns ) {
137 if ( isset( $patterns[ $url ] ) ) {
138 return true;
139 }
140
141 $serverUrl = $this->originFromUrl( $url );
142
143 if ( isset( $patterns[$serverUrl] ) ) {
144 // The origin of the url matches a pattern,
145 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
146 return true;
147 }
148 foreach ( $patterns as $pattern => $val ) {
149 // We only use this pattern if it ends in a slash, this prevents
150 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
151 // "https://good.com".
152 if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
153 // The pattern starts with the same as the url
154 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
155 return true;
156 }
157 }
158
159 return false;
160 }
161
165 private function verifyPostBodyOk() {
166 $req = $this->getRequest();
167 $contentType = $req->getHeader( 'content-type' );
168 if ( $contentType !== 'application/json'
169 && $contentType !== 'application/csp-report'
170 ) {
171 $this->error( 'wrongformat', __METHOD__ );
172 }
173 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
174 $this->error( 'toobig', __METHOD__ );
175 }
176 }
177
183 private function getReport() {
184 $postBody = $this->getRequest()->getRawInput();
185 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
186 // paranoia, already checked content-length earlier.
187 $this->error( 'toobig', __METHOD__ );
188 }
189 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
190 if ( !$status->isGood() ) {
191 $msg = $status->getMessages()[0]->getKey();
192 $this->error( $msg, __METHOD__ );
193 }
194
195 $report = $status->getValue();
196
197 if ( !isset( $report['csp-report'] ) ) {
198 $this->error( 'missingkey', __METHOD__ );
199 }
200 return $report['csp-report'];
201 }
202
213 private function originFromUrl( $url ) {
214 $bits = parse_url( $url );
215
216 if ( !$bits || !isset( $bits['scheme'] ) ) {
217 return $url;
218 }
219
220 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
221 $bits['path'] = '';
222
223 if ( !isset( $bits['delimiter'] ) ) {
224 $bits['delimiter'] = '://';
225 }
226
227 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
228 return UrlUtils::assemble( $bits );
229 }
230
237 private function error( $code, $method ): never {
238 $this->log->info( 'Error reading CSP report: ' . $code, [
239 'method' => $method,
240 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
241 ] );
242 // Return 400 on error for user agents to display, e.g. to the console.
243 $this->dieWithError(
244 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
245 );
246 }
247
249 public function getAllowedParams() {
250 return [
251 'reportonly' => [
252 ParamValidator::PARAM_TYPE => 'boolean',
253 ParamValidator::PARAM_DEFAULT => false
254 ],
255 'source' => [
256 ParamValidator::PARAM_TYPE => 'string',
257 ParamValidator::PARAM_DEFAULT => 'internal',
258 ParamValidator::PARAM_REQUIRED => false
259 ]
260 ];
261 }
262
264 public function mustBePosted() {
265 return true;
266 }
267
272 public function isInternal() {
273 return true;
274 }
275
280 public function isReadMode() {
281 return false;
282 }
283
290 public function shouldCheckMaxLag() {
291 return false;
292 }
293}
294
296class_alias( ApiCSPReport::class, 'ApiCSPReport' );
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:60
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1522
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:557
getResult()
Get the result object.
Definition ApiBase.php:696
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:958
Api module to receive and log CSP violation reports.
isInternal()
Mark as internal.
isReadMode()
Even if you don't have read rights, we still want your report.
__construct(ApiMain $main, string $action,)
shouldCheckMaxLag()
Doesn't touch db, so max lag should be rather irrelevant.
mustBePosted()
Indicates whether this module must be called with a POST request.Implementations of this method must ...
execute()
Logs a content-security-policy violation report from web browser.
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:66
JSON formatter wrapper class.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const CSPFalsePositiveUrls
Name constant for the CSPFalsePositiveUrls setting, for use with Config::get()
Handle sending Content-Security-Policy headers.
A service to expand, parse, and otherwise manipulate URLs.
Definition UrlUtils.php:16
Service for formatting and validating API parameters.
$source