MediaWiki  master
ApiCSPReport.php
Go to the documentation of this file.
1 <?php
24 
30 class 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',
265  ApiBase::PARAM_REQUIRED => false
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 }
const PARAM_TYPE
(string|string[]) Either an array of allowed value strings, or a string type as described below...
Definition: ApiBase.php:94
wfEscapeWikiText( $text)
Escapes the given text so that it may be output using addWikiText() without any linking, formatting, etc.
getResult()
Get the result object.
Definition: ApiBase.php:640
getFlags( $report, $userAgent)
Get extra notes about the report.
error( $code, $method)
Stop processing the request, and output/log an error.
The Message class provides methods which fulfil two basic services:
Definition: Message.php:162
const PARAM_DFLT
(null|boolean|integer|string) Default value of the parameter.
Definition: ApiBase.php:55
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
const PARAM_REQUIRED
(boolean) Is the parameter required?
Definition: ApiBase.php:118
matchUrlPattern( $url, array $patterns)
$source
dieWithError( $msg, $code=null, $data=null, $httpCode=null)
Abort execution with an error.
Definition: ApiBase.php:2005
isReadMode()
Even if you don&#39;t have read rights, we still want your report.
Api module to receive and log CSP violation reports.
isInternal()
Mark as internal.
IContextSource $context
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition: ApiBase.php:876
execute()
Logs a content-security-policy violation report from web browser.
const FORCE_ASSOC
If set, treat JSON objects &#39;{...}&#39; as associative arrays.
Definition: FormatJson.php:63
originFromUrl( $url)
getModuleName()
Get the name of the module being executed by this instance.
Definition: ApiBase.php:520
shouldCheckMaxLag()
Doesn&#39;t touch db, so max lag should be rather irrelavent.
getReport()
Get the report from post body and turn into associative array.
wfAssembleUrl( $urlParts)
This function will reassemble a URL parsed with wfParseURL.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
logReport( $flags, $logLine, $context)
Log CSP report, with a different severity depending on $flags.
const MAX_POST_SIZE
These reports should be small.
generateLogLine( $flags, $report)
Get text of log line.
$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
static parse( $value, $options=0)
Decodes a JSON string.
Definition: FormatJson.php:188
verifyPostBodyOk()
Output an api error if post body is obviously not OK.