Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.89% covered (warning)
67.89%
74 / 109
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiCSPReport
68.52% covered (warning)
68.52%
74 / 108
26.67% covered (danger)
26.67%
4 / 15
93.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 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
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
7.48
 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
 generateLogLine
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 originFromUrl
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 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    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
44    /**
45     * Logs a content-security-policy violation report from web browser.
46     */
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
69    /**
70     * Log CSP report, with a different severity depending on $flags
71     * @param array $flags Flags for this report
72     * @param string $logLine text of log entry
73     * @param array $context logging context
74     */
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
85    /**
86     * Get extra notes about the report.
87     *
88     * @param array $report The CSP report
89     * @param string $userAgent
90     * @return array
91     */
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
127    /**
128     * @param string $url
129     * @param string[] $patterns
130     * @return bool
131     */
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
164    /**
165     * Output an api error if post body is obviously not OK.
166     */
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
180    /**
181     * Get the report from post body and turn into associative array.
182     *
183     * @return array
184     */
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
205    /**
206     * Get text of log line.
207     *
208     * @param array $flags of additional markers for this report
209     * @param array $report the csp report
210     * @return string Text to put in log
211     */
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
230    /**
231     * @param string $url
232     * @return string
233     */
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
242    /**
243     * Stop processing the request, and output/log an error
244     *
245     * @param string $code error code
246     * @param string $method method that made error
247     */
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
259    /** @inheritDoc */
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
274    /** @inheritDoc */
275    public function mustBePosted() {
276        return true;
277    }
278
279    /**
280     * Mark as internal. This isn't meant to be used by normal api users
281     * @return bool
282     */
283    public function isInternal() {
284        return true;
285    }
286
287    /**
288     * Even if you don't have read rights, we still want your report.
289     * @return bool
290     */
291    public function isReadMode() {
292        return false;
293    }
294
295    /**
296     * Doesn't touch db, so max lag should be rather irrelevant.
297     *
298     * Also, this makes sure that reports aren't lost during lag events.
299     * @return bool
300     */
301    public function shouldCheckMaxLag() {
302        return false;
303    }
304}
305
306/** @deprecated class alias since 1.43 */
307class_alias( ApiCSPReport::class, 'ApiCSPReport' );