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