@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Remove "phabricator.csrf-key" and upgrade CSRF hashing to SHA256

Summary:
Ref T12509.

- Remove the "phabricator.csrf-key" configuration option in favor of automatically generating an HMAC key.
- Upgrade two hasher callsites (one in CSRF itself, one in providing a CSRF secret for logged-out users) to SHA256.
- Extract the CSRF logic from `PhabricatorUser` to a standalone engine.

I was originally going to do this as two changes (extract logic, then upgrade hashes) but the logic had a couple of very silly pieces to it that made faithful extraction a little silly.

For example, it computed `time_block = (epoch + (offset * cycle_frequency)) / cycle_frequency` instead of `time_block = (epoch / cycle_frequency) + offset`. These are equivalent but the former was kind of silly.

It also computed `substr(hmac(substr(hmac(secret)).salt))` instead of `substr(hmac(secret.salt))`. These have the same overall effect but the former is, again, kind of silly (and a little bit materially worse, in this case).

This will cause a one-time compatibility break: pages loaded before the upgrade won't be able to submit contained forms after the upgrade, unless they're open for long enough for the Javascript to refresh the CSRF token (an hour, I think?). I'll note this in the changelog.

Test Plan:
- As a logged-in user, submitted forms normally (worked).
- As a logged-in user, submitted forms with a bad CSRF value (error, as expected).
- As a logged-out user, hit the success and error cases.
- Visually inspected tokens for correct format.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T12509

Differential Revision: https://secure.phabricator.com/D19946

+178 -128
+2
src/__phutil_library_map__.php
··· 2190 2190 'PhabricatorAuthApplication' => 'applications/auth/application/PhabricatorAuthApplication.php', 2191 2191 'PhabricatorAuthAuthFactorPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthFactorPHIDType.php', 2192 2192 'PhabricatorAuthAuthProviderPHIDType' => 'applications/auth/phid/PhabricatorAuthAuthProviderPHIDType.php', 2193 + 'PhabricatorAuthCSRFEngine' => 'applications/auth/engine/PhabricatorAuthCSRFEngine.php', 2193 2194 'PhabricatorAuthChallenge' => 'applications/auth/storage/PhabricatorAuthChallenge.php', 2194 2195 'PhabricatorAuthChallengeGarbageCollector' => 'applications/auth/garbagecollector/PhabricatorAuthChallengeGarbageCollector.php', 2195 2196 'PhabricatorAuthChallengePHIDType' => 'applications/auth/phid/PhabricatorAuthChallengePHIDType.php', ··· 7828 7829 'PhabricatorAuthApplication' => 'PhabricatorApplication', 7829 7830 'PhabricatorAuthAuthFactorPHIDType' => 'PhabricatorPHIDType', 7830 7831 'PhabricatorAuthAuthProviderPHIDType' => 'PhabricatorPHIDType', 7832 + 'PhabricatorAuthCSRFEngine' => 'Phobject', 7831 7833 'PhabricatorAuthChallenge' => array( 7832 7834 'PhabricatorAuthDAO', 7833 7835 'PhabricatorPolicyInterface',
+119
src/applications/auth/engine/PhabricatorAuthCSRFEngine.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthCSRFEngine extends Phobject { 4 + 5 + private $salt; 6 + private $secret; 7 + 8 + public function setSalt($salt) { 9 + $this->salt = $salt; 10 + return $this; 11 + } 12 + 13 + public function getSalt() { 14 + return $this->salt; 15 + } 16 + 17 + public function setSecret(PhutilOpaqueEnvelope $secret) { 18 + $this->secret = $secret; 19 + return $this; 20 + } 21 + 22 + public function getSecret() { 23 + return $this->secret; 24 + } 25 + 26 + public function newSalt() { 27 + $salt_length = $this->getSaltLength(); 28 + return Filesystem::readRandomCharacters($salt_length); 29 + } 30 + 31 + public function newToken() { 32 + $salt = $this->getSalt(); 33 + 34 + if (!$salt) { 35 + throw new PhutilInvalidStateException('setSalt'); 36 + } 37 + 38 + $token = $this->newRawToken($salt); 39 + $prefix = $this->getBREACHPrefix(); 40 + 41 + return sprintf('%s%s%s', $prefix, $salt, $token); 42 + } 43 + 44 + public function isValidToken($token) { 45 + $salt_length = $this->getSaltLength(); 46 + 47 + // We expect a BREACH-mitigating token. See T3684. 48 + $breach_prefix = $this->getBREACHPrefix(); 49 + $breach_prelen = strlen($breach_prefix); 50 + if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) { 51 + return false; 52 + } 53 + 54 + $salt = substr($token, $breach_prelen, $salt_length); 55 + $token = substr($token, $breach_prelen + $salt_length); 56 + 57 + foreach ($this->getWindowOffsets() as $offset) { 58 + $expect_token = $this->newRawToken($salt, $offset); 59 + if (phutil_hashes_are_identical($expect_token, $token)) { 60 + return true; 61 + } 62 + } 63 + 64 + return false; 65 + } 66 + 67 + private function newRawToken($salt, $offset = 0) { 68 + $now = PhabricatorTime::getNow(); 69 + $cycle_frequency = $this->getCycleFrequency(); 70 + 71 + $time_block = (int)floor($now / $cycle_frequency); 72 + $time_block = $time_block + $offset; 73 + 74 + $secret = $this->getSecret(); 75 + if (!$secret) { 76 + throw new PhutilInvalidStateException('setSecret'); 77 + } 78 + $secret = $secret->openEnvelope(); 79 + 80 + $hash = PhabricatorHash::digestWithNamedKey( 81 + $secret.$time_block.$salt, 82 + 'csrf'); 83 + 84 + return substr($hash, 0, $this->getTokenLength()); 85 + } 86 + 87 + private function getBREACHPrefix() { 88 + return 'B@'; 89 + } 90 + 91 + private function getSaltLength() { 92 + return 8; 93 + } 94 + 95 + private function getTokenLength() { 96 + return 16; 97 + } 98 + 99 + private function getCycleFrequency() { 100 + return phutil_units('1 hour in seconds'); 101 + } 102 + 103 + private function getWindowOffsets() { 104 + // We accept some tokens from the recent past and near future. Users may 105 + // have older tokens if they close their laptop and open it up again 106 + // later. Users may have newer tokens if there are multiple web hosts with 107 + // a bit of clock skew. 108 + 109 + // Javascript on the client tries to keep CSRF tokens up to date, but 110 + // it may fail, and it doesn't run if the user closes their laptop. 111 + 112 + // The window during which our tokens remain valid is generally more 113 + // conservative than other platforms. For example, Rails uses "session 114 + // duration" and Django uses "forever". 115 + 116 + return range(-6, 1); 117 + } 118 + 119 + }
+2 -1
src/applications/base/controller/PhabricatorController.php
··· 98 98 99 99 100 100 if (!$user->isLoggedIn()) { 101 - $user->attachAlternateCSRFString(PhabricatorHash::weakDigest($phsid)); 101 + $csrf = PhabricatorHash::digestWithNamedKey($phsid, 'csrf.alternate'); 102 + $user->attachAlternateCSRFString($csrf); 102 103 } 103 104 104 105 $request->setUser($user);
+3
src/applications/config/check/PhabricatorExtraConfigSetupCheck.php
··· 388 388 389 389 'metamta.mail-key' => pht( 390 390 'Mail object address hash keys are now generated automatically.'), 391 + 392 + 'phabricator.csrf-key' => pht( 393 + 'CSRF HMAC keys are now managed automatically.'), 391 394 ); 392 395 393 396 return $ancient_config;
-15
src/applications/config/option/PhabricatorSecurityConfigOptions.php
··· 154 154 pht('Multi-Factor Required'), 155 155 pht('Multi-Factor Optional'), 156 156 )), 157 - $this->newOption( 158 - 'phabricator.csrf-key', 159 - 'string', 160 - '0b7ec0592e0a2829d8b71df2fa269b2c6172eca3') 161 - ->setHidden(true) 162 - ->setSummary( 163 - pht('Hashed with other inputs to generate CSRF tokens.')) 164 - ->setDescription( 165 - pht( 166 - 'This is hashed with other inputs to generate CSRF tokens. If '. 167 - 'you want, you can change it to some other string which is '. 168 - 'unique to your install. This will make your install more secure '. 169 - 'in a vague, mostly theoretical way. But it will take you like 3 '. 170 - 'seconds of mashing on your keyboard to set it up so you might '. 171 - 'as well.')), 172 157 $this->newOption( 173 158 'uri.allowed-protocols', 174 159 'set',
+52 -112
src/applications/people/storage/PhabricatorUser.php
··· 318 318 return Filesystem::readRandomCharacters(255); 319 319 } 320 320 321 - const CSRF_CYCLE_FREQUENCY = 3600; 322 - const CSRF_SALT_LENGTH = 8; 323 - const CSRF_TOKEN_LENGTH = 16; 324 - const CSRF_BREACH_PREFIX = 'B@'; 325 - 326 321 const EMAIL_CYCLE_FREQUENCY = 86400; 327 322 const EMAIL_TOKEN_LENGTH = 24; 328 - 329 - private function getRawCSRFToken($offset = 0) { 330 - return $this->generateToken( 331 - time() + (self::CSRF_CYCLE_FREQUENCY * $offset), 332 - self::CSRF_CYCLE_FREQUENCY, 333 - PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), 334 - self::CSRF_TOKEN_LENGTH); 335 - } 336 - 337 - public function getCSRFToken() { 338 - if ($this->isOmnipotent()) { 339 - // We may end up here when called from the daemons. The omnipotent user 340 - // has no meaningful CSRF token, so just return `null`. 341 - return null; 342 - } 343 - 344 - if ($this->csrfSalt === null) { 345 - $this->csrfSalt = Filesystem::readRandomCharacters( 346 - self::CSRF_SALT_LENGTH); 347 - } 348 - 349 - $salt = $this->csrfSalt; 350 - 351 - // Generate a token hash to mitigate BREACH attacks against SSL. See 352 - // discussion in T3684. 353 - $token = $this->getRawCSRFToken(); 354 - $hash = PhabricatorHash::weakDigest($token, $salt); 355 - return self::CSRF_BREACH_PREFIX.$salt.substr( 356 - $hash, 0, self::CSRF_TOKEN_LENGTH); 357 - } 358 - 359 - public function validateCSRFToken($token) { 360 - // We expect a BREACH-mitigating token. See T3684. 361 - $breach_prefix = self::CSRF_BREACH_PREFIX; 362 - $breach_prelen = strlen($breach_prefix); 363 - if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) { 364 - return false; 365 - } 366 - 367 - $salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH); 368 - $token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH); 369 - 370 - // When the user posts a form, we check that it contains a valid CSRF token. 371 - // Tokens cycle each hour (every CSRF_CYCLE_FREQUENCY seconds) and we accept 372 - // either the current token, the next token (users can submit a "future" 373 - // token if you have two web frontends that have some clock skew) or any of 374 - // the last 6 tokens. This means that pages are valid for up to 7 hours. 375 - // There is also some Javascript which periodically refreshes the CSRF 376 - // tokens on each page, so theoretically pages should be valid indefinitely. 377 - // However, this code may fail to run (if the user loses their internet 378 - // connection, or there's a JS problem, or they don't have JS enabled). 379 - // Choosing the size of the window in which we accept old CSRF tokens is 380 - // an issue of balancing concerns between security and usability. We could 381 - // choose a very narrow (e.g., 1-hour) window to reduce vulnerability to 382 - // attacks using captured CSRF tokens, but it's also more likely that real 383 - // users will be affected by this, e.g. if they close their laptop for an 384 - // hour, open it back up, and try to submit a form before the CSRF refresh 385 - // can kick in. Since the user experience of submitting a form with expired 386 - // CSRF is often quite bad (you basically lose data, or it's a big pain to 387 - // recover at least) and I believe we gain little additional protection 388 - // by keeping the window very short (the overwhelming value here is in 389 - // preventing blind attacks, and most attacks which can capture CSRF tokens 390 - // can also just capture authentication information [sniffing networks] 391 - // or act as the user [xss]) the 7 hour default seems like a reasonable 392 - // balance. Other major platforms have much longer CSRF token lifetimes, 393 - // like Rails (session duration) and Django (forever), which suggests this 394 - // is a reasonable analysis. 395 - $csrf_window = 6; 396 - 397 - for ($ii = -$csrf_window; $ii <= 1; $ii++) { 398 - $valid = $this->getRawCSRFToken($ii); 399 - 400 - $digest = PhabricatorHash::weakDigest($valid, $salt); 401 - $digest = substr($digest, 0, self::CSRF_TOKEN_LENGTH); 402 - if (phutil_hashes_are_identical($digest, $token)) { 403 - return true; 404 - } 405 - } 406 - 407 - return false; 408 - } 409 - 410 - private function generateToken($epoch, $frequency, $key, $len) { 411 - if ($this->getPHID()) { 412 - $vec = $this->getPHID().$this->getAccountSecret(); 413 - } else { 414 - $vec = $this->getAlternateCSRFString(); 415 - } 416 - 417 - if ($this->hasSession()) { 418 - $vec = $vec.$this->getSession()->getSessionKey(); 419 - } 420 - 421 - $time_block = floor($epoch / $frequency); 422 - $vec = $vec.$key.$time_block; 423 - 424 - return substr(PhabricatorHash::weakDigest($vec), 0, $len); 425 - } 426 323 427 324 public function getUserProfile() { 428 325 return $this->assertAttached($this->profile); ··· 621 518 } 622 519 623 520 return (string)$uri; 624 - } 625 - 626 - public function getAlternateCSRFString() { 627 - return $this->assertAttached($this->alternateCSRFString); 628 - } 629 - 630 - public function attachAlternateCSRFString($string) { 631 - $this->alternateCSRFString = $string; 632 - return $this; 633 521 } 634 522 635 523 /** ··· 1214 1102 1215 1103 public function getBadgePHIDs() { 1216 1104 return $this->assertAttached($this->badgePHIDs); 1105 + } 1106 + 1107 + /* -( CSRF )--------------------------------------------------------------- */ 1108 + 1109 + 1110 + public function getCSRFToken() { 1111 + if ($this->isOmnipotent()) { 1112 + // We may end up here when called from the daemons. The omnipotent user 1113 + // has no meaningful CSRF token, so just return `null`. 1114 + return null; 1115 + } 1116 + 1117 + return $this->newCSRFEngine() 1118 + ->newToken(); 1119 + } 1120 + 1121 + public function validateCSRFToken($token) { 1122 + return $this->newCSRFengine() 1123 + ->isValidToken($token); 1124 + } 1125 + 1126 + public function getAlternateCSRFString() { 1127 + return $this->assertAttached($this->alternateCSRFString); 1128 + } 1129 + 1130 + public function attachAlternateCSRFString($string) { 1131 + $this->alternateCSRFString = $string; 1132 + return $this; 1133 + } 1134 + 1135 + private function newCSRFEngine() { 1136 + if ($this->getPHID()) { 1137 + $vec = $this->getPHID().$this->getAccountSecret(); 1138 + } else { 1139 + $vec = $this->getAlternateCSRFString(); 1140 + } 1141 + 1142 + if ($this->hasSession()) { 1143 + $vec = $vec.$this->getSession()->getSessionKey(); 1144 + } 1145 + 1146 + $engine = new PhabricatorAuthCSRFEngine(); 1147 + 1148 + if ($this->csrfSalt === null) { 1149 + $this->csrfSalt = $engine->newSalt(); 1150 + } 1151 + 1152 + $engine 1153 + ->setSalt($this->csrfSalt) 1154 + ->setSecret(new PhutilOpaqueEnvelope($vec)); 1155 + 1156 + return $engine; 1217 1157 } 1218 1158 1219 1159