Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.63% covered (warning)
68.63%
70 / 102
21.43% covered (danger)
21.43%
3 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiCSPReport
69.31% covered (warning)
69.31%
70 / 101
21.43% covered (danger)
21.43%
3 / 14
86.26
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 logReport
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getFlags
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 matchUrlPattern
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 verifyPostBodyOk
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getReport
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 originFromUrl
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 error
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 mustBePosted
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isReadMode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 shouldCheckMaxLag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Copyright © 2015 Brian Wolff
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 */
8
9namespace MediaWiki\Api;
10
11use MediaWiki\Json\FormatJson;
12use MediaWiki\Logger\LoggerFactory;
13use MediaWiki\MainConfigNames;
14use MediaWiki\Request\ContentSecurityPolicy;
15use MediaWiki\Utils\UrlUtils;
16use Psr\Log\LoggerInterface;
17use Wikimedia\ParamValidator\ParamValidator;
18
19/**
20 * Api module to receive and log CSP violation reports
21 *
22 * @ingroup API
23 */
24class ApiCSPReport extends ApiBase {
25
26    private LoggerInterface $log;
27
28    /**
29     * These reports should be small. Ignore super big reports out of paranoia
30     */
31    private const MAX_POST_SIZE = 8192;
32
33    public function __construct(
34        ApiMain $main,
35        string $action,
36    ) {
37        parent::__construct( $main, $action );
38    }
39
40    /**
41     * Logs a content-security-policy violation report from web browser.
42     */
43    public function execute() {
44        $reportOnly = $this->getParameter( 'reportonly' );
45        $logname = $reportOnly ? 'csp-report-only' : 'csp';
46        $this->log = LoggerFactory::getInstance( $logname );
47        $userAgent = $this->getRequest()->getHeader( 'user-agent' );
48
49        $this->verifyPostBodyOk();
50        $report = $this->getReport();
51        $flags = $this->getFlags( $report, $userAgent );
52
53        $this->logReport(
54            '[{flags}] Received CSP report: <{blockedOrigin}> blocked from being loaded on <{documentUri}>:{line}',
55            [
56                'flags' => implode( ', ', $flags ),
57                'blockedOrigin' => isset( $report['blocked-uri'] ) ?
58                    $this->originFromUrl( $report['blocked-uri'] ) : 'n/a',
59                'documentUri' => $report['document-uri'] ?? 'n/a',
60                'line' => $report['line-number'] ?? '',
61                // XXX Is it ok to put untrusted data into log??
62                'csp-report' => $report,
63                'method' => __METHOD__,
64                'user_id' => $this->getUser()->getId() ?: 'logged-out',
65                'user-agent' => $userAgent,
66                'source' => $this->getParameter( 'source' ),
67            ],
68            $flags
69        );
70        $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
71    }
72
73    /**
74     * Log CSP report, with a different severity depending on $flags
75     * @param string $logLine text of log entry
76     * @param array $context logging context
77     * @param array $flags Flags for this report
78     */
79    private function logReport( $logLine, $context, $flags ) {
80        if ( in_array( 'false-positive', $flags ) ) {
81            // These reports probably don't matter much
82            $this->log->debug( $logLine, $context );
83        } else {
84            // Normal report.
85            $this->log->warning( $logLine, $context );
86        }
87    }
88
89    /**
90     * Get extra notes about the report.
91     *
92     * @param array $report The CSP report
93     * @param string $userAgent
94     * @return array
95     */
96    private function getFlags( $report, $userAgent ) {
97        $reportOnly = $this->getParameter( 'reportonly' );
98        $source = $this->getParameter( 'source' );
99        $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
100
101        $flags = [];
102        if ( $source !== 'internal' ) {
103            $flags[] = 'source=' . $source;
104        }
105        if ( $reportOnly ) {
106            $flags[] = 'report-only';
107        }
108
109        if (
110            (
111                ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
112                $report['blocked-uri'] === "self"
113            ) ||
114            (
115                isset( $report['blocked-uri'] ) &&
116                $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
117            ) ||
118            (
119                isset( $report['source-file'] ) &&
120                $this->matchUrlPattern( $report['source-file'], $falsePositives )
121            )
122        ) {
123            // False positive due to:
124            // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
125
126            $flags[] = 'false-positive';
127        }
128        return $flags;
129    }
130
131    /**
132     * @param string $url
133     * @param string[] $patterns
134     * @return bool
135     */
136    private function matchUrlPattern( $url, array $patterns ) {
137        if ( isset( $patterns[ $url ] ) ) {
138            return true;
139        }
140
141        $serverUrl = $this->originFromUrl( $url );
142
143        if ( isset( $patterns[$serverUrl] ) ) {
144            // The origin of the url matches a pattern,
145            // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
146            return true;
147        }
148        foreach ( $patterns as $pattern => $val ) {
149            // We only use this pattern if it ends in a slash, this prevents
150            // "/foos" from matching "/foo", and "https://good.combo.bad" matching
151            // "https://good.com".
152            if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
153                // The pattern starts with the same as the url
154                // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
155                return true;
156            }
157        }
158
159        return false;
160    }
161
162    /**
163     * Output an api error if post body is obviously not OK.
164     */
165    private function verifyPostBodyOk() {
166        $req = $this->getRequest();
167        $contentType = $req->getHeader( 'content-type' );
168        if ( $contentType !== 'application/json'
169            && $contentType !== 'application/csp-report'
170        ) {
171            $this->error( 'wrongformat', __METHOD__ );
172        }
173        if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
174            $this->error( 'toobig', __METHOD__ );
175        }
176    }
177
178    /**
179     * Get the report from post body and turn into associative array.
180     *
181     * @return array
182     */
183    private function getReport() {
184        $postBody = $this->getRequest()->getRawInput();
185        if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
186            // paranoia, already checked content-length earlier.
187            $this->error( 'toobig', __METHOD__ );
188        }
189        $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
190        if ( !$status->isGood() ) {
191            $msg = $status->getMessages()[0]->getKey();
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
203    /**
204     * Extract the origin (scheme and host) from a URL.
205     *
206     * Uses parse_url() instead of UrlUtils->parse() to handle URLs with protocols
207     * not in $wgUrlProtocols (e.g., chrome-extension://). This is safe because
208     * we only use the result for logging CSP reports.
209     *
210     * @param string $url
211     * @return string Origin URL, or the original URL if parsing fails
212     */
213    private function originFromUrl( $url ) {
214        $bits = parse_url( $url );
215
216        if ( !$bits || !isset( $bits['scheme'] ) ) {
217            return $url;
218        }
219
220        unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
221        $bits['path'] = '';
222
223        if ( !isset( $bits['delimiter'] ) ) {
224            $bits['delimiter'] = '://';
225        }
226
227        // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
228        return UrlUtils::assemble( $bits );
229    }
230
231    /**
232     * Stop processing the request, and output/log an error
233     *
234     * @param string $code error code
235     * @param string $method method that made error
236     */
237    private function error( $code, $method ): never {
238        $this->log->info( 'Error reading CSP report: ' . $code, [
239            'method' => $method,
240            'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
241        ] );
242        // Return 400 on error for user agents to display, e.g. to the console.
243        $this->dieWithError(
244            [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
245        );
246    }
247
248    /** @inheritDoc */
249    public function getAllowedParams() {
250        return [
251            'reportonly' => [
252                ParamValidator::PARAM_TYPE => 'boolean',
253                ParamValidator::PARAM_DEFAULT => false
254            ],
255            'source' => [
256                ParamValidator::PARAM_TYPE => 'string',
257                ParamValidator::PARAM_DEFAULT => 'internal',
258                ParamValidator::PARAM_REQUIRED => false
259            ]
260        ];
261    }
262
263    /** @inheritDoc */
264    public function mustBePosted() {
265        return true;
266    }
267
268    /**
269     * Mark as internal. This isn't meant to be used by normal api users
270     * @return bool
271     */
272    public function isInternal() {
273        return true;
274    }
275
276    /**
277     * Even if you don't have read rights, we still want your report.
278     * @return bool
279     */
280    public function isReadMode() {
281        return false;
282    }
283
284    /**
285     * Doesn't touch db, so max lag should be rather irrelevant.
286     *
287     * Also, this makes sure that reports aren't lost during lag events.
288     * @return bool
289     */
290    public function shouldCheckMaxLag() {
291        return false;
292    }
293}
294
295/** @deprecated class alias since 1.43 */
296class_alias( ApiCSPReport::class, 'ApiCSPReport' );