Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
22 / 33
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenAwareHandlerTrait
66.67% covered (warning)
66.67%
22 / 33
50.00% covered (danger)
50.00%
3 / 6
27.70
0.00% covered (danger)
0.00%
0 / 1
 getValidatedBody
n/a
0 / 0
n/a
0 / 0
0
 getSession
n/a
0 / 0
n/a
0 / 0
0
 getTokenParamDefinition
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getToken
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getBadTokenMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateToken
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
10.02
 getBadTokenException
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\Rest;
4
5use LogicException;
6use MediaWiki\Session\Session;
7use MediaWiki\User\LoggedOutEditToken;
8use Wikimedia\Message\DataMessageValue;
9use Wikimedia\Message\MessageValue;
10use Wikimedia\ParamValidator\ParamValidator;
11
12/**
13 * This trait can be used on handlers that choose to support token-based CSRF protection. Note that doing so is
14 * discouraged, and you should preferably require that the endpoint be used with a session provider that is
15 * safe against CSRF, such as OAuth.
16 * @see Handler::requireSafeAgainstCsrf()
17 *
18 * @package MediaWiki\Rest
19 */
20trait TokenAwareHandlerTrait {
21    abstract public function getValidatedBody();
22
23    abstract public function getSession(): Session;
24
25    /**
26     * Returns the definition for the token parameter, to be used in getBodyValidator().
27     *
28     * @return array[]
29     */
30    protected function getTokenParamDefinition(): array {
31        return [
32            'token' => [
33                Handler::PARAM_SOURCE => 'body',
34                ParamValidator::PARAM_TYPE => 'string',
35                ParamValidator::PARAM_REQUIRED => false,
36                ParamValidator::PARAM_DEFAULT => '',
37            ]
38        ];
39    }
40
41    /**
42     * Determines the CSRF token to be used, possibly taking it from a request parameter.
43     *
44     * Returns an empty string if the request isn't known to be safe and
45     * no token was supplied by the client.
46     * Returns null if the session provider is safe against CSRF (and thus no token
47     * is needed)
48     *
49     * @return string|null
50     */
51    protected function getToken(): ?string {
52        if ( !$this instanceof Handler ) {
53            throw new LogicException( 'This trait must be used on handler classes.' );
54        }
55
56        if ( !$this->needsToken() ) {
57            return null;
58        }
59
60        $body = $this->getValidatedBody();
61        return $body['token'] ?? '';
62    }
63
64    /**
65     * Determines whether a CSRF token is needed.
66     *
67     * Returns false if the request has been authenticated in a way that
68     * protects against CSRF, such as OAuth.
69     *
70     * @return bool
71     */
72    protected function needsToken(): bool {
73        return !$this->getSession()->getProvider()->safeAgainstCsrf();
74    }
75
76    /**
77     * Returns a standard error message to use when the given CSRF token is invalid.
78     * In the future, this trait may also provide a method for checking the token.
79     *
80     * @return MessageValue
81     */
82    protected function getBadTokenMessage(): MessageValue {
83        return DataMessageValue::new( 'rest-badtoken' );
84    }
85
86    /**
87     * Checks that the given CSRF token is valid (or the used authentication method does
88     * not require CSRF).
89     * Note that this method only supports the 'csrf' token type. The body validator must
90     * return an array and include the 'token' field (see getTokenParamDefinition()).
91     * @param bool $allowAnonymousToken Allow anonymous users to pass the check by submitting
92     *   an empty token. (This matches how e.g. anonymous editing works on the action API and web.)
93     * @return void
94     * @throws LocalizedHttpException
95     */
96    protected function validateToken( bool $allowAnonymousToken = false ): void {
97        if ( $this->getSession()->getProvider()->safeAgainstCsrf() ) {
98            return;
99        }
100
101        $submittedToken = $this->getToken();
102        $sessionToken = null;
103        $isAnon = $this->getSession()->getUser()->isAnon();
104        if ( $allowAnonymousToken && $isAnon ) {
105            $sessionToken = new LoggedOutEditToken();
106        } elseif ( $this->getSession()->hasToken() ) {
107            $sessionToken = $this->getSession()->getToken();
108        }
109
110        if ( $sessionToken && $sessionToken->match( $submittedToken ) ) {
111            return;
112        } elseif ( !$submittedToken ) {
113            throw $this->getBadTokenException( 'rest-badtoken-missing' );
114        } elseif ( $isAnon && !$this->getSession()->isPersistent() ) {
115            // The client probably forgot to authenticate.
116            throw $this->getBadTokenException( 'rest-badtoken-nosession' );
117        } else {
118            // The user submitted a token, the session had a token, but they didn't match.
119            throw new LocalizedHttpException( $this->getBadTokenMessage(), 403 );
120        }
121    }
122
123    /**
124     * @param string $messageKey
125     * @return LocalizedHttpException
126     * @internal For use by the trait only
127     */
128    private function getBadTokenException( string $messageKey ): LocalizedHttpException {
129        return new LocalizedHttpException( DataMessageValue::new( $messageKey, [], 'rest-badtoken' ), 403 );
130    }
131}