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->getMessages()[0]->getKey();
190 $this->error( $msg, __METHOD__ );
191 }
192
193 $report = $status->getValue();
194
195 if ( !isset( $report['csp-report'] ) ) {
196 $this->error( 'missingkey', __METHOD__ );
197 }
198 return $report['csp-report'];
199 }
200
208 private function generateLogLine( $flags, $report ) {
209 $flagText = '';
210 if ( $flags ) {
211 $flagText = '[' . implode( ', ', $flags ) . ']';
212 }
213
214 $blockedOrigin = isset( $report['blocked-uri'] )
215 ? $this->originFromUrl( $report['blocked-uri'] )
216 : 'n/a';
217 $page = $report['document-uri'] ?? 'n/a';
218 $line = isset( $report['line-number'] )
219 ? ':' . $report['line-number']
220 : '';
221 return $flagText .
222 ' Received CSP report: <' . $blockedOrigin . '>' .
223 ' blocked from being loaded on <' . $page . '>' . $line;
224 }
225
230 private function originFromUrl( $url ) {
231 $bits = wfParseUrl( $url );
232 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
233 $bits['path'] = '';
234 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
235 return wfAssembleUrl( $bits );
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 ParamValidator::PARAM_TYPE => 'boolean',
260 ParamValidator::PARAM_DEFAULT => false
261 ],
262 'source' => [
263 ParamValidator::PARAM_TYPE => 'string',
264 ParamValidator::PARAM_DEFAULT => 'internal',
265 ParamValidator::PARAM_REQUIRED => false
266 ]
267 ];
268 }
269
270 public function mustBePosted() {
271 return true;
272 }
273
278 public function isInternal() {
279 return true;
280 }
281
286 public function isReadMode() {
287 return false;
288 }
289
296 public function shouldCheckMaxLag() {
297 return false;
298 }
299}
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:65
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1540
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:943
getResult()
Get the result object.
Definition ApiBase.php:681
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:542
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