diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php
index 6337c984b9..25750ddacd 100644
--- a/src/aphront/response/AphrontFileResponse.php
+++ b/src/aphront/response/AphrontFileResponse.php
@@ -1,137 +1,142 @@
allowOrigins[] = $origin;
return $this;
}
public function setDownload($download) {
if (!strlen($download)) {
$download = 'untitled';
}
$this->download = $download;
return $this;
}
public function getDownload() {
return $this->download;
}
public function setMimeType($mime_type) {
$this->mimeType = $mime_type;
return $this;
}
public function getMimeType() {
return $this->mimeType;
}
public function setContent($content) {
$this->setContentLength(strlen($content));
$this->content = $content;
return $this;
}
public function setContentIterator($iterator) {
$this->contentIterator = $iterator;
return $this;
}
public function buildResponseString() {
return $this->content;
}
public function getContentIterator() {
if ($this->contentIterator) {
return $this->contentIterator;
}
return parent::getContentIterator();
}
public function setContentLength($length) {
$this->contentLength = $length;
return $this;
}
public function getContentLength() {
return $this->contentLength;
}
public function setCompressResponse($compress_response) {
$this->compressResponse = $compress_response;
return $this;
}
public function getCompressResponse() {
return $this->compressResponse;
}
public function setRange($min, $max) {
$this->rangeMin = $min;
$this->rangeMax = $max;
return $this;
}
public function getHeaders() {
$headers = array(
array('Content-Type', $this->getMimeType()),
// This tells clients that we can support requests with a "Range" header,
// which allows downloads to be resumed, in some browsers, some of the
// time, if the stars align.
array('Accept-Ranges', 'bytes'),
);
if ($this->rangeMin || $this->rangeMax) {
$len = $this->getContentLength();
$min = $this->rangeMin;
+
$max = $this->rangeMax;
+ if ($max === null) {
+ $max = ($len - 1);
+ }
+
$headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}");
$content_len = ($max - $min) + 1;
} else {
$content_len = $this->getContentLength();
}
if (!$this->shouldCompressResponse()) {
$headers[] = array('Content-Length', $content_len);
}
if (strlen($this->getDownload())) {
$headers[] = array('X-Download-Options', 'noopen');
$filename = $this->getDownload();
$filename = addcslashes($filename, '"\\');
$headers[] = array(
'Content-Disposition',
'attachment; filename="'.$filename.'"',
);
}
if ($this->allowOrigins) {
$headers[] = array(
'Access-Control-Allow-Origin',
implode(',', $this->allowOrigins),
);
}
$headers = array_merge(parent::getHeaders(), $headers);
return $headers;
}
protected function shouldCompressResponse() {
return $this->getCompressResponse();
}
}
diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php
index 43b5aa419f..31761d1244 100644
--- a/src/applications/files/controller/PhabricatorFileDataController.php
+++ b/src/applications/files/controller/PhabricatorFileDataController.php
@@ -1,199 +1,206 @@
getViewer();
$this->phid = $request->getURIData('phid');
$this->key = $request->getURIData('key');
$alt = PhabricatorEnv::getEnvConfig('security.alternate-file-domain');
$base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri');
$alt_uri = new PhutilURI($alt);
$alt_domain = $alt_uri->getDomain();
$req_domain = $request->getHost();
$main_domain = id(new PhutilURI($base_uri))->getDomain();
if (!strlen($alt) || $main_domain == $alt_domain) {
// No alternate domain.
$should_redirect = false;
$is_alternate_domain = false;
} else if ($req_domain != $alt_domain) {
// Alternate domain, but this request is on the main domain.
$should_redirect = true;
$is_alternate_domain = false;
} else {
// Alternate domain, and on the alternate domain.
$should_redirect = false;
$is_alternate_domain = true;
}
$response = $this->loadFile();
if ($response) {
return $response;
}
$file = $this->getFile();
if ($should_redirect) {
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI($file->getCDNURI());
}
$response = new AphrontFileResponse();
$response->setCacheDurationInSeconds(60 * 60 * 24 * 30);
$response->setCanCDN($file->getCanCDN());
$begin = null;
$end = null;
// NOTE: It's important to accept "Range" requests when playing audio.
// If we don't, Safari has difficulty figuring out how long sounds are
// and glitches when trying to loop them. In particular, Safari sends
// an initial request for bytes 0-1 of the audio file, and things go south
// if we can't respond with a 206 Partial Content.
$range = $request->getHTTPHeader('range');
if ($range) {
$matches = null;
- if (preg_match('/^bytes=(\d+)-(\d+)$/', $range, $matches)) {
+ if (preg_match('/^bytes=(\d+)-(\d*)$/', $range, $matches)) {
// Note that the "Range" header specifies bytes differently than
// we do internally: the range 0-1 has 2 bytes (byte 0 and byte 1).
$begin = (int)$matches[1];
- $end = (int)$matches[2] + 1;
+
+ // The "Range" may be "200-299" or "200-", meaning "until end of file".
+ if (strlen($matches[2])) {
+ $range_end = (int)$matches[2];
+ $end = $range_end + 1;
+ } else {
+ $range_end = null;
+ }
$response->setHTTPResponseCode(206);
- $response->setRange($begin, ($end - 1));
+ $response->setRange($begin, $range_end);
}
}
$is_viewable = $file->isViewableInBrowser();
$force_download = $request->getExists('download');
$request_type = $request->getHTTPHeader('X-Phabricator-Request-Type');
$is_lfs = ($request_type == 'git-lfs');
if ($is_viewable && !$force_download) {
$response->setMimeType($file->getViewableMimeType());
} else {
$is_public = !$viewer->isLoggedIn();
$is_post = $request->isHTTPPost();
// NOTE: Require POST to download files from the primary domain if the
// request includes credentials. The "Download File" links we generate
// in the web UI are forms which use POST to satisfy this requirement.
// The intent is to make attacks based on tags like "" and
// "" (which can issue GET requests, but can not easily issue
// POST requests) more difficult to execute.
// The best defense against these attacks is to use an alternate file
// domain, which is why we strongly recommend doing so.
$is_safe = ($is_alternate_domain || $is_lfs || $is_post || $is_public);
if (!$is_safe) {
// This is marked as "external" because it is fully qualified.
return id(new AphrontRedirectResponse())
->setIsExternal(true)
->setURI(PhabricatorEnv::getProductionURI($file->getBestURI()));
}
$response->setMimeType($file->getMimeType());
$response->setDownload($file->getName());
}
$iterator = $file->getFileDataIterator($begin, $end);
$response->setContentLength($file->getByteSize());
$response->setContentIterator($iterator);
return $response;
}
private function loadFile() {
// Access to files is provided by knowledge of a per-file secret key in
// the URI. Knowledge of this secret is sufficient to retrieve the file.
// For some requests, we also have a valid viewer. However, for many
// requests (like alternate domain requests or Git LFS requests) we will
// not. Even if we do have a valid viewer, use the omnipotent viewer to
// make this logic simpler and more consistent.
// Beyond making the policy check itself more consistent, this also makes
// sure we're consitent about returning HTTP 404 on bad requests instead
// of serving HTTP 200 with a login page, which can mislead some clients.
$viewer = PhabricatorUser::getOmnipotentUser();
$file = id(new PhabricatorFileQuery())
->setViewer($viewer)
->withPHIDs(array($this->phid))
->executeOne();
if (!$file) {
return new Aphront404Response();
}
// We may be on the CDN domain, so we need to use a fully-qualified URI
// here to make sure we end up back on the main domain.
$info_uri = PhabricatorEnv::getURI($file->getInfoURI());
if (!$file->validateSecretKey($this->key)) {
$dialog = $this->newDialog()
->setTitle(pht('Invalid Authorization'))
->appendParagraph(
pht(
'The link you followed to access this file is no longer '.
'valid. The visibility of the file may have changed after '.
'the link was generated.'))
->appendParagraph(
pht(
'You can continue to the file detail page to get more '.
'information and attempt to access the file.'))
->addCancelButton($info_uri, pht('Continue'));
return id(new AphrontDialogResponse())
->setDialog($dialog)
->setHTTPResponseCode(404);
}
if ($file->getIsPartial()) {
$dialog = $this->newDialog()
->setTitle(pht('Partial Upload'))
->appendParagraph(
pht(
'This file has only been partially uploaded. It must be '.
'uploaded completely before you can download it.'))
->appendParagraph(
pht(
'You can continue to the file detail page to monitor the '.
'upload progress of the file.'))
->addCancelButton($info_uri, pht('Continue'));
return id(new AphrontDialogResponse())
->setDialog($dialog)
->setHTTPResponseCode(404);
}
$this->file = $file;
return null;
}
private function getFile() {
if (!$this->file) {
throw new PhutilInvalidStateException('loadFile');
}
return $this->file;
}
}
diff --git a/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php b/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php
index db10b87964..b3425ba313 100644
--- a/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php
+++ b/src/applications/files/format/__tests__/PhabricatorFileStorageFormatTestCase.php
@@ -1,128 +1,136 @@
true,
);
}
public function testRot13Storage() {
$engine = new PhabricatorTestStorageEngine();
$rot13_format = PhabricatorFileROT13StorageFormat::FORMATKEY;
$data = 'The cow jumped over the full moon.';
$expect = 'Gur pbj whzcrq bire gur shyy zbba.';
$params = array(
'name' => 'test.dat',
'storageEngines' => array(
$engine,
),
'format' => $rot13_format,
);
$file = PhabricatorFile::newFromFileData($data, $params);
// We should have a file stored as rot13, which reads back the input
// data correctly.
$this->assertEqual($rot13_format, $file->getStorageFormat());
$this->assertEqual($data, $file->loadFileData());
// The actual raw data in the storage engine should be encoded.
$raw_data = $engine->readFile($file->getStorageHandle());
$this->assertEqual($expect, $raw_data);
// If we generate an iterator over a slice of the file, it should return
// the decrypted file.
$iterator = $file->getFileDataIterator(4, 14);
$raw_data = '';
foreach ($iterator as $data_chunk) {
$raw_data .= $data_chunk;
}
$this->assertEqual('cow jumped', $raw_data);
}
public function testAES256Storage() {
$engine = new PhabricatorTestStorageEngine();
$key_name = 'test.abcd';
$key_text = 'abcdefghijklmnopABCDEFGHIJKLMNOP';
PhabricatorKeyring::addKey(
array(
'name' => $key_name,
'type' => 'aes-256-cbc',
'material.base64' => base64_encode($key_text),
));
$format = id(new PhabricatorFileAES256StorageFormat())
->selectMasterKey($key_name);
$data = 'The cow jumped over the full moon.';
$params = array(
'name' => 'test.dat',
'storageEngines' => array(
$engine,
),
'format' => $format,
);
$file = PhabricatorFile::newFromFileData($data, $params);
// We should have a file stored as AES256.
$format_key = $format->getStorageFormatKey();
$this->assertEqual($format_key, $file->getStorageFormat());
$this->assertEqual($data, $file->loadFileData());
// The actual raw data in the storage engine should be encrypted. We
// can't really test this, but we can make sure it's not the same as the
// input data.
$raw_data = $engine->readFile($file->getStorageHandle());
$this->assertTrue($data !== $raw_data);
// If we generate an iterator over a slice of the file, it should return
// the decrypted file.
$iterator = $file->getFileDataIterator(4, 14);
$raw_data = '';
foreach ($iterator as $data_chunk) {
$raw_data .= $data_chunk;
}
$this->assertEqual('cow jumped', $raw_data);
+
+ $iterator = $file->getFileDataIterator(4, null);
+ $raw_data = '';
+ foreach ($iterator as $data_chunk) {
+ $raw_data .= $data_chunk;
+ }
+ $this->assertEqual('cow jumped over the full moon.', $raw_data);
+
}
public function testStorageTampering() {
$engine = new PhabricatorTestStorageEngine();
$good = 'The cow jumped over the full moon.';
$evil = 'The cow slept quietly, honoring the glorious dictator.';
$params = array(
'name' => 'message.txt',
'storageEngines' => array(
$engine,
),
);
// First, write the file normally.
$file = PhabricatorFile::newFromFileData($good, $params);
$this->assertEqual($good, $file->loadFileData());
// As an adversary, tamper with the file.
$engine->tamperWithFile($file->getStorageHandle(), $evil);
// Attempts to read the file data should now fail the integrity check.
$caught = null;
try {
$file->loadFileData();
} catch (PhabricatorFileIntegrityException $ex) {
$caught = $ex;
}
$this->assertTrue($caught instanceof PhabricatorFileIntegrityException);
}
}