MediaWiki  master
ApiCSPReport.php
Go to the documentation of this file.
1 <?php
25 use Psr\Log\LoggerInterface;
27 
33 class ApiCSPReport extends ApiBase {
34 
36  private $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  (
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 ( substr( $pattern, -1 ) === '/' && strpos( $url, $pattern ) === 0 ) {
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  $warningText = $flagText .
224  ' Received CSP report: <' . $blockedOrigin . '>' .
225  ' blocked from being loaded on <' . $page . '>' . $line;
226  return $warningText;
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  $serverUrl = wfAssembleUrl( $bits );
238  // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
239  return $serverUrl;
240  }
241 
249  private function error( $code, $method ) {
250  $this->log->info( 'Error reading CSP report: ' . $code, [
251  'method' => $method,
252  'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
253  ] );
254  // Return 400 on error for user agents to display, e.g. to the console.
255  $this->dieWithError(
256  [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
257  );
258  }
259 
260  public function getAllowedParams() {
261  return [
262  'reportonly' => [
263  ParamValidator::PARAM_TYPE => 'boolean',
264  ParamValidator::PARAM_DEFAULT => false
265  ],
266  'source' => [
267  ParamValidator::PARAM_TYPE => 'string',
268  ParamValidator::PARAM_DEFAULT => 'internal',
269  ParamValidator::PARAM_REQUIRED => false
270  ]
271  ];
272  }
273 
274  public function mustBePosted() {
275  return true;
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.
This abstract class implements many basic API functions, and is the base of all API classes.
Definition: ApiBase.php:56
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition: ApiBase.php:1458
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition: ApiBase.php:886
getResult()
Get the result object.
Definition: ApiBase.php:629
getModuleName()
Get the name of the module being executed by this instance.
Definition: ApiBase.php:498
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 falsePositiveBrowser( $ua)
Does this browser give false positive reports?
static parse( $value, $options=0)
Decodes a JSON string.
Definition: FormatJson.php:160
const FORCE_ASSOC
If set, treat JSON objects '{...}' as associative arrays.
Definition: FormatJson.php:63
PSR-3 logger instance factory.
A class containing constants representing the names of configuration variables.
The Message class deals with fetching and processing of interface message into a variety of formats.
Definition: Message.php:141
Service for formatting and validating API parameters.
$line
Definition: mcc.php:119
$source