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    /** @inheritDoc */
22    abstract public function getValidatedBody();
23
24    abstract public function getSession(): Session;
25
26    /**
27     * Returns the definition for the token parameter, to be used in getBodyValidator().
28     *
29     * @return array[]
30     */
31    protected function getTokenParamDefinition(): array {
32        return [
33            'token' => [
34                Handler::PARAM_SOURCE => 'body',
35                ParamValidator::PARAM_TYPE => 'string',
36                ParamValidator::PARAM_REQUIRED => false,
37                ParamValidator::PARAM_DEFAULT => '',
38            ]
39        ];
40    }
41
42    /**
43     * Determines the CSRF token to be used, possibly taking it from a request parameter.
44     *
45     * Returns an empty string if the request isn't known to be safe and
46     * no token was supplied by the client.
47     * Returns null if the session provider is safe against CSRF (and thus no token
48     * is needed)
49     *
50     * @return string|null
51     */
52    protected function getToken(): ?string {
53        if ( !$this instanceof Handler ) {
54            throw new LogicException( 'This trait must be used on handler classes.' );
55        }
56
57        if ( !$this->needsToken() ) {
58            return null;
59        }
60
61        $body = $this->getValidatedBody();
62        return $body['token'] ?? '';
63    }
64
65    /**
66     * Determines whether a CSRF token is needed.
67     *
68     * Returns false if the request has been authenticated in a way that
69     * protects against CSRF, such as OAuth.
70     */
71    protected function needsToken(): bool {
72        return !$this->getSession()->getProvider()->safeAgainstCsrf();
73    }
74
75    /**
76     * Returns a standard error message to use when the given CSRF token is invalid.
77     * In the future, this trait may also provide a method for checking the token.
78     */
79    protected function getBadTokenMessage(): MessageValue {
80        return DataMessageValue::new( 'rest-badtoken' );
81    }
82
83    /**
84     * Checks that the given CSRF token is valid (or the used authentication method does
85     * not require CSRF).
86     * Note that this method only supports the 'csrf' token type. The body validator must
87     * return an array and include the 'token' field (see getTokenParamDefinition()).
88     * @param bool $allowAnonymousToken Allow anonymous users to pass the check by submitting
89     *   an empty token. (This matches how e.g. anonymous editing works on the action API and web.)
90     * @return void
91     * @throws LocalizedHttpException
92     */
93    protected function validateToken( bool $allowAnonymousToken = false ): void {
94        if ( $this->getSession()->getProvider()->safeAgainstCsrf() ) {
95            return;
96        }
97
98        $submittedToken = $this->getToken();
99        $sessionToken = null;
100        $isAnon = $this->getSession()->getUser()->isAnon();
101        if ( $allowAnonymousToken && $isAnon ) {
102            $sessionToken = new LoggedOutEditToken();
103        } elseif ( $this->getSession()->hasToken() ) {
104            $sessionToken = $this->getSession()->getToken();
105        }
106
107        if ( $sessionToken && $sessionToken->match( $submittedToken ) ) {
108            return;
109        } elseif ( !$submittedToken ) {
110            throw $this->getBadTokenException( 'rest-badtoken-missing' );
111        } elseif ( $isAnon && !$this->getSession()->isPersistent() ) {
112            // The client probably forgot to authenticate.
113            throw $this->getBadTokenException( 'rest-badtoken-nosession' );
114        } else {
115            // The user submitted a token, the session had a token, but they didn't match.
116            throw new LocalizedHttpException( $this->getBadTokenMessage(), 403 );
117        }
118    }
119
120    /**
121     * @param string $messageKey
122     * @return LocalizedHttpException
123     * @internal For use by the trait only
124     */
125    private function getBadTokenException( string $messageKey ): LocalizedHttpException {
126        return new LocalizedHttpException( DataMessageValue::new( $messageKey, [], 'rest-badtoken' ), 403 );
127    }
128}