MediaWiki  master
ApiCSPReport.php
Go to the documentation of this file.
1 <?php
24 use Psr\Log\LoggerInterface;
25 
31 class ApiCSPReport extends ApiBase {
32 
34  private $log;
35 
39  private const MAX_POST_SIZE = 8192;
40 
44  public function execute() {
45  $reportOnly = $this->getParameter( 'reportonly' );
46  $logname = $reportOnly ? 'csp-report-only' : 'csp';
47  $this->log = LoggerFactory::getInstance( $logname );
48  $userAgent = $this->getRequest()->getHeader( 'user-agent' );
49 
50  $this->verifyPostBodyOk();
51  $report = $this->getReport();
52  $flags = $this->getFlags( $report, $userAgent );
53 
54  $warningText = $this->generateLogLine( $flags, $report );
55  $this->logReport( $flags, $warningText, [
56  // XXX Is it ok to put untrusted data into log??
57  'csp-report' => $report,
58  'method' => __METHOD__,
59  'user_id' => $this->getUser()->getId() ?: 'logged-out',
60  'user-agent' => $userAgent,
61  'source' => $this->getParameter( 'source' ),
62  ] );
63  $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
64  }
65 
72  private function logReport( $flags, $logLine, $context ) {
73  if ( in_array( 'false-positive', $flags ) ) {
74  // These reports probably don't matter much
75  $this->log->debug( $logLine, $context );
76  } else {
77  // Normal report.
78  $this->log->warning( $logLine, $context );
79  }
80  }
81 
89  private function getFlags( $report, $userAgent ) {
90  $reportOnly = $this->getParameter( 'reportonly' );
91  $source = $this->getParameter( 'source' );
92  $falsePositives = $this->getConfig()->get( 'CSPFalsePositiveUrls' );
93 
94  $flags = [];
95  if ( $source !== 'internal' ) {
96  $flags[] = 'source=' . $source;
97  }
98  if ( $reportOnly ) {
99  $flags[] = 'report-only';
100  }
101 
102  if (
103  (
105  $report['blocked-uri'] === "self"
106  ) ||
107  (
108  isset( $report['blocked-uri'] ) &&
109  $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
110  ) ||
111  (
112  isset( $report['source-file'] ) &&
113  $this->matchUrlPattern( $report['source-file'], $falsePositives )
114  )
115  ) {
116  // False positive due to:
117  // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
118 
119  $flags[] = 'false-positive';
120  }
121  return $flags;
122  }
123 
129  private function matchUrlPattern( $url, array $patterns ) {
130  if ( isset( $patterns[ $url ] ) ) {
131  return true;
132  }
133 
134  $bits = wfParseUrl( $url );
135  unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
136  $bits['path'] = '';
137  $serverUrl = wfAssembleUrl( $bits );
138  if ( isset( $patterns[$serverUrl] ) ) {
139  // The origin of the url matches a pattern,
140  // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
141  return true;
142  }
143  foreach ( $patterns as $pattern => $val ) {
144  // We only use this pattern if it ends in a slash, this prevents
145  // "/foos" from matching "/foo", and "https://good.combo.bad" matching
146  // "https://good.com".
147  if ( substr( $pattern, -1 ) === '/' && strpos( $url, $pattern ) === 0 ) {
148  // The pattern starts with the same as the url
149  // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
150  return true;
151  }
152  }
153 
154  return false;
155  }
156 
160  private function verifyPostBodyOk() {
161  $req = $this->getRequest();
162  $contentType = $req->getHeader( 'content-type' );
163  if ( $contentType !== 'application/json'
164  && $contentType !== 'application/csp-report'
165  ) {
166  $this->error( 'wrongformat', __METHOD__ );
167  }
168  if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
169  $this->error( 'toobig', __METHOD__ );
170  }
171  }
172 
178  private function getReport() {
179  $postBody = $this->getRequest()->getRawInput();
180  if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
181  // paranoia, already checked content-length earlier.
182  $this->error( 'toobig', __METHOD__ );
183  }
184  $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
185  if ( !$status->isGood() ) {
186  $msg = $status->getErrors()[0]['message'];
187  if ( $msg instanceof Message ) {
188  $msg = $msg->getKey();
189  }
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  $warningText = $flagText .
222  ' Received CSP report: <' . $blockedOrigin . '>' .
223  ' blocked from being loaded on <' . $page . '>' . $line;
224  return $warningText;
225  }
226 
231  private function originFromUrl( $url ) {
232  $bits = wfParseUrl( $url );
233  unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
234  $bits['path'] = '';
235  $serverUrl = wfAssembleUrl( $bits );
236  // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
237  return $serverUrl;
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  ApiBase::PARAM_TYPE => 'boolean',
262  ApiBase::PARAM_DFLT => false
263  ],
264  'source' => [
265  ApiBase::PARAM_TYPE => 'string',
266  ApiBase::PARAM_DFLT => 'internal',
267  ApiBase::PARAM_REQUIRED => false
268  ]
269  ];
270  }
271 
272  public function mustBePosted() {
273  return true;
274  }
275 
276  public function isWriteMode() {
277  return false;
278  }
279 
284  public function isInternal() {
285  return true;
286  }
287 
292  public function isReadMode() {
293  return false;
294  }
295 
302  public function shouldCheckMaxLag() {
303  return false;
304  }
305 }
ContextSource\$context
IContextSource $context
Definition: ContextSource.php:34
ContextSource\getConfig
getConfig()
Definition: ContextSource.php:67
ApiCSPReport\matchUrlPattern
matchUrlPattern( $url, array $patterns)
Definition: ApiCSPReport.php:129
ApiCSPReport\getAllowedParams
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
Definition: ApiCSPReport.php:258
ApiCSPReport\isWriteMode
isWriteMode()
Indicates whether this module requires write mode.
Definition: ApiCSPReport.php:276
ApiBase\PARAM_REQUIRED
const PARAM_REQUIRED
(boolean) Inverse of IntegerDef::PARAM_IGNORE_RANGE
Definition: ApiBase.php:77
ApiBase\dieWithError
dieWithError( $msg, $code=null, $data=null, $httpCode=null)
Abort execution with an error.
Definition: ApiBase.php:1382
ApiBase\PARAM_TYPE
const PARAM_TYPE
(boolean) Inverse of IntegerDef::PARAM_IGNORE_RANGE
Definition: ApiBase.php:71
ApiBase\getResult
getResult()
Get the result object.
Definition: ApiBase.php:565
ApiCSPReport\getFlags
getFlags( $report, $userAgent)
Get extra notes about the report.
Definition: ApiCSPReport.php:89
ApiCSPReport\mustBePosted
mustBePosted()
Indicates whether this module must be called with a POST request Stable to override.
Definition: ApiCSPReport.php:272
ContentSecurityPolicy\falsePositiveBrowser
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
Definition: ContentSecurityPolicy.php:502
ApiCSPReport\originFromUrl
originFromUrl( $url)
Definition: ApiCSPReport.php:231
ContextSource\getRequest
getRequest()
Definition: ContextSource.php:76
ApiCSPReport
Api module to receive and log CSP violation reports.
Definition: ApiCSPReport.php:31
ContextSource\getUser
getUser()
Stable to override.
Definition: ContextSource.php:131
ApiCSPReport\$log
LoggerInterface $log
Definition: ApiCSPReport.php:34
ApiBase
This abstract class implements many basic API functions, and is the base of all API classes.
Definition: ApiBase.php:52
wfParseUrl
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
Definition: GlobalFunctions.php:791
MediaWiki\Logger\LoggerFactory
PSR-3 logger instance factory.
Definition: LoggerFactory.php:45
ApiCSPReport\shouldCheckMaxLag
shouldCheckMaxLag()
Doesn't touch db, so max lag should be rather irrelavent.
Definition: ApiCSPReport.php:302
ApiCSPReport\isInternal
isInternal()
Mark as internal.
Definition: ApiCSPReport.php:284
ApiCSPReport\isReadMode
isReadMode()
Even if you don't have read rights, we still want your report.
Definition: ApiCSPReport.php:292
ApiCSPReport\getReport
getReport()
Get the report from post body and turn into associative array.
Definition: ApiCSPReport.php:178
FormatJson\FORCE_ASSOC
const FORCE_ASSOC
If set, treat JSON objects '{...}' as associative arrays.
Definition: FormatJson.php:63
ApiCSPReport\logReport
logReport( $flags, $logLine, $context)
Log CSP report, with a different severity depending on $flags.
Definition: ApiCSPReport.php:72
ApiCSPReport\execute
execute()
Logs a content-security-policy violation report from web browser.
Definition: ApiCSPReport.php:44
$line
$line
Definition: mcc.php:119
wfEscapeWikiText
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking,...
Definition: GlobalFunctions.php:1487
FormatJson\parse
static parse( $value, $options=0)
Decodes a JSON string.
Definition: FormatJson.php:188
ApiCSPReport\verifyPostBodyOk
verifyPostBodyOk()
Output an api error if post body is obviously not OK.
Definition: ApiCSPReport.php:160
ApiBase\PARAM_DFLT
const PARAM_DFLT
(boolean) Inverse of IntegerDef::PARAM_IGNORE_RANGE
Definition: ApiBase.php:69
ApiBase\getParameter
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition: ApiBase.php:837
ApiBase\getModuleName
getModuleName()
Get the name of the module being executed by this instance.
Definition: ApiBase.php:444
Message
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:161
$source
$source
Definition: mwdoc-filter.php:34
wfAssembleUrl
wfAssembleUrl( $urlParts)
This function will reassemble a URL parsed with wfParseURL.
Definition: GlobalFunctions.php:586
ApiCSPReport\error
error( $code, $method)
Stop processing the request, and output/log an error.
Definition: ApiCSPReport.php:247
ApiCSPReport\MAX_POST_SIZE
const MAX_POST_SIZE
These reports should be small.
Definition: ApiCSPReport.php:39
ApiCSPReport\generateLogLine
generateLogLine( $flags, $report)
Get text of log line.
Definition: ApiCSPReport.php:208