1<?php
2/**
3 * Matomo - free/libre analytics platform
4 *
5 * @link https://matomo.org
6 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
7 *
8 */
9namespace Piwik;
10
11use Piwik\Session\SessionNamespace;
12
13/**
14 * Nonce class.
15 *
16 * A cryptographic nonce -- "number used only once" -- is often recommended as
17 * part of a robust defense against cross-site request forgery (CSRF/XSRF). This
18 * class provides static methods that create and manage nonce values.
19 *
20 * Nonces in Piwik are stored as a session variable and have a configurable expiration.
21 *
22 * Learn more about nonces [here](http://en.wikipedia.org/wiki/Cryptographic_nonce).
23 *
24 * @api
25 */
26class Nonce
27{
28    /**
29     * Returns an existing nonce by ID. If none exists, a new nonce will be generated.
30     *
31     * @param string $id Unique id to avoid namespace conflicts, e.g., `'ModuleName.ActionName'`.
32     * @param int $ttl Optional time-to-live in seconds; default is 5 minutes. (ie, in 5 minutes,
33     *                 the nonce will no longer be valid).
34     * @return string
35     */
36    public static function getNonce($id, $ttl = 600)
37    {
38        // save session-dependent nonce
39        $ns = new SessionNamespace($id);
40        $nonce = $ns->nonce;
41
42        // re-use an unexpired nonce (a small deviation from the "used only once" principle, so long as we do not reset the expiration)
43        // to handle browser pre-fetch or double fetch caused by some browser add-ons/extensions
44        if (empty($nonce)) {
45            // generate a new nonce
46            $nonce = md5(SettingsPiwik::getSalt() . time() . Common::generateUniqId());
47            $ns->nonce = $nonce;
48        }
49
50        // extend lifetime if nonce is requested again to prevent from early timeout if nonce is requested again
51        // a few seconds before timeout
52        $ns->setExpirationSeconds($ttl, 'nonce');
53
54        return $nonce;
55    }
56
57    /**
58     * Returns if a nonce is valid and comes from a valid request.
59     *
60     * A nonce is valid if it matches the current nonce and if the current nonce
61     * has not expired.
62     *
63     * The request is valid if the referrer is a local URL (see {@link Url::isLocalUrl()})
64     * and if the HTTP origin is valid (see {@link getAcceptableOrigins()}).
65     *
66     * @param string $id The nonce's unique ID. See {@link getNonce()}.
67     * @param string $cnonce Nonce sent from client.
68     * @param null|string $expectedReferrerHost The expected referrer host for the HTTP referrer URL.
69     * @return bool `true` if valid; `false` otherwise.
70     */
71    public static function verifyNonce($id, $cnonce, $expectedReferrerHost = null)
72    {
73        // load error with message function.
74        $error = self::verifyNonceWithErrorMessage($id, $cnonce, $expectedReferrerHost);
75        return $error === "";
76    }
77
78    /**
79     * Returns error message
80     *
81     * A nonce is valid if it matches the current nonce and if the current nonce
82     * has not expired.
83     *
84     * The request is valid if the referrer is a local URL (see {@link Url::isLocalUrl()})
85     * and if the HTTP origin is valid (see {@link getAcceptableOrigins()}).
86     *
87     * @param string $id The nonce's unique ID. See {@link getNonce()}.
88     * @param string $cnonce Nonce sent from client.
89     * @param null $expectedReferrerHost The expected referrer host for the HTTP referrer URL.
90     * @return string if empty is valid otherwise return error message
91     */
92    public static function verifyNonceWithErrorMessage($id, $cnonce, $expectedReferrerHost = null)
93    {
94        $ns = new SessionNamespace($id);
95        $nonce = $ns->nonce;
96
97        $additionalErrors = '';
98
99        //  The Session cookie is set to a secure cookie, when SSL is mis-configured, it can cause the PHP session cookie ID to change on each page view.
100        //  Indicate to user how to solve this particular use case by forcing secure connections.
101        if (Url::isSecureConnectionAssumedByPiwikButNotForcedYet()) {
102            $additionalErrors =  '<br/><br/>' . Piwik::translate('Login_InvalidNonceSSLMisconfigured',
103                array(
104                  '<a target="_blank" rel="noreferrer noopener" href="https://matomo.org/faq/how-to/faq_91/">',
105                  '</a>',
106                  'config/config.ini.php',
107                  '<pre>force_ssl=1</pre>',
108                  '<pre>[General]</pre>',
109                )
110              );
111        }
112
113        // validate token
114        if (empty($cnonce) || $cnonce !== $nonce) {
115            return Piwik::translate('Login_InvalidNonceToken');
116        }
117
118        // validate referrer
119        $referrer = Url::getReferrer();
120        if (empty($expectedReferrerHost) && !empty($referrer) && !Url::isLocalUrl($referrer)) {
121            return Piwik::translate('Login_InvalidNonceReferrer', array(
122              '<a target="_blank" rel="noreferrer noopener" href="https://matomo.org/faq/how-to-install/#faq_98">',
123              '</a>'
124              )) . $additionalErrors;
125        }
126
127        //referrer is different expected host
128        if (!empty($expectedReferrerHost) && !self::isReferrerHostValid($referrer, $expectedReferrerHost)) {
129            return Piwik::translate('Login_InvalidNonceUnexpectedReferrer') . $additionalErrors;
130        }
131
132        // validate origin
133        $origin = self::getOrigin();
134        if (!empty($origin) &&
135          ($origin == 'null'
136            || !in_array($origin, self::getAcceptableOrigins()))
137        ) {
138            return Piwik::translate('Login_InvalidNonceOrigin') . $additionalErrors;
139        }
140
141        return '';
142    }
143
144    // public for tests
145    public static function isReferrerHostValid($referrer, $expectedReferrerHost)
146    {
147        if (empty($referrer)) {
148            return false;
149        }
150
151        $referrerHost = Url::getHostFromUrl($referrer);
152        return preg_match('/(^|\.)' . preg_quote($expectedReferrerHost) . '$/i', $referrerHost);
153    }
154
155    /**
156     * Force expiration of the current nonce.
157     *
158     * @param string $id The unique nonce ID.
159     */
160    public static function discardNonce($id)
161    {
162        $ns = new SessionNamespace($id);
163        $ns->unsetAll();
164    }
165
166    /**
167     * Returns the **Origin** HTTP header or `false` if not found.
168     *
169     * @return string|bool
170     */
171    public static function getOrigin()
172    {
173        if (!empty($_SERVER['HTTP_ORIGIN'])) {
174            return $_SERVER['HTTP_ORIGIN'];
175        }
176        return false;
177    }
178
179    /**
180     * Returns a list acceptable values for the HTTP **Origin** header.
181     *
182     * @return array
183     */
184    public static function getAcceptableOrigins()
185    {
186        $host = Url::getCurrentHost(null);
187
188        if (empty($host)) {
189            return array();
190        }
191
192        // parse host:port
193        if (preg_match('/^([^:]+):([0-9]+)$/D', $host, $matches)) {
194            $host = $matches[1];
195            $port = $matches[2];
196            $origins = array(
197                'http://' . $host,
198                'https://' . $host,
199            );
200            if ($port != 443) {
201                $origins[] = 'http://' . $host .':' . $port;
202            }
203            $origins[] = 'https://' . $host . ':' . $port;
204        } elseif (Config::getInstance()->General['force_ssl']) {
205            $origins = array(
206                'https://' . $host,
207                'https://' . $host . ':443',
208            );
209        } else {
210            $origins = array(
211                'http://' . $host,
212                'https://' . $host,
213                'http://' . $host . ':80',
214                'https://' . $host . ':443',
215            );
216        }
217
218        return $origins;
219    }
220
221    /**
222     * Verifies and discards a nonce.
223     *
224     * @param string $nonceName The nonce's unique ID. See {@link getNonce()}.
225     * @param string|null $nonce The nonce from the client. If `null`, the value from the
226     *                           **nonce** query parameter is used.
227     * @throws \Exception if the nonce is invalid. See {@link verifyNonce()}.
228     */
229    public static function checkNonce($nonceName, $nonce = null, $expectedReferrerHost = null)
230    {
231        if ($nonce === null) {
232            $nonce = Common::getRequestVar('nonce', null, 'string');
233        }
234
235        if (!self::verifyNonce($nonceName, $nonce, $expectedReferrerHost)) {
236            throw new \Exception(Piwik::translate('General_ExceptionNonceMismatch'));
237        }
238
239        self::discardNonce($nonceName);
240    }
241}
242