MediaWiki master
ApiCSPReport.php
Go to the documentation of this file.
1<?php
9namespace MediaWiki\Api;
10
16use Psr\Log\LoggerInterface;
18
24class ApiCSPReport extends ApiBase {
25
26 private LoggerInterface $log;
27
31 private const MAX_POST_SIZE = 8192;
32
33 private UrlUtils $urlUtils;
34
35 public function __construct(
36 ApiMain $main,
37 string $action,
38 UrlUtils $urlUtils
39 ) {
40 parent::__construct( $main, $action );
41 $this->urlUtils = $urlUtils;
42 }
43
47 public function execute() {
48 $reportOnly = $this->getParameter( 'reportonly' );
49 $logname = $reportOnly ? 'csp-report-only' : 'csp';
50 $this->log = LoggerFactory::getInstance( $logname );
51 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
52
53 $this->verifyPostBodyOk();
54 $report = $this->getReport();
55 $flags = $this->getFlags( $report, $userAgent );
56
57 $warningText = $this->generateLogLine( $flags, $report );
58 $this->logReport( $flags, $warningText, [
59 // XXX Is it ok to put untrusted data into log??
60 'csp-report' => $report,
61 'method' => __METHOD__,
62 'user_id' => $this->getUser()->getId() ?: 'logged-out',
63 'user-agent' => $userAgent,
64 'source' => $this->getParameter( 'source' ),
65 ] );
66 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
67 }
68
75 private function logReport( $flags, $logLine, $context ) {
76 if ( in_array( 'false-positive', $flags ) ) {
77 // These reports probably don't matter much
78 $this->log->debug( $logLine, $context );
79 } else {
80 // Normal report.
81 $this->log->warning( $logLine, $context );
82 }
83 }
84
92 private function getFlags( $report, $userAgent ) {
93 $reportOnly = $this->getParameter( 'reportonly' );
94 $source = $this->getParameter( 'source' );
95 $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
96
97 $flags = [];
98 if ( $source !== 'internal' ) {
99 $flags[] = 'source=' . $source;
100 }
101 if ( $reportOnly ) {
102 $flags[] = 'report-only';
103 }
104
105 if (
106 (
107 ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
108 $report['blocked-uri'] === "self"
109 ) ||
110 (
111 isset( $report['blocked-uri'] ) &&
112 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
113 ) ||
114 (
115 isset( $report['source-file'] ) &&
116 $this->matchUrlPattern( $report['source-file'], $falsePositives )
117 )
118 ) {
119 // False positive due to:
120 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
121
122 $flags[] = 'false-positive';
123 }
124 return $flags;
125 }
126
132 private function matchUrlPattern( $url, array $patterns ) {
133 if ( isset( $patterns[ $url ] ) ) {
134 return true;
135 }
136
137 $bits = $this->urlUtils->parse( $url );
138 if ( !$bits ) {
139 return false;
140 }
141
142 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
143 $bits['path'] = '';
144 $serverUrl = UrlUtils::assemble( $bits );
145 if ( isset( $patterns[$serverUrl] ) ) {
146 // The origin of the url matches a pattern,
147 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
148 return true;
149 }
150 foreach ( $patterns as $pattern => $val ) {
151 // We only use this pattern if it ends in a slash, this prevents
152 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
153 // "https://good.com".
154 if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
155 // The pattern starts with the same as the url
156 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
157 return true;
158 }
159 }
160
161 return false;
162 }
163
167 private function verifyPostBodyOk() {
168 $req = $this->getRequest();
169 $contentType = $req->getHeader( 'content-type' );
170 if ( $contentType !== 'application/json'
171 && $contentType !== 'application/csp-report'
172 ) {
173 $this->error( 'wrongformat', __METHOD__ );
174 }
175 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
176 $this->error( 'toobig', __METHOD__ );
177 }
178 }
179
185 private function getReport() {
186 $postBody = $this->getRequest()->getRawInput();
187 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
188 // paranoia, already checked content-length earlier.
189 $this->error( 'toobig', __METHOD__ );
190 }
191 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
192 if ( !$status->isGood() ) {
193 $msg = $status->getMessages()[0]->getKey();
194 $this->error( $msg, __METHOD__ );
195 }
196
197 $report = $status->getValue();
198
199 if ( !isset( $report['csp-report'] ) ) {
200 $this->error( 'missingkey', __METHOD__ );
201 }
202 return $report['csp-report'];
203 }
204
212 private function generateLogLine( $flags, $report ) {
213 $flagText = '';
214 if ( $flags ) {
215 $flagText = '[' . implode( ', ', $flags ) . ']';
216 }
217
218 $blockedOrigin = isset( $report['blocked-uri'] )
219 ? $this->originFromUrl( $report['blocked-uri'] )
220 : 'n/a';
221 $page = $report['document-uri'] ?? 'n/a';
222 $line = isset( $report['line-number'] )
223 ? ':' . $report['line-number']
224 : '';
225 return $flagText .
226 ' Received CSP report: <' . $blockedOrigin . '>' .
227 ' blocked from being loaded on <' . $page . '>' . $line;
228 }
229
234 private function originFromUrl( $url ) {
235 $bits = $this->urlUtils->parse( $url ) ?? [];
236 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
237 $bits['path'] = '';
238 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
239 return UrlUtils::assemble( $bits );
240 }
241
248 private function error( $code, $method ): never {
249 $this->log->info( 'Error reading CSP report: ' . $code, [
250 'method' => $method,
251 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
252 ] );
253 // Return 400 on error for user agents to display, e.g. to the console.
254 $this->dieWithError(
255 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
256 );
257 }
258
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
275 public function mustBePosted() {
276 return true;
277 }
278
283 public function isInternal() {
284 return true;
285 }
286
291 public function isReadMode() {
292 return false;
293 }
294
301 public function shouldCheckMaxLag() {
302 return false;
303 }
304}
305
307class_alias( ApiCSPReport::class, 'ApiCSPReport' );
wfEscapeWikiText( $input)
Escapes the given text so that it may be output using addWikiText() without any linking,...
This abstract class implements many basic API functions, and is the base of all API classes.
Definition ApiBase.php:61
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1511
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:543
getResult()
Get the result object.
Definition ApiBase.php:682
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:944
Api module to receive and log CSP violation reports.
isInternal()
Mark as internal.
isReadMode()
Even if you don't have read rights, we still want your report.
shouldCheckMaxLag()
Doesn't touch db, so max lag should be rather irrelevant.
mustBePosted()
Indicates whether this module must be called with a POST request.Implementations of this method must ...
execute()
Logs a content-security-policy violation report from web browser.
__construct(ApiMain $main, string $action, UrlUtils $urlUtils)
getAllowedParams()
Returns an array of allowed parameters (parameter name) => (default value) or (parameter name) => (ar...
This is the main API class, used for both external and internal processing.
Definition ApiMain.php:66
JSON formatter wrapper class.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const CSPFalsePositiveUrls
Name constant for the CSPFalsePositiveUrls setting, for use with Config::get()
Handle sending Content-Security-Policy headers.
A service to expand, parse, and otherwise manipulate URLs.
Definition UrlUtils.php:16
Service for formatting and validating API parameters.
$source