@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.

Never generate file download forms which point to the CDN domain, tighten "form-action" CSP

Summary:
Depends on D19155. Ref T13094. Ref T4340.

We can't currently implement a strict `form-action 'self'` content security policy because some file downloads rely on a `<form />` which sometimes POSTs to the CDN domain.

Broadly, stop generating these forms. We just redirect instead, and show an interstitial confirm dialog if no CDN domain is configured. This makes the UX for installs with no CDN domain a little worse and the UX for everyone else better.

Then, implement the stricter Content-Security-Policy.

This also removes extra confirm dialogs for downloading Harbormaster build logs and data exports.

Test Plan:
- Went through the plain data export, data export with bulk jobs, ssh key generation, calendar ICS download, Diffusion data, Paste data, Harbormaster log data, and normal file data download workflows with a CDN domain.
- Went through all those workflows again without a CDN domain.
- Grepped for affected symbols (`getCDNURI()`, `getDownloadURI()`).
- Added an evil form to a page, tried to submit it, was rejected.
- Went through the ReCaptcha and Stripe flows again to see if they're submitting any forms.

Subscribers: PHID-OPKG-gm6ozazyms6q6i22gyam

Maniphest Tasks: T13094, T4340

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

+118 -82
+14 -14
resources/celerity/map.php
··· 10 10 'conpherence.pkg.css' => 'e68cf1fa', 11 11 'conpherence.pkg.js' => '15191c65', 12 12 'core.pkg.css' => '2fa91e14', 13 - 'core.pkg.js' => '7aa5bd92', 13 + 'core.pkg.js' => 'e7ce7bba', 14 14 'darkconsole.pkg.js' => '1f9a31bc', 15 15 'differential.pkg.css' => '113e692c', 16 16 'differential.pkg.js' => 'f6d809c0', ··· 255 255 'rsrc/externals/javelin/lib/URI.js' => 'c989ade3', 256 256 'rsrc/externals/javelin/lib/Vector.js' => '2caa8fb8', 257 257 'rsrc/externals/javelin/lib/WebSocket.js' => '3ffe32d6', 258 - 'rsrc/externals/javelin/lib/Workflow.js' => '1e911d0f', 258 + 'rsrc/externals/javelin/lib/Workflow.js' => '0eb1db0c', 259 259 'rsrc/externals/javelin/lib/__tests__/Cookie.js' => '5ed109e8', 260 260 'rsrc/externals/javelin/lib/__tests__/DOM.js' => 'c984504b', 261 261 'rsrc/externals/javelin/lib/__tests__/JSON.js' => '837a7d68', ··· 757 757 'javelin-workboard-card' => 'c587b80f', 758 758 'javelin-workboard-column' => '758b4758', 759 759 'javelin-workboard-controller' => '26167537', 760 - 'javelin-workflow' => '1e911d0f', 760 + 'javelin-workflow' => '0eb1db0c', 761 761 'maniphest-report-css' => '9b9580b7', 762 762 'maniphest-task-edit-css' => 'fda62a9b', 763 763 'maniphest-task-summary-css' => '11cc5344', ··· 977 977 'javelin-dom', 978 978 'javelin-router', 979 979 ), 980 + '0eb1db0c' => array( 981 + 'javelin-stratcom', 982 + 'javelin-request', 983 + 'javelin-dom', 984 + 'javelin-vector', 985 + 'javelin-install', 986 + 'javelin-util', 987 + 'javelin-mask', 988 + 'javelin-uri', 989 + 'javelin-routable', 990 + ), 980 991 '0f764c35' => array( 981 992 'javelin-install', 982 993 'javelin-util', ··· 1034 1045 'javelin-vector', 1035 1046 'javelin-request', 1036 1047 'javelin-uri', 1037 - ), 1038 - '1e911d0f' => array( 1039 - 'javelin-stratcom', 1040 - 'javelin-request', 1041 - 'javelin-dom', 1042 - 'javelin-vector', 1043 - 'javelin-install', 1044 - 'javelin-util', 1045 - 'javelin-mask', 1046 - 'javelin-uri', 1047 - 'javelin-routable', 1048 1048 ), 1049 1049 '1f6794f6' => array( 1050 1050 'javelin-behavior',
+10
src/aphront/response/AphrontRedirectResponse.php
··· 8 8 private $uri; 9 9 private $stackWhenCreated; 10 10 private $isExternal; 11 + private $closeDialogBeforeRedirect; 11 12 12 13 public function setIsExternal($external) { 13 14 $this->isExternal = $external; ··· 35 36 36 37 public function shouldStopForDebugging() { 37 38 return PhabricatorEnv::getEnvConfig('debug.stop-on-redirect'); 39 + } 40 + 41 + public function setCloseDialogBeforeRedirect($close) { 42 + $this->closeDialogBeforeRedirect = $close; 43 + return $this; 44 + } 45 + 46 + public function getCloseDialogBeforeRedirect() { 47 + return $this->closeDialogBeforeRedirect; 38 48 } 39 49 40 50 public function getHeaders() {
+7
src/aphront/response/AphrontResponse.php
··· 147 147 // Block relics of the old world: Flash, Java applets, and so on. 148 148 $csp[] = "object-src 'none'"; 149 149 150 + // Don't allow forms to submit offsite. 151 + 152 + // This can result in some trickiness with file downloads if applications 153 + // try to start downloads by submitting a dialog. Redirect to the file's 154 + // download URI instead of submitting a form to it. 155 + $csp[] = "form-action 'self'"; 156 + 150 157 $csp = implode('; ', $csp); 151 158 152 159 return $csp;
+20 -10
src/applications/auth/controller/PhabricatorAuthSSHKeyGenerateController.php
··· 24 24 $keys = PhabricatorSSHKeyGenerator::generateKeypair(); 25 25 list($public_key, $private_key) = $keys; 26 26 27 + $key_name = $default_name.'.key'; 28 + 27 29 $file = PhabricatorFile::newFromFileData( 28 30 $private_key, 29 31 array( 30 - 'name' => $default_name.'.key', 32 + 'name' => $key_name, 31 33 'ttl.relative' => phutil_units('10 minutes in seconds'), 32 34 'viewPolicy' => $viewer->getPHID(), 33 35 )); ··· 62 64 ->setContentSourceFromRequest($request) 63 65 ->applyTransactions($key, $xactions); 64 66 65 - // NOTE: We're disabling workflow on submit so the download works. We're 66 - // disabling workflow on cancel so the page reloads, showing the new 67 - // key. 67 + $download_link = phutil_tag( 68 + 'a', 69 + array( 70 + 'href' => $file->getDownloadURI(), 71 + ), 72 + array( 73 + id(new PHUIIconView())->setIcon('fa-download'), 74 + ' ', 75 + pht('Download Private Key (%s)', $key_name), 76 + )); 77 + $download_link = phutil_tag('strong', array(), $download_link); 78 + 79 + // NOTE: We're disabling workflow on cancel so the page reloads, showing 80 + // the new key. 68 81 69 82 return $this->newDialog() 70 83 ->setTitle(pht('Download Private Key')) 71 - ->setDisableWorkflowOnCancel(true) 72 - ->setDisableWorkflowOnSubmit(true) 73 - ->setSubmitURI($file->getDownloadURI()) 74 84 ->appendParagraph( 75 85 pht( 76 86 'A keypair has been generated, and the public key has been '. 77 - 'added as a recognized key. Use the button below to download '. 78 - 'the private key.')) 87 + 'added as a recognized key.')) 88 + ->appendParagraph($download_link) 79 89 ->appendParagraph( 80 90 pht( 81 91 'After you download the private key, it will be destroyed. '. 82 92 'You will not be able to retrieve it if you lose your copy.')) 83 - ->addSubmitButton(pht('Download Private Key')) 93 + ->setDisableWorkflowOnCancel(true) 84 94 ->addCancelButton($cancel_uri, pht('Done')); 85 95 } 86 96
+1
src/applications/base/controller/PhabricatorController.php
··· 298 298 ->setContent( 299 299 array( 300 300 'redirect' => $response->getURI(), 301 + 'close' => $response->getCloseDialogBeforeRedirect(), 301 302 )); 302 303 } 303 304 }
+1 -1
src/applications/diffusion/controller/DiffusionServeController.php
··· 1046 1046 // <https://github.com/github/git-lfs/issues/1088> 1047 1047 $no_authorization = 'Basic '.base64_encode('none'); 1048 1048 1049 - $get_uri = $file->getCDNURI(); 1049 + $get_uri = $file->getCDNURI('data'); 1050 1050 $actions['download'] = array( 1051 1051 'href' => $get_uri, 1052 1052 'header' => array(
+2 -2
src/applications/files/application/PhabricatorFilesApplication.php
··· 101 101 102 102 private function getResourceSubroutes() { 103 103 return array( 104 - 'data/'. 104 + '(?P<kind>data|download)/'. 105 105 '(?:@(?P<instance>[^/]+)/)?'. 106 106 '(?P<key>[^/]+)/'. 107 107 '(?P<phid>[^/]+)/'. ··· 132 132 133 133 public function getQuicksandURIPatternBlacklist() { 134 134 return array( 135 - '/file/data/.*', 135 + '/file/(data|download)/.*', 136 136 ); 137 137 } 138 138
+19 -20
src/applications/files/controller/PhabricatorFileDataController.php
··· 26 26 $req_domain = $request->getHost(); 27 27 $main_domain = id(new PhutilURI($base_uri))->getDomain(); 28 28 29 + $request_kind = $request->getURIData('kind'); 30 + $is_download = ($request_kind === 'download'); 31 + 29 32 if (!strlen($alt) || $main_domain == $alt_domain) { 30 33 // No alternate domain. 31 34 $should_redirect = false; ··· 50 53 if ($should_redirect) { 51 54 return id(new AphrontRedirectResponse()) 52 55 ->setIsExternal(true) 53 - ->setURI($file->getCDNURI()); 56 + ->setURI($file->getCDNURI($request_kind)); 54 57 } 55 58 56 59 $response = new AphrontFileResponse(); ··· 71 74 } 72 75 73 76 $is_viewable = $file->isViewableInBrowser(); 74 - $force_download = $request->getExists('download'); 75 - 76 77 $request_type = $request->getHTTPHeader('X-Phabricator-Request-Type'); 77 78 $is_lfs = ($request_type == 'git-lfs'); 78 79 79 - if ($is_viewable && !$force_download) { 80 + if ($is_viewable && !$is_download) { 80 81 $response->setMimeType($file->getViewableMimeType()); 81 82 } else { 82 - $is_public = !$viewer->isLoggedIn(); 83 83 $is_post = $request->isHTTPPost(); 84 84 85 - // NOTE: Require POST to download files from the primary domain if the 86 - // request includes credentials. The "Download File" links we generate 87 - // in the web UI are forms which use POST to satisfy this requirement. 85 + // NOTE: Require POST to download files from the primary domain. If the 86 + // request is not a POST request but arrives on the primary domain, we 87 + // render a confirmation dialog. For discussion, see T13094. 88 88 89 - // The intent is to make attacks based on tags like "<iframe />" and 90 - // "<script />" (which can issue GET requests, but can not easily issue 91 - // POST requests) more difficult to execute. 92 - 93 - // The best defense against these attacks is to use an alternate file 94 - // domain, which is why we strongly recommend doing so. 95 - 96 - $is_safe = ($is_alternate_domain || $is_lfs || $is_post || $is_public); 89 + $is_safe = ($is_alternate_domain || $is_lfs || $is_post); 97 90 if (!$is_safe) { 98 - // This is marked as "external" because it is fully qualified. 99 - return id(new AphrontRedirectResponse()) 100 - ->setIsExternal(true) 101 - ->setURI(PhabricatorEnv::getProductionURI($file->getBestURI())); 91 + return $this->newDialog() 92 + ->setSubmitURI($file->getDownloadURI()) 93 + ->setTitle(pht('Download File')) 94 + ->appendParagraph( 95 + pht( 96 + 'Download file %s (%s)?', 97 + phutil_tag('strong', array(), $file->getName()), 98 + phutil_format_bytes($file->getByteSize()))) 99 + ->addCancelButton($file->getURI()) 100 + ->addSubmitButton(pht('Download File')); 102 101 } 103 102 104 103 $response->setMimeType($file->getMimeType());
+1 -2
src/applications/files/controller/PhabricatorFileInfoController.php
··· 137 137 $curtain->addAction( 138 138 id(new PhabricatorActionView()) 139 139 ->setUser($viewer) 140 - ->setRenderAsForm($can_download) 141 140 ->setDownload($can_download) 142 141 ->setName(pht('Download File')) 143 142 ->setIcon('fa-download') 144 - ->setHref($file->getViewURI()) 143 + ->setHref($file->getDownloadURI()) 145 144 ->setDisabled(!$can_download) 146 145 ->setWorkflow(!$can_download)); 147 146 }
+1 -1
src/applications/files/markup/PhabricatorEmbedFileRemarkupRule.php
··· 144 144 145 145 $existing_xform = $file->getTransform($preview_key); 146 146 if ($existing_xform) { 147 - $xform_uri = $existing_xform->getCDNURI(); 147 + $xform_uri = $existing_xform->getCDNURI('data'); 148 148 } else { 149 149 $xform_uri = $file->getURIForTransform($xform); 150 150 }
+22 -6
src/applications/files/storage/PhabricatorFile.php
··· 810 810 pht('You must save a file before you can generate a view URI.')); 811 811 } 812 812 813 - return $this->getCDNURI(); 813 + return $this->getCDNURI('data'); 814 814 } 815 815 816 - public function getCDNURI() { 816 + public function getCDNURI($request_kind) { 817 + if (($request_kind !== 'data') && 818 + ($request_kind !== 'download')) { 819 + throw new Exception( 820 + pht( 821 + 'Unknown file content request kind "%s".', 822 + $request_kind)); 823 + } 824 + 817 825 $name = self::normalizeFileName($this->getName()); 818 826 $name = phutil_escape_uri($name); 819 827 820 828 $parts = array(); 821 829 $parts[] = 'file'; 822 - $parts[] = 'data'; 830 + $parts[] = $request_kind; 823 831 824 832 // If this is an instanced install, add the instance identifier to the URI. 825 833 // Instanced configurations behind a CDN may not be able to control the ··· 861 869 } 862 870 863 871 public function getDownloadURI() { 864 - $uri = id(new PhutilURI($this->getViewURI())) 865 - ->setQueryParam('download', true); 866 - return (string)$uri; 872 + return $this->getCDNURI('download'); 867 873 } 868 874 869 875 public function getURIForTransform(PhabricatorFileTransform $transform) { ··· 1467 1473 return id(new AphrontRedirectResponse()) 1468 1474 ->setIsExternal($is_external) 1469 1475 ->setURI($uri); 1476 + } 1477 + 1478 + public function newDownloadResponse() { 1479 + // We're cheating a little bit here and relying on the fact that 1480 + // getDownloadURI() always returns a fully qualified URI with a complete 1481 + // domain. 1482 + return id(new AphrontRedirectResponse()) 1483 + ->setIsExternal(true) 1484 + ->setCloseDialogBeforeRedirect(true) 1485 + ->setURI($this->getDownloadURI()); 1470 1486 } 1471 1487 1472 1488 public function attachTransforms(array $map) {
+1 -13
src/applications/harbormaster/controller/HarbormasterBuildLogDownloadController.php
··· 44 44 ->addCancelButton($cancel_uri); 45 45 } 46 46 47 - $size = $file->getByteSize(); 48 - 49 - return $this->newDialog() 50 - ->setTitle(pht('Download Build Log')) 51 - ->appendParagraph( 52 - pht( 53 - 'This log has a total size of %s. If you insist, you may '. 54 - 'download it.', 55 - phutil_tag('strong', array(), phutil_format_bytes($size)))) 56 - ->setDisableWorkflowOnSubmit(true) 57 - ->addSubmitButton(pht('Download Log')) 58 - ->setSubmitURI($file->getDownloadURI()) 59 - ->addCancelButton($cancel_uri, pht('Done')); 47 + return $file->newDownloadResponse(); 60 48 } 61 49 62 50 }
+4 -2
src/applications/harbormaster/view/HarbormasterBuildLogView.php
··· 34 34 35 35 $download_uri = "/harbormaster/log/download/{$id}/"; 36 36 37 + $can_download = (bool)$log->getFilePHID(); 38 + 37 39 $download_button = id(new PHUIButtonView()) 38 40 ->setTag('a') 39 41 ->setHref($download_uri) 40 42 ->setIcon('fa-download') 41 - ->setDisabled(!$log->getFilePHID()) 42 - ->setWorkflow(true) 43 + ->setDisabled(!$can_download) 44 + ->setWorkflow(!$can_download) 43 45 ->setText(pht('Download Log')); 44 46 45 47 $header->addActionLink($download_button);
+8 -10
src/applications/search/controller/PhabricatorApplicationSearchController.php
··· 512 512 ->setURI($job->getMonitorURI()); 513 513 } else { 514 514 $file = $export_engine->exportFile(); 515 - 516 - return $this->newDialog() 517 - ->setTitle(pht('Download Results')) 518 - ->appendParagraph( 519 - pht('Click the download button to download the exported data.')) 520 - ->addCancelButton($cancel_uri, pht('Done')) 521 - ->setSubmitURI($file->getDownloadURI()) 522 - ->setDisableWorkflowOnSubmit(true) 523 - ->addSubmitButton(pht('Download Data')); 515 + return $file->newDownloadResponse(); 524 516 } 525 517 } 526 518 } ··· 535 527 ->setValue($format_key) 536 528 ->setOptions($format_options)); 537 529 530 + if ($is_large_export) { 531 + $submit_button = pht('Continue'); 532 + } else { 533 + $submit_button = pht('Download Data'); 534 + } 535 + 538 536 return $this->newDialog() 539 537 ->setTitle(pht('Export Results')) 540 538 ->setErrors($errors) 541 539 ->appendForm($export_form) 542 540 ->addCancelButton($cancel_uri) 543 - ->addSubmitButton(pht('Continue')); 541 + ->addSubmitButton($submit_button); 544 542 } 545 543 546 544 private function processEditRequest() {
-1
src/infrastructure/export/engine/PhabricatorExportEngineBulkJobType.php
··· 36 36 ->setName(pht('Temporary File Expired')); 37 37 } else { 38 38 $actions[] = id(new PhabricatorActionView()) 39 - ->setRenderAsForm(true) 40 39 ->setHref($file->getDownloadURI()) 41 40 ->setIcon('fa-download') 42 41 ->setName(pht('Download Data Export'));
+7
webroot/rsrc/externals/javelin/lib/Workflow.js
··· 276 276 // It is permissible to send back a falsey redirect to force a page 277 277 // reload, so we need to take this branch if the key is present. 278 278 if (r && (typeof r.redirect != 'undefined')) { 279 + // Before we redirect to file downloads, we close the dialog. These 280 + // redirects aren't real navigation events so we end up stuck in the 281 + // dialog otherwise. 282 + if (r.close) { 283 + this._pop(); 284 + } 285 + 279 286 JX.$U(r.redirect).go(); 280 287 } else if (r && r.dialog) { 281 288 this._push();