From ae0bae92afe0307dfe9f1868302c79099de3cc2f Mon Sep 17 00:00:00 2001 From: Brian Wolff Date: Sun, 28 Feb 2016 22:57:10 -0500 Subject: [PATCH] Add API module to receive CSP reports. There are two expected usecases for this: * The proposed builtin CSP support at I80f6f4 * Setting CSP headers on media served from upload.wikimedia.org This was split from I80f6f46 For details on CSP, see http://www.w3.org/TR/CSP2/ See also https://www.mediawiki.org/wiki/Requests_for_comment/Content-Security-Policy Related to (but not directly a fix for) T117618 Bug: T135963 Change-Id: Id92126ca7707186757e77fe50cd336ff1acb8b3f --- autoload.php | 1 + includes/api/ApiCSPReport.php | 224 ++++++++++++++++++++++++++++++++++ includes/api/ApiMain.php | 1 + includes/api/i18n/en.json | 4 +- includes/api/i18n/qqq.json | 3 + 5 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 includes/api/ApiCSPReport.php diff --git a/autoload.php b/autoload.php index 87ba2254e6..c02ae62156 100644 --- a/autoload.php +++ b/autoload.php @@ -28,6 +28,7 @@ $wgAutoloadLocalClasses = [ 'ApiComparePages' => __DIR__ . '/includes/api/ApiComparePages.php', 'ApiContinuationManager' => __DIR__ . '/includes/api/ApiContinuationManager.php', 'ApiCreateAccount' => __DIR__ . '/includes/api/ApiCreateAccount.php', + 'ApiCSPReport' => __DIR__ . '/includes/api/ApiCSPReport.php', 'ApiDelete' => __DIR__ . '/includes/api/ApiDelete.php', 'ApiDisabled' => __DIR__ . '/includes/api/ApiDisabled.php', 'ApiEditPage' => __DIR__ . '/includes/api/ApiEditPage.php', diff --git a/includes/api/ApiCSPReport.php b/includes/api/ApiCSPReport.php new file mode 100644 index 0000000000..5271996080 --- /dev/null +++ b/includes/api/ApiCSPReport.php @@ -0,0 +1,224 @@ +getParameter( 'reportonly' ); + $logname = $reportOnly ? 'csp-report-only' : 'csp'; + $this->log = LoggerFactory::getInstance( $logname ); + $userAgent = $this->getRequest()->getHeader( 'user-agent' ); + + $this->verifyPostBodyOk(); + $report = $this->getReport(); + $flags = $this->getFlags( $report ); + + $warningText = $this->generateLogLine( $flags, $report ); + $this->logReport( $flags, $warningText, [ + // XXX Is it ok to put untrusted data into log?? + 'csp-report' => $report, + 'method' => __METHOD__, + 'user' => $this->getUser()->getName(), + 'user-agent' => $userAgent, + 'source' => $this->getParameter( 'source' ), + ] ); + $this->getResult()->addValue( null, $this->getModuleName(), 'success' ); + } + + /** + * Log CSP report, with a different severity depending on $flags + * @param $flags Array Flags for this report + * @param $logLine String text of log entry + * @param $context Array logging context + */ + private function logReport( $flags, $logLine, $context ) { + if ( in_array( 'false-positive', $flags ) ) { + // These reports probably don't matter much + $this->log->debug( $logLine, $context ); + } else { + // Normal report. + $this->log->warning( $logLine, $context ); + } + } + + /** + * Get extra notes about the report. + * + * @param $report Array The CSP report + * @return Array + */ + private function getFlags( $report ) { + $reportOnly = $this->getParameter( 'reportonly' ); + $userAgent = $this->getRequest()->getHeader( 'user-agent' ); + $source = $this->getParameter( 'source' ); + + $flags = []; + if ( $source !== 'internal' ) { + $flags[] = 'source=' . $source; + } + if ( $reportOnly ) { + $flags[] = 'report-only'; + } + return $flags; + } + + /** + * Output an api error if post body is obviously not OK. + */ + private function verifyPostBodyOk() { + $req = $this->getRequest(); + $contentType = $req->getHeader( 'content-type' ); + if ( $contentType !== 'application/json' + && $contentType !=='application/csp-report' + ) { + $this->error( 'wrongformat', __METHOD__ ); + } + if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) { + $this->error( 'toobig', __METHOD__ ); + } + } + + /** + * Get the report from post body and turn into associative array. + * + * @return Array + */ + private function getReport() { + $postBody = $this->getRequest()->getRawInput(); + if ( strlen( $postBody ) > self::MAX_POST_SIZE ) { + // paranoia, already checked content-length earlier. + $this->error( 'toobig', __METHOD__ ); + } + $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC ); + if ( !$status->isGood() ) { + list( $code, ) = $this->getErrorFromStatus( $status ); + $this->error( $code, __METHOD__ ); + } + + $report = $status->getValue(); + + if ( !isset( $report['csp-report'] ) ) { + $this->error( 'missingkey', __METHOD__ ); + } + return $report['csp-report']; + } + + /** + * Get text of log line. + * + * @param $flags Array of additional markers for this report + * @param $report Array the csp report + * @return String Text to put in log + */ + private function generateLogLine( $flags, $report ) { + $flagText = ''; + if ( $flags ) { + $flagText = '[' . implode( $flags, ', ' ) . ']'; + } + + $blockedFile = isset( $report['blocked-uri'] ) ? $report['blocked-uri'] : 'n/a'; + $page = isset( $report['document-uri'] ) ? $report['document-uri'] : 'n/a'; + $line = isset( $report['line-number'] ) ? ':' . $report['line-number'] : ''; + $warningText = $flagText . + ' Received CSP report: <' . $blockedFile . + '> blocked from being loaded on <' . $page . '>' . $line; + return $warningText; + } + + /** + * Stop processing the request, and output/log an error + * + * @param $code String error code + * @param $method String method that made error + * @throws UsageException Always + */ + private function error( $code, $method ) { + $this->log->info( 'Error reading CSP report: ' . $code, [ + 'method' => $method, + 'user-agent' => $this->getRequest()->getHeader( 'user-agent' ) + ] ); + // 500 so it shows up in browser's developer console. + $this->dieUsage( "Error processing CSP report: $code", 'cspreport-' . $code, 500 ); + } + + public function getAllowedParams() { + return [ + 'reportonly' => [ + ApiBase::PARAM_TYPE => 'boolean', + ApiBase::PARAM_DFLT => false + ], + 'source' => [ + ApiBase::PARAM_TYPE => 'string', + ApiBase::PARAM_DFLT => 'internal', + ApiBase::PARAM_REQUIRED => false + ] + ]; + } + + public function mustBePosted() { + return true; + } + + public function isWriteMode() { + return false; + } + + /** + * Mark as internal. This isn't meant to be used by normal api users + */ + public function isInternal() { + return true; + } + + /** + * Even if you don't have read rights, we still want your report. + */ + public function isReadMode() { + return false; + } + + /** + * Doesn't touch db, so max lag should be rather irrelavent. + * + * Also, this makes sure that reports aren't lost during lag events. + */ + public function shouldCheckMaxLag() { + return false; + } +} diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index c8601a0992..a9541d3f10 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -71,6 +71,7 @@ class ApiMain extends ApiBase { 'compare' => 'ApiComparePages', 'tokens' => 'ApiTokens', 'checktoken' => 'ApiCheckToken', + 'cspreport' => 'ApiCSPReport', // Write modules 'purge' => 'ApiPurge', diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 5124955353..24b66ab364 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -73,7 +73,9 @@ "apihelp-createaccount-param-language": "Language code to set as default for the user (optional, defaults to content language).", "apihelp-createaccount-example-pass": "Create user testuser with password test123.", "apihelp-createaccount-example-mail": "Create user testmailuser and email a randomly-generated password.", - + "apihelp-cspreport-description": "Used by browsers to report violations of the Content Security Policy. This module should never be used, except when used automatically by a CSP compliant web browser.", + "apihelp-cspreport-param-reportonly": "Mark as being a report from a monitoring policy, not an enforced policy", + "apihelp-cspreport-param-source": "What generated the CSP header that triggered this report", "apihelp-delete-description": "Delete a page.", "apihelp-delete-param-title": "Title of the page to delete. Cannot be used together with $1pageid.", "apihelp-delete-param-pageid": "Page ID of the page to delete. Cannot be used together with $1title.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index ed9952ff1d..6c47aa4230 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -75,6 +75,9 @@ "apihelp-createaccount-param-language": "{{doc-apihelp-param|createaccount|language}}", "apihelp-createaccount-example-pass": "{{doc-apihelp-example|createaccount}}", "apihelp-createaccount-example-mail": "{{doc-apihelp-example|createaccount}}", + "apihelp-cspreport-description": "{{doc-apihelp-description|cspreport}}", + "apihelp-cspreport-param-reportonly": "{{doc-apihelp-param|cspreport|reportonly}}", + "apihelp-cspreport-param-source": "{{doc-apihelp-param|cspreport|source}}", "apihelp-delete-description": "{{doc-apihelp-description|delete}}", "apihelp-delete-param-title": "{{doc-apihelp-param|delete|title}}", "apihelp-delete-param-pageid": "{{doc-apihelp-param|delete|pageid}}", -- 2.20.1