MediaWiki REL1_34
ApiCSPReport.php
Go to the documentation of this file.
1<?php
24
30class ApiCSPReport extends ApiBase {
31
32 private $log;
33
37 const MAX_POST_SIZE = 8192;
38
42 public function execute() {
43 $reportOnly = $this->getParameter( 'reportonly' );
44 $logname = $reportOnly ? 'csp-report-only' : 'csp';
45 $this->log = LoggerFactory::getInstance( $logname );
46 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
47
48 $this->verifyPostBodyOk();
49 $report = $this->getReport();
50 $flags = $this->getFlags( $report, $userAgent );
51
52 $warningText = $this->generateLogLine( $flags, $report );
53 $this->logReport( $flags, $warningText, [
54 // XXX Is it ok to put untrusted data into log??
55 'csp-report' => $report,
56 'method' => __METHOD__,
57 'user_id' => $this->getUser()->getId() ?: 'logged-out',
58 'user-agent' => $userAgent,
59 'source' => $this->getParameter( 'source' ),
60 ] );
61 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
62 }
63
70 private function logReport( $flags, $logLine, $context ) {
71 if ( in_array( 'false-positive', $flags ) ) {
72 // These reports probably don't matter much
73 $this->log->debug( $logLine, $context );
74 } else {
75 // Normal report.
76 $this->log->warning( $logLine, $context );
77 }
78 }
79
87 private function getFlags( $report, $userAgent ) {
88 $reportOnly = $this->getParameter( 'reportonly' );
89 $source = $this->getParameter( 'source' );
90 $falsePositives = $this->getConfig()->get( 'CSPFalsePositiveUrls' );
91
92 $flags = [];
93 if ( $source !== 'internal' ) {
94 $flags[] = 'source=' . $source;
95 }
96 if ( $reportOnly ) {
97 $flags[] = 'report-only';
98 }
99
100 if (
101 (
103 $report['blocked-uri'] === "self"
104 ) ||
105 (
106 isset( $report['blocked-uri'] ) &&
107 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
108 ) ||
109 (
110 isset( $report['source-file'] ) &&
111 $this->matchUrlPattern( $report['source-file'], $falsePositives )
112 )
113 ) {
114 // False positive due to:
115 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
116
117 $flags[] = 'false-positive';
118 }
119 return $flags;
120 }
121
127 private function matchUrlPattern( $url, array $patterns ) {
128 if ( isset( $patterns[ $url ] ) ) {
129 return true;
130 }
131
132 $bits = wfParseUrl( $url );
133 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
134 $bits['path'] = '';
135 $serverUrl = wfAssembleUrl( $bits );
136 if ( isset( $patterns[$serverUrl] ) ) {
137 // The origin of the url matches a pattern,
138 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
139 return true;
140 }
141 foreach ( $patterns as $pattern => $val ) {
142 // We only use this pattern if it ends in a slash, this prevents
143 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
144 // "https://good.com".
145 if ( substr( $pattern, -1 ) === '/' && strpos( $url, $pattern ) === 0 ) {
146 // The pattern starts with the same as the url
147 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
148 return true;
149 }
150 }
151
152 return false;
153 }
154
158 private function verifyPostBodyOk() {
159 $req = $this->getRequest();
160 $contentType = $req->getHeader( 'content-type' );
161 if ( $contentType !== 'application/json'
162 && $contentType !== 'application/csp-report'
163 ) {
164 $this->error( 'wrongformat', __METHOD__ );
165 }
166 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
167 $this->error( 'toobig', __METHOD__ );
168 }
169 }
170
176 private function getReport() {
177 $postBody = $this->getRequest()->getRawInput();
178 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
179 // paranoia, already checked content-length earlier.
180 $this->error( 'toobig', __METHOD__ );
181 }
182 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
183 if ( !$status->isGood() ) {
184 $msg = $status->getErrors()[0]['message'];
185 if ( $msg instanceof Message ) {
186 $msg = $msg->getKey();
187 }
188 $this->error( $msg, __METHOD__ );
189 }
190
191 $report = $status->getValue();
192
193 if ( !isset( $report['csp-report'] ) ) {
194 $this->error( 'missingkey', __METHOD__ );
195 }
196 return $report['csp-report'];
197 }
198
206 private function generateLogLine( $flags, $report ) {
207 $flagText = '';
208 if ( $flags ) {
209 $flagText = '[' . implode( ', ', $flags ) . ']';
210 }
211
212 $blockedOrigin = isset( $report['blocked-uri'] )
213 ? $this->originFromUrl( $report['blocked-uri'] )
214 : 'n/a';
215 $page = $report['document-uri'] ?? 'n/a';
216 $line = isset( $report['line-number'] )
217 ? ':' . $report['line-number']
218 : '';
219 $warningText = $flagText .
220 ' Received CSP report: <' . $blockedOrigin . '>' .
221 ' blocked from being loaded on <' . $page . '>' . $line;
222 return $warningText;
223 }
224
229 private function originFromUrl( $url ) {
230 $bits = wfParseUrl( $url );
231 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
232 $bits['path'] = '';
233 $serverUrl = wfAssembleUrl( $bits );
234 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
235 return $serverUrl;
236 }
237
245 private function error( $code, $method ) {
246 $this->log->info( 'Error reading CSP report: ' . $code, [
247 'method' => $method,
248 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
249 ] );
250 // Return 400 on error for user agents to display, e.g. to the console.
251 $this->dieWithError(
252 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
253 );
254 }
255
256 public function getAllowedParams() {
257 return [
258 'reportonly' => [
259 ApiBase::PARAM_TYPE => 'boolean',
260 ApiBase::PARAM_DFLT => false
261 ],
262 'source' => [
263 ApiBase::PARAM_TYPE => 'string',
264 ApiBase::PARAM_DFLT => 'internal',
266 ]
267 ];
268 }
269
270 public function mustBePosted() {
271 return true;
272 }
273
274 public function isWriteMode() {
275 return false;
276 }
277
282 public function isInternal() {
283 return true;
284 }
285
290 public function isReadMode() {
291 return false;
292 }
293
300 public function shouldCheckMaxLag() {
301 return false;
302 }
303}
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
wfEscapeWikiText( $text)
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.
$line
Definition cdb.php:59
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:42
const PARAM_REQUIRED
(boolean) Is the parameter required?
Definition ApiBase.php:118
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:876
dieWithError( $msg, $code=null, $data=null, $httpCode=null)
Abort execution with an error.
Definition ApiBase.php:2014
const PARAM_TYPE
(string|string[]) Either an array of allowed value strings, or a string type as described below.
Definition ApiBase.php:94
const PARAM_DFLT
(null|boolean|integer|string) Default value of the parameter.
Definition ApiBase.php:55
getResult()
Get the result object.
Definition ApiBase.php:640
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:520
Api module to receive and log CSP violation reports.
getReport()
Get the report from post body and turn into associative array.
matchUrlPattern( $url, array $patterns)
error( $code, $method)
Stop processing the request, and output/log an error.
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 irrelavent.
isReadMode()
Even if you don't have read rights, we still want your report.
logReport( $flags, $logLine, $context)
Log CSP report, with a different severity depending on $flags.
originFromUrl( $url)
getFlags( $report, $userAgent)
Get extra notes about the report.
const MAX_POST_SIZE
These reports should be small.
mustBePosted()
Indicates whether this module must be called with a POST request.
isWriteMode()
Indicates whether this module requires write mode.
isInternal()
Mark as internal.
generateLogLine( $flags, $report)
Get text of log line.
verifyPostBodyOk()
Output an api error if post body is obviously not OK.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
IContextSource $context
PSR-3 logger instance factory.
The Message class provides methods which fulfil two basic services:
Definition Message.php:162
$source