MediaWiki master
ApiCSPReport.php
Go to the documentation of this file.
1<?php
23namespace MediaWiki\Api;
24
30use Psr\Log\LoggerInterface;
32
38class ApiCSPReport extends ApiBase {
39
40 private LoggerInterface $log;
41
45 private const MAX_POST_SIZE = 8192;
46
47 private UrlUtils $urlUtils;
48
49 public function __construct(
50 ApiMain $main,
51 string $action,
52 UrlUtils $urlUtils
53 ) {
54 parent::__construct( $main, $action );
55 $this->urlUtils = $urlUtils;
56 }
57
61 public function execute() {
62 $reportOnly = $this->getParameter( 'reportonly' );
63 $logname = $reportOnly ? 'csp-report-only' : 'csp';
64 $this->log = LoggerFactory::getInstance( $logname );
65 $userAgent = $this->getRequest()->getHeader( 'user-agent' );
66
67 $this->verifyPostBodyOk();
68 $report = $this->getReport();
69 $flags = $this->getFlags( $report, $userAgent );
70
71 $warningText = $this->generateLogLine( $flags, $report );
72 $this->logReport( $flags, $warningText, [
73 // XXX Is it ok to put untrusted data into log??
74 'csp-report' => $report,
75 'method' => __METHOD__,
76 'user_id' => $this->getUser()->getId() ?: 'logged-out',
77 'user-agent' => $userAgent,
78 'source' => $this->getParameter( 'source' ),
79 ] );
80 $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
81 }
82
89 private function logReport( $flags, $logLine, $context ) {
90 if ( in_array( 'false-positive', $flags ) ) {
91 // These reports probably don't matter much
92 $this->log->debug( $logLine, $context );
93 } else {
94 // Normal report.
95 $this->log->warning( $logLine, $context );
96 }
97 }
98
106 private function getFlags( $report, $userAgent ) {
107 $reportOnly = $this->getParameter( 'reportonly' );
108 $source = $this->getParameter( 'source' );
109 $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
110
111 $flags = [];
112 if ( $source !== 'internal' ) {
113 $flags[] = 'source=' . $source;
114 }
115 if ( $reportOnly ) {
116 $flags[] = 'report-only';
117 }
118
119 if (
120 (
122 $report['blocked-uri'] === "self"
123 ) ||
124 (
125 isset( $report['blocked-uri'] ) &&
126 $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
127 ) ||
128 (
129 isset( $report['source-file'] ) &&
130 $this->matchUrlPattern( $report['source-file'], $falsePositives )
131 )
132 ) {
133 // False positive due to:
134 // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
135
136 $flags[] = 'false-positive';
137 }
138 return $flags;
139 }
140
146 private function matchUrlPattern( $url, array $patterns ) {
147 if ( isset( $patterns[ $url ] ) ) {
148 return true;
149 }
150
151 $bits = $this->urlUtils->parse( $url );
152 if ( !$bits ) {
153 return false;
154 }
155
156 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
157 $bits['path'] = '';
158 $serverUrl = UrlUtils::assemble( $bits );
159 if ( isset( $patterns[$serverUrl] ) ) {
160 // The origin of the url matches a pattern,
161 // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
162 return true;
163 }
164 foreach ( $patterns as $pattern => $val ) {
165 // We only use this pattern if it ends in a slash, this prevents
166 // "/foos" from matching "/foo", and "https://good.combo.bad" matching
167 // "https://good.com".
168 if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
169 // The pattern starts with the same as the url
170 // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
171 return true;
172 }
173 }
174
175 return false;
176 }
177
181 private function verifyPostBodyOk() {
182 $req = $this->getRequest();
183 $contentType = $req->getHeader( 'content-type' );
184 if ( $contentType !== 'application/json'
185 && $contentType !== 'application/csp-report'
186 ) {
187 $this->error( 'wrongformat', __METHOD__ );
188 }
189 if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
190 $this->error( 'toobig', __METHOD__ );
191 }
192 }
193
199 private function getReport() {
200 $postBody = $this->getRequest()->getRawInput();
201 if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
202 // paranoia, already checked content-length earlier.
203 $this->error( 'toobig', __METHOD__ );
204 }
205 $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
206 if ( !$status->isGood() ) {
207 $msg = $status->getMessages()[0]->getKey();
208 $this->error( $msg, __METHOD__ );
209 }
210
211 $report = $status->getValue();
212
213 if ( !isset( $report['csp-report'] ) ) {
214 $this->error( 'missingkey', __METHOD__ );
215 }
216 return $report['csp-report'];
217 }
218
226 private function generateLogLine( $flags, $report ) {
227 $flagText = '';
228 if ( $flags ) {
229 $flagText = '[' . implode( ', ', $flags ) . ']';
230 }
231
232 $blockedOrigin = isset( $report['blocked-uri'] )
233 ? $this->originFromUrl( $report['blocked-uri'] )
234 : 'n/a';
235 $page = $report['document-uri'] ?? 'n/a';
236 $line = isset( $report['line-number'] )
237 ? ':' . $report['line-number']
238 : '';
239 return $flagText .
240 ' Received CSP report: <' . $blockedOrigin . '>' .
241 ' blocked from being loaded on <' . $page . '>' . $line;
242 }
243
248 private function originFromUrl( $url ) {
249 $bits = $this->urlUtils->parse( $url ) ?? [];
250 unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
251 $bits['path'] = '';
252 // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
253 return UrlUtils::assemble( $bits );
254 }
255
263 private function error( $code, $method ) {
264 $this->log->info( 'Error reading CSP report: ' . $code, [
265 'method' => $method,
266 'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
267 ] );
268 // Return 400 on error for user agents to display, e.g. to the console.
269 $this->dieWithError(
270 [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
271 );
272 }
273
274 public function getAllowedParams() {
275 return [
276 'reportonly' => [
277 ParamValidator::PARAM_TYPE => 'boolean',
278 ParamValidator::PARAM_DEFAULT => false
279 ],
280 'source' => [
281 ParamValidator::PARAM_TYPE => 'string',
282 ParamValidator::PARAM_DEFAULT => 'internal',
283 ParamValidator::PARAM_REQUIRED => false
284 ]
285 ];
286 }
287
288 public function mustBePosted() {
289 return true;
290 }
291
296 public function isInternal() {
297 return true;
298 }
299
304 public function isReadMode() {
305 return false;
306 }
307
314 public function shouldCheckMaxLag() {
315 return false;
316 }
317}
318
320class_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:76
dieWithError( $msg, $code=null, $data=null, $httpCode=0)
Abort execution with an error.
Definition ApiBase.php:1577
getModuleName()
Get the name of the module being executed by this instance.
Definition ApiBase.php:571
getResult()
Get the result object.
Definition ApiBase.php:710
getParameter( $paramName, $parseLimit=true)
Get a value for the given parameter.
Definition ApiBase.php:973
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.
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:78
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.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
A service to expand, parse, and otherwise manipulate URLs.
Definition UrlUtils.php:16
Service for formatting and validating API parameters.
$source