MediaWiki master
ApiCSPReport.php
Go to the documentation of this file.
1<?php
26use Psr\Log\LoggerInterface;
28
34class ApiCSPReport extends ApiBase {
35
36 private LoggerInterface $log;
37
41 private const MAX_POST_SIZE = 8192;
42
46 public function execute() {
47 $reportOnly = $this->getParameter( 'reportonly' );
48 $logname = $reportOnly ? 'csp-report-only' : 'csp';
49 $this->log = LoggerFactory::getInstance( $logname );
50 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
51
52 $this->verifyPostBodyOk();
53 $report = $this->getReport();
54 $flags = $this->getFlags( $report, $userAgent );
55
56 $warningText = $this->generateLogLine( $flags, $report );
57 $this->logReport( $flags, $warningText, [
58 // XXX Is it ok to put untrusted data into log??
59 'csp-report' => $report,
60 'method' => __METHOD__,
61 'user_id' => $this->getUser()->getId() ?: 'logged-out',
62 'user-agent' => $userAgent,
63 'source' => $this->getParameter( 'source' ),
64 ] );
65 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
66 }
67
74 private function logReport( $flags, $logLine, $context ) {
75 if ( in_array( 'false-positive', $flags ) ) {
76 // These reports probably don't matter much
77 $this->log->debug( $logLine, $context );
78 } else {
79 // Normal report.
80 $this->log->warning( $logLine, $context );
81 }
82 }
83
91 private function getFlags( $report, $userAgent ) {
92 $reportOnly = $this->getParameter( 'reportonly' );
93 $source = $this->getParameter( 'source' );
94 $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
95
96 $flags = [];
97 if ( $source !== 'internal' ) {
98 $flags[] = 'source=' . $source;
99 }
100 if ( $reportOnly ) {
101 $flags[] = 'report-only';
102 }
103
104 if (
105 (
106 ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
107 $report['blocked-uri'] === "self"
108 ) ||
109 (
110 isset( $report['blocked-uri'] ) &&
111 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
112 ) ||
113 (
114 isset( $report['source-file'] ) &&
115 $this->matchUrlPattern( $report['source-file'], $falsePositives )
116 )
117 ) {
118 // False positive due to:
119 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
120
121 $flags[] = 'false-positive';
122 }
123 return $flags;
124 }
125
131 private function matchUrlPattern( $url, array $patterns ) {
132 if ( isset( $patterns[ $url ] ) ) {
133 return true;
134 }
135
136 $bits = wfParseUrl( $url );
137 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
138 $bits['path'] = '';
139 $serverUrl = wfAssembleUrl( $bits );
140 if ( isset( $patterns[$serverUrl] ) ) {
141 // The origin of the url matches a pattern,
142 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
143 return true;
144 }
145 foreach ( $patterns as $pattern => $val ) {
146 // We only use this pattern if it ends in a slash, this prevents
147 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
148 // "https://good.com".
149 if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
150 // The pattern starts with the same as the url
151 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
152 return true;
153 }
154 }
155
156 return false;
157 }
158
162 private function verifyPostBodyOk() {
163 $req = $this->getRequest();
164 $contentType = $req->getHeader( 'content-type' );
165 if ( $contentType !== 'application/json'
166 && $contentType !== 'application/csp-report'
167 ) {
168 $this->error( 'wrongformat', __METHOD__ );
169 }
170 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
171 $this->error( 'toobig', __METHOD__ );
172 }
173 }
174
180 private function getReport() {
181 $postBody = $this->getRequest()->getRawInput();
182 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
183 // paranoia, already checked content-length earlier.
184 $this->error( 'toobig', __METHOD__ );
185 }
186 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
187 if ( !$status->isGood() ) {
188 $msg = $status->getErrors()[0]['message'];
189 if ( $msg instanceof Message ) {
190 $msg = $msg->getKey();
191 }
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
210 private function generateLogLine( $flags, $report ) {
211 $flagText = '';
212 if ( $flags ) {
213 $flagText = '[' . implode( ', ', $flags ) . ']';
214 }
215
216 $blockedOrigin = isset( $report['blocked-uri'] )
217 ? $this->originFromUrl( $report['blocked-uri'] )
218 : 'n/a';
219 $page = $report['document-uri'] ?? 'n/a';
220 $line = isset( $report['line-number'] )
221 ? ':' . $report['line-number']
222 : '';
223 return $flagText .
224 ' Received CSP report: <' . $blockedOrigin . '>' .
225 ' blocked from being loaded on <' . $page . '>' . $line;
226 }
227
232 private function originFromUrl( $url ) {
233 $bits = wfParseUrl( $url );
234 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
235 $bits['path'] = '';
236 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
237 return wfAssembleUrl( $bits );
238 }
239
247 private function error( $code, $method ) {
248 $this->log->info( 'Error reading CSP report: ' . $code, [
249 'method' => $method,
250 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
251 ] );
252 // Return 400 on error for user agents to display, e.g. to the console.
253 $this->dieWithError(
254 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
255 );
256 }
257
258 public function getAllowedParams() {
259 return [
260 'reportonly' => [
261 ParamValidator::PARAM_TYPE => 'boolean',
262 ParamValidator::PARAM_DEFAULT => false
263 ],
264 'source' => [
265 ParamValidator::PARAM_TYPE => 'string',
266 ParamValidator::PARAM_DEFAULT => 'internal',
267 ParamValidator::PARAM_REQUIRED => false
268 ]
269 ];
270 }
271
272 public function mustBePosted() {
273 return true;
274 }
275
280 public function isInternal() {
281 return true;
282 }
283
288 public function isReadMode() {
289 return false;
290 }
291
298 public function shouldCheckMaxLag() {
299 return false;
300 }
301}
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.
static parse( $value, $options=0)
Decodes a JSON string.
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