@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

Be more strict about "Location:" redirects

Summary:
Via HackerOne. Chrome (at least) interprets backslashes like forward slashes, so a redirect to "/\evil.com" is the same as a redirect to "//evil.com".

- Reject local URIs with backslashes (we never generate these).
- Fully-qualify all "Location:" redirects.
- Require external redirects to be marked explicitly.

Test Plan:
- Expanded existing test coverage.
- Verified that neither Diffusion nor Phriction can generate URIs with backslashes (they are escaped in Diffusion, and removed by slugging in Phriction).
- Logged in with Facebook (OAuth2 submits a form to the external site, and isn't affected) and Twitter (OAuth1 redirects, and is affected).
- Went through some local redirects (login, save-an-object).
- Verified file still work.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

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

+183 -6
+2
src/__phutil_library_map__.php
··· 76 76 'AphrontProgressBarView' => 'view/widget/bars/AphrontProgressBarView.php', 77 77 'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php', 78 78 'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php', 79 + 'AphrontRedirectResponseTestCase' => 'aphront/response/__tests__/AphrontRedirectResponseTestCase.php', 79 80 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php', 80 81 'AphrontRequest' => 'aphront/AphrontRequest.php', 81 82 'AphrontRequestFailureView' => 'view/page/AphrontRequestFailureView.php', ··· 2836 2837 'AphrontProgressBarView' => 'AphrontBarView', 2837 2838 'AphrontProxyResponse' => 'AphrontResponse', 2838 2839 'AphrontRedirectResponse' => 'AphrontResponse', 2840 + 'AphrontRedirectResponseTestCase' => 'PhabricatorTestCase', 2839 2841 'AphrontReloadResponse' => 'AphrontRedirectResponse', 2840 2842 'AphrontRequestFailureView' => 'AphrontView', 2841 2843 'AphrontRequestTestCase' => 'PhabricatorTestCase',
+80 -2
src/aphront/response/AphrontRedirectResponse.php
··· 7 7 8 8 private $uri; 9 9 private $stackWhenCreated; 10 + private $isExternal; 11 + 12 + public function setIsExternal($external) { 13 + $this->isExternal = $external; 14 + return $this; 15 + } 10 16 11 17 public function __construct() { 12 18 if ($this->shouldStopForDebugging()) { ··· 21 27 } 22 28 23 29 public function getURI() { 24 - return (string)$this->uri; 30 + // NOTE: When we convert a RedirectResponse into an AjaxResponse, we pull 31 + // the URI through this method. Make sure it passes checks before we 32 + // hand it over to callers. 33 + return self::getURIForRedirect($this->uri, $this->isExternal); 25 34 } 26 35 27 36 public function shouldStopForDebugging() { ··· 31 40 public function getHeaders() { 32 41 $headers = array(); 33 42 if (!$this->shouldStopForDebugging()) { 34 - $headers[] = array('Location', $this->uri); 43 + $uri = self::getURIForRedirect($this->uri, $this->isExternal); 44 + $headers[] = array('Location', $uri); 35 45 } 36 46 $headers = array_merge(parent::getHeaders(), $headers); 37 47 return $headers; ··· 83 93 } 84 94 85 95 return ''; 96 + } 97 + 98 + 99 + /** 100 + * Format a URI for use in a "Location:" header. 101 + * 102 + * Verifies that a URI redirects to the expected type of resource (local or 103 + * remote) and formats it for use in a "Location:" header. 104 + * 105 + * The HTTP spec says "Location:" headers must use absolute URIs. Although 106 + * browsers work with relative URIs, we return absolute URIs to avoid 107 + * ambiguity. For example, Chrome interprets "Location: /\evil.com" to mean 108 + * "perform a protocol-relative redirect to evil.com". 109 + * 110 + * @param string URI to redirect to. 111 + * @param bool True if this URI identifies a remote resource. 112 + * @return string URI for use in a "Location:" header. 113 + */ 114 + public static function getURIForRedirect($uri, $is_external) { 115 + $uri_object = new PhutilURI($uri); 116 + if ($is_external) { 117 + // If this is a remote resource it must have a domain set. This 118 + // would also be caught below, but testing for it explicitly first allows 119 + // us to raise a better error message. 120 + if (!strlen($uri_object->getDomain())) { 121 + throw new Exception( 122 + pht( 123 + 'Refusing to redirect to external URI "%s". This URI '. 124 + 'is not fully qualified, and is missing a domain name. To '. 125 + 'redirect to a local resource, remove the external flag.', 126 + (string)$uri)); 127 + } 128 + 129 + // Check that it's a valid remote resource. 130 + if (!PhabricatorEnv::isValidRemoteWebResource($uri)) { 131 + throw new Exception( 132 + pht( 133 + 'Refusing to redirect to external URI "%s". This URI '. 134 + 'is not a valid remote web resource.', 135 + (string)$uri)); 136 + } 137 + } else { 138 + // If this is a local resource, it must not have a domain set. This allows 139 + // us to raise a better error message than the check below can. 140 + if (strlen($uri_object->getDomain())) { 141 + throw new Exception( 142 + pht( 143 + 'Refusing to redirect to local resource "%s". The URI has a '. 144 + 'domain, but the redirect is not marked external. Mark '. 145 + 'redirects as external to allow redirection off the local '. 146 + 'domain.', 147 + (string)$uri)); 148 + } 149 + 150 + // If this is a local resource, it must be a valid local resource. 151 + if (!PhabricatorEnv::isValidLocalWebResource($uri)) { 152 + throw new Exception( 153 + pht( 154 + 'Refusing to redirect to local resource "%s". This URI is not '. 155 + 'formatted in a recognizable way.', 156 + (string)$uri)); 157 + } 158 + 159 + // Fully qualify the result URI. 160 + $uri = PhabricatorEnv::getURI((string)$uri); 161 + } 162 + 163 + return (string)$uri; 86 164 } 87 165 88 166 }
+63
src/aphront/response/__tests__/AphrontRedirectResponseTestCase.php
··· 1 + <?php 2 + 3 + final class AphrontRedirectResponseTestCase extends PhabricatorTestCase { 4 + 5 + public function testLocalRedirectURIs() { 6 + // Format a bunch of URIs for local and remote redirection, making sure 7 + // we get the expected results. 8 + 9 + $uris = array( 10 + '/a' => array( 11 + 'http://phabricator.example.com/a', 12 + false, 13 + ), 14 + 'a' => array( 15 + false, 16 + false, 17 + ), 18 + '/\\evil.com' => array( 19 + false, 20 + false, 21 + ), 22 + 'http://www.evil.com/' => array( 23 + false, 24 + 'http://www.evil.com/', 25 + ), 26 + '//evil.com' => array( 27 + false, 28 + false, 29 + ), 30 + '//' => array( 31 + false, 32 + false, 33 + ), 34 + '' => array( 35 + false, 36 + false, 37 + ), 38 + ); 39 + 40 + foreach ($uris as $input => $cases) { 41 + foreach (array(false, true) as $idx => $is_external) { 42 + $expect = $cases[$idx]; 43 + 44 + $caught = null; 45 + try { 46 + $result = AphrontRedirectResponse::getURIForRedirect( 47 + $input, 48 + $is_external); 49 + } catch (Exception $ex) { 50 + $caught = $ex; 51 + } 52 + 53 + if ($expect === false) { 54 + $this->assertTrue(($caught instanceof Exception), $input); 55 + } else { 56 + $this->assertEqual(null, $caught, $input); 57 + $this->assertEqual($expect, $result, $input); 58 + } 59 + } 60 + } 61 + } 62 + 63 + }
+3 -1
src/applications/auth/provider/PhabricatorOAuth1AuthProvider.php
··· 62 62 $client_code, 63 63 $adapter->getTokenSecret()); 64 64 65 - $response = id(new AphrontRedirectResponse())->setURI($uri); 65 + $response = id(new AphrontRedirectResponse()) 66 + ->setIsExternal(true) 67 + ->setURI($uri); 66 68 return array($account, $response); 67 69 } 68 70
+6
src/applications/files/controller/PhabricatorFileDataController.php
··· 74 74 // if the user can see the file, generate a token; 75 75 // redirect to the alt domain with the token; 76 76 return id(new AphrontRedirectResponse()) 77 + ->setIsExternal(true) 77 78 ->setURI($file->getCDNURIWithToken()); 78 79 79 80 } else { ··· 128 129 // file cannot be served via cdn, and no token given 129 130 // redirect to the main domain to aquire a token 130 131 132 + // This is marked as an "external" URI because it is fully qualified. 131 133 return id(new AphrontRedirectResponse()) 134 + ->setIsExternal(true) 132 135 ->setURI($acquire_token_uri); 133 136 } 134 137 } ··· 171 174 // authenticate users on the file domain. This should blunt any 172 175 // attacks based on iframes, script tags, applet tags, etc., at least. 173 176 // Send the user to the "info" page if they're using some other method. 177 + 178 + // This is marked as "external" because it is fully qualified. 174 179 return id(new AphrontRedirectResponse()) 180 + ->setIsExternal(true) 175 181 ->setURI(PhabricatorEnv::getProductionURI($file->getBestURI())); 176 182 } 177 183 $response->setMimeType($file->getMimeType());
+3 -1
src/applications/phortune/provider/PhortunePaypalPaymentProvider.php
··· 116 116 'token' => $result['TOKEN'], 117 117 )); 118 118 119 - return id(new AphrontRedirectResponse())->setURI($uri); 119 + return id(new AphrontRedirectResponse()) 120 + ->setIsExternal(true) 121 + ->setURI($uri); 120 122 case 'charge': 121 123 var_dump($_REQUEST); 122 124 break;
+6 -2
src/applications/phortune/provider/PhortuneWePayPaymentProvider.php
··· 149 149 // user might not end up back here. Really this needs a bunch of junk. 150 150 151 151 $uri = new PhutilURI($result->checkout_uri); 152 - return id(new AphrontRedirectResponse())->setURI($uri); 152 + return id(new AphrontRedirectResponse()) 153 + ->setIsExternal(true) 154 + ->setURI($uri); 153 155 case 'charge': 154 156 $checkout_id = $request->getInt('checkout_id'); 155 157 $params = array( ··· 195 197 196 198 unset($unguarded); 197 199 198 - return id(new AphrontRedirectResponse())->setURI($cart_uri); 200 + return id(new AphrontRedirectResponse()) 201 + ->setIsExternal(true) 202 + ->setURI($cart_uri); 199 203 case 'cancel': 200 204 var_dump($_REQUEST); 201 205 break;
+15
src/infrastructure/env/PhabricatorEnv.php
··· 462 462 return false; 463 463 } 464 464 465 + // Chrome (at a minimum) interprets backslashes in Location headers and the 466 + // URL bar as forward slashes. This is probably intended to reduce user 467 + // error caused by confusion over which key is "forward slash" vs "back 468 + // slash". 469 + // 470 + // However, it means a URI like "/\evil.com" is interpreted like 471 + // "//evil.com", which is a protocol relative remote URI. 472 + // 473 + // Since we currently never generate URIs with backslashes in them, reject 474 + // these unconditionally rather than trying to figure out how browsers will 475 + // interpret them. 476 + if (preg_match('/\\\\/', $uri)) { 477 + return false; 478 + } 479 + 465 480 // Valid URIs must begin with '/', followed by the end of the string or some 466 481 // other non-'/' character. This rejects protocol-relative URIs like 467 482 // "//evil.com/evil_stuff/".
+1
src/infrastructure/env/__tests__/PhabricatorEnvTestCase.php
··· 13 13 'javascript:lol' => false, 14 14 '' => false, 15 15 null => false, 16 + '/\\evil.com' => false, 16 17 ); 17 18 18 19 foreach ($map as $uri => $expect) {
+4
src/infrastructure/testing/PhabricatorTestCase.php
··· 118 118 119 119 // TODO: Remove this when we remove "releeph.installed". 120 120 $this->env->overrideEnvConfig('releeph.installed', true); 121 + 122 + $this->env->overrideEnvConfig( 123 + 'phabricator.base-uri', 124 + 'http://phabricator.example.com'); 121 125 } 122 126 123 127 protected function didRunTests() {