Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.87% covered (warning)
68.87%
73 / 106
28.57% covered (danger)
28.57%
4 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiCSPReport
68.87% covered (warning)
68.87%
73 / 106
28.57% covered (danger)
28.57%
4 / 14
88.28
0.00% covered (danger)
0.00%
0 / 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
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 verifyPostBodyOk
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 getReport
53.85% covered (warning)
53.85%
7 / 13
0.00% covered (danger)
0.00%
0 / 1
7.46
 generateLogLine
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 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 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23use MediaWiki\Logger\LoggerFactory;
24use MediaWiki\MainConfigNames;
25use MediaWiki\Request\ContentSecurityPolicy;
26use Psr\Log\LoggerInterface;
27use Wikimedia\ParamValidator\ParamValidator;
28
29/**
30 * Api module to receive and log CSP violation reports
31 *
32 * @ingroup API
33 */
34class ApiCSPReport extends ApiBase {
35
36    private LoggerInterface $log;
37
38    /**
39     * These reports should be small. Ignore super big reports out of paranoia
40     */
41    private const MAX_POST_SIZE = 8192;
42
43    /**
44     * Logs a content-security-policy violation report from web browser.
45     */
46    public function execute() {
47        $reportOnly = $this->getParameter( 'reportonly' );
48        $logname = $reportOnly ? 'csp-report-only' : 'csp';
49        $this->log = LoggerFactory::getInstance( $logname );
50        $userAgent = $this->getRequest()->getHeader( 'user-agent' );
51
52        $this->verifyPostBodyOk();
53        $report = $this->getReport();
54        $flags = $this->getFlags( $report, $userAgent );
55
56        $warningText = $this->generateLogLine( $flags, $report );
57        $this->logReport( $flags, $warningText, [
58            // XXX Is it ok to put untrusted data into log??
59            'csp-report' => $report,
60            'method' => __METHOD__,
61            'user_id' => $this->getUser()->getId() ?: 'logged-out',
62            'user-agent' => $userAgent,
63            'source' => $this->getParameter( 'source' ),
64        ] );
65        $this->getResult()->addValue( null, $this->getModuleName(), 'success' );
66    }
67
68    /**
69     * Log CSP report, with a different severity depending on $flags
70     * @param array $flags Flags for this report
71     * @param string $logLine text of log entry
72     * @param array $context logging context
73     */
74    private function logReport( $flags, $logLine, $context ) {
75        if ( in_array( 'false-positive', $flags ) ) {
76            // These reports probably don't matter much
77            $this->log->debug( $logLine, $context );
78        } else {
79            // Normal report.
80            $this->log->warning( $logLine, $context );
81        }
82    }
83
84    /**
85     * Get extra notes about the report.
86     *
87     * @param array $report The CSP report
88     * @param string $userAgent
89     * @return array
90     */
91    private function getFlags( $report, $userAgent ) {
92        $reportOnly = $this->getParameter( 'reportonly' );
93        $source = $this->getParameter( 'source' );
94        $falsePositives = $this->getConfig()->get( MainConfigNames::CSPFalsePositiveUrls );
95
96        $flags = [];
97        if ( $source !== 'internal' ) {
98            $flags[] = 'source=' . $source;
99        }
100        if ( $reportOnly ) {
101            $flags[] = 'report-only';
102        }
103
104        if (
105            (
106                ContentSecurityPolicy::falsePositiveBrowser( $userAgent ) &&
107                $report['blocked-uri'] === "self"
108            ) ||
109            (
110                isset( $report['blocked-uri'] ) &&
111                $this->matchUrlPattern( $report['blocked-uri'], $falsePositives )
112            ) ||
113            (
114                isset( $report['source-file'] ) &&
115                $this->matchUrlPattern( $report['source-file'], $falsePositives )
116            )
117        ) {
118            // False positive due to:
119            // https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
120
121            $flags[] = 'false-positive';
122        }
123        return $flags;
124    }
125
126    /**
127     * @param string $url
128     * @param string[] $patterns
129     * @return bool
130     */
131    private function matchUrlPattern( $url, array $patterns ) {
132        if ( isset( $patterns[ $url ] ) ) {
133            return true;
134        }
135
136        $bits = wfParseUrl( $url );
137        unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
138        $bits['path'] = '';
139        $serverUrl = wfAssembleUrl( $bits );
140        if ( isset( $patterns[$serverUrl] ) ) {
141            // The origin of the url matches a pattern,
142            // e.g. "https://example.org" matches "https://example.org/foo/b?a#r"
143            return true;
144        }
145        foreach ( $patterns as $pattern => $val ) {
146            // We only use this pattern if it ends in a slash, this prevents
147            // "/foos" from matching "/foo", and "https://good.combo.bad" matching
148            // "https://good.com".
149            if ( str_ends_with( $pattern, '/' ) && str_starts_with( $url, $pattern ) ) {
150                // The pattern starts with the same as the url
151                // e.g. "https://example.org/foo/" matches "https://example.org/foo/b?a#r"
152                return true;
153            }
154        }
155
156        return false;
157    }
158
159    /**
160     * Output an api error if post body is obviously not OK.
161     */
162    private function verifyPostBodyOk() {
163        $req = $this->getRequest();
164        $contentType = $req->getHeader( 'content-type' );
165        if ( $contentType !== 'application/json'
166            && $contentType !== 'application/csp-report'
167        ) {
168            $this->error( 'wrongformat', __METHOD__ );
169        }
170        if ( $req->getHeader( 'content-length' ) > self::MAX_POST_SIZE ) {
171            $this->error( 'toobig', __METHOD__ );
172        }
173    }
174
175    /**
176     * Get the report from post body and turn into associative array.
177     *
178     * @return array
179     */
180    private function getReport() {
181        $postBody = $this->getRequest()->getRawInput();
182        if ( strlen( $postBody ) > self::MAX_POST_SIZE ) {
183            // paranoia, already checked content-length earlier.
184            $this->error( 'toobig', __METHOD__ );
185        }
186        $status = FormatJson::parse( $postBody, FormatJson::FORCE_ASSOC );
187        if ( !$status->isGood() ) {
188            $msg = $status->getErrors()[0]['message'];
189            if ( $msg instanceof Message ) {
190                $msg = $msg->getKey();
191            }
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     * Get text of log line.
205     *
206     * @param array $flags of additional markers for this report
207     * @param array $report the csp report
208     * @return string Text to put in log
209     */
210    private function generateLogLine( $flags, $report ) {
211        $flagText = '';
212        if ( $flags ) {
213            $flagText = '[' . implode( ', ', $flags ) . ']';
214        }
215
216        $blockedOrigin = isset( $report['blocked-uri'] )
217            ? $this->originFromUrl( $report['blocked-uri'] )
218            : 'n/a';
219        $page = $report['document-uri'] ?? 'n/a';
220        $line = isset( $report['line-number'] )
221            ? ':' . $report['line-number']
222            : '';
223        return $flagText .
224            ' Received CSP report: <' . $blockedOrigin . '>' .
225            ' blocked from being loaded on <' . $page . '>' . $line;
226    }
227
228    /**
229     * @param string $url
230     * @return string
231     */
232    private function originFromUrl( $url ) {
233        $bits = wfParseUrl( $url );
234        unset( $bits['user'], $bits['pass'], $bits['query'], $bits['fragment'] );
235        $bits['path'] = '';
236        // e.g. "https://example.org" from "https://example.org/foo/b?a#r"
237        return wfAssembleUrl( $bits );
238    }
239
240    /**
241     * Stop processing the request, and output/log an error
242     *
243     * @param string $code error code
244     * @param string $method method that made error
245     * @throws ApiUsageException Always
246     */
247    private function error( $code, $method ) {
248        $this->log->info( 'Error reading CSP report: ' . $code, [
249            'method' => $method,
250            'user-agent' => $this->getRequest()->getHeader( 'user-agent' )
251        ] );
252        // Return 400 on error for user agents to display, e.g. to the console.
253        $this->dieWithError(
254            [ 'apierror-csp-report', wfEscapeWikiText( $code ) ], 'cspreport-' . $code, [], 400
255        );
256    }
257
258    public function getAllowedParams() {
259        return [
260            'reportonly' => [
261                ParamValidator::PARAM_TYPE => 'boolean',
262                ParamValidator::PARAM_DEFAULT => false
263            ],
264            'source' => [
265                ParamValidator::PARAM_TYPE => 'string',
266                ParamValidator::PARAM_DEFAULT => 'internal',
267                ParamValidator::PARAM_REQUIRED => false
268            ]
269        ];
270    }
271
272    public function mustBePosted() {
273        return true;
274    }
275
276    /**
277     * Mark as internal. This isn't meant to be used by normal api users
278     * @return bool
279     */
280    public function isInternal() {
281        return true;
282    }
283
284    /**
285     * Even if you don't have read rights, we still want your report.
286     * @return bool
287     */
288    public function isReadMode() {
289        return false;
290    }
291
292    /**
293     * Doesn't touch db, so max lag should be rather irrelevant.
294     *
295     * Also, this makes sure that reports aren't lost during lag events.
296     * @return bool
297     */
298    public function shouldCheckMaxLag() {
299        return false;
300    }
301}