MediaWiki master
ApiCSPReport.php
Go to the documentation of this file.
1<?php
27use Psr\Log\LoggerInterface;
29
35class ApiCSPReport extends ApiBase {
36
37 private LoggerInterface $log;
38
42 private const MAX_POST_SIZE = 8192;
43
47 public function execute() {
48 $reportOnly = $this->getParameter( 'reportonly' );
49 $logname = $reportOnly ? 'csp-report-only' : 'csp';
50 $this->log = LoggerFactory::getInstance( $logname );
51 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
52
53 $this->verifyPostBodyOk();
54 $report = $this->getReport();
55 $flags = $this->getFlags( $report, $userAgent );
56
57 $warningText = $this->generateLogLine( $flags, $report );
58 $this->logReport( $flags, $warningText, [
59 // XXX Is it ok to put untrusted data into log??
60 'csp-report' => $report,
61 'method' => __METHOD__,
62 'user_id' => $this->getUser()->getId() ?: 'logged-out',
63 'user-agent' => $userAgent,
64 'source' => $this->getParameter( 'source' ),
65 ] );
66 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
67 }
68
75 private function logReport( $flags, $logLine, $context ) {
76 if ( in_array( 'false-positive', $flags ) ) {
77 // These reports probably don't matter much
78 $this->log->debug( $logLine, $context );
79 } else {
80 // Normal report.
81 $this->log->warning( $logLine, $context );
82 }
83 }
84
92 private function getFlags( $report, $userAgent ) {
93 $reportOnly = $this->getParameter( 'reportonly' );
94 $source = $this->getParameter( 'source' );
95 $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
96
97 $flags = [];
98 if ( $source !== 'internal' ) {
99 $flags[] = 'source=' . $source;
100 }
101 if ( $reportOnly ) {
102 $flags[] = 'report-only';
103 }
104
105 if (
106 (
107 ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
108 $report['blocked-uri'] === "self"
109 ) ||
110 (
111 isset( $report['blocked-uri'] ) &&
112 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
113 ) ||
114 (
115 isset( $report['source-file'] ) &&
116 $this->matchUrlPattern( $report['source-file'], $falsePositives )
117 )
118 ) {
119 // False positive due to:
120 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
121
122 $flags[] = 'false-positive';
123 }
124 return $flags;
125 }
126
132 private function matchUrlPattern( $url, array $patterns ) {
133 if ( isset( $patterns[ $url ] ) ) {
134 return true;
135 }
136
137 $bits = wfParseUrl( $url );
138 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
139 $bits['path'] = '';
140 $serverUrl = wfAssembleUrl( $bits );
141 if ( isset( $patterns[$serverUrl] ) ) {
142 // The origin of the url matches a pattern,
143 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
144 return true;
145 }
146 foreach ( $patterns as $pattern => $val ) {
147 // We only use this pattern if it ends in a slash, this prevents
148 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
149 // "https://good.com".
150 if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
151 // The pattern starts with the same as the url
152 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
153 return true;
154 }
155 }
156
157 return false;
158 }
159
163 private function verifyPostBodyOk() {
164 $req = $this->getRequest();
165 $contentType = $req->getHeader( 'content-type' );
166 if ( $contentType !== 'application/json'
167 && $contentType !== 'application/csp-report'
168 ) {
169 $this->error( 'wrongformat', __METHOD__ );
170 }
171 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
172 $this->error( 'toobig', __METHOD__ );
173 }
174 }
175
181 private function getReport() {
182 $postBody = $this->getRequest()->getRawInput();
183 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
184 // paranoia, already checked content-length earlier.
185 $this->error( 'toobig', __METHOD__ );
186 }
187 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
188 if ( !$status->isGood() ) {
189 $msg = $status->getErrors()[0]['message'];
190 if ( $msg instanceof Message ) {
191 $msg = $msg->getKey();
192 }
193 $this->error( $msg, __METHOD__ );
194 }
195
196 $report = $status->getValue();
197
198 if ( !isset( $report['csp-report'] ) ) {
199 $this->error( 'missingkey', __METHOD__ );
200 }
201 return $report['csp-report'];
202 }
203
211 private function generateLogLine( $flags, $report ) {
212 $flagText = '';
213 if ( $flags ) {
214 $flagText = '[' . implode( ', ', $flags ) . ']';
215 }
216
217 $blockedOrigin = isset( $report['blocked-uri'] )
218 ? $this->originFromUrl( $report['blocked-uri'] )
219 : 'n/a';
220 $page = $report['document-uri'] ?? 'n/a';
221 $line = isset( $report['line-number'] )
222 ? ':' . $report['line-number']
223 : '';
224 return $flagText .
225 ' Received CSP report: <' . $blockedOrigin . '>' .
226 ' blocked from being loaded on <' . $page . '>' . $line;
227 }
228
233 private function originFromUrl( $url ) {
234 $bits = wfParseUrl( $url );
235 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
236 $bits['path'] = '';
237 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
238 return wfAssembleUrl( $bits );
239 }
240
248 private function error( $code, $method ) {
249 $this->log->info( 'Error reading CSP report: ' . $code, [
250 'method' => $method,
251 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
252 ] );
253 // Return 400 on error for user agents to display, e.g. to the console.
254 $this->dieWithError(
255 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
256 );
257 }
258
259 public function getAllowedParams() {
260 return [
261 'reportonly' => [
262 ParamValidator::PARAM_TYPE => 'boolean',
263 ParamValidator::PARAM_DEFAULT => false
264 ],
265 'source' => [
266 ParamValidator::PARAM_TYPE => 'string',
267 ParamValidator::PARAM_DEFAULT => 'internal',
268 ParamValidator::PARAM_REQUIRED => false
269 ]
270 ];
271 }
272
273 public function mustBePosted() {
274 return true;
275 }
276
281 public function isInternal() {
282 return true;
283 }
284
289 public function isReadMode() {
290 return false;
291 }
292
299 public function shouldCheckMaxLag() {
300 return false;
301 }
302}
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
wfAssembleUrl( $urlParts)
This function will reassemble a URL parsed with wfParseURL.
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:64
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1542
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:942
getResult()
Get the result object.
Definition ApiBase.php:680
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:541
Api module to receive and log CSP violation reports.
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
execute()
Logs a content-security-policy violation report from web browser.
shouldCheckMaxLag()
Doesn't touch db, so max lag should be rather irrelevant.
isReadMode()
Even if you don't have read rights, we still want your report.
mustBePosted()
Indicates whether this module must be called with a POST request.
isInternal()
Mark as internal.
JSON formatter wrapper class.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
Handle sending Content-Security-Policy headers.
Service for formatting and validating API parameters.
$source