Page MenuHomePhabricator

D20975.diff
No OneTemporary

D20975.diff

diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -224,6 +224,8 @@
'AphrontFormView' => 'view/form/AphrontFormView.php',
'AphrontGlyphBarView' => 'view/widget/bars/AphrontGlyphBarView.php',
'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php',
+ 'AphrontHTTPHeaderParser' => 'aphront/headerparser/AphrontHTTPHeaderParser.php',
+ 'AphrontHTTPHeaderParserTestCase' => 'aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php',
'AphrontHTTPParameterType' => 'aphront/httpparametertype/AphrontHTTPParameterType.php',
'AphrontHTTPProxyResponse' => 'aphront/response/AphrontHTTPProxyResponse.php',
'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
@@ -242,6 +244,9 @@
'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php',
'AphrontMoreView' => 'view/layout/AphrontMoreView.php',
'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php',
+ 'AphrontMultipartParser' => 'aphront/multipartparser/AphrontMultipartParser.php',
+ 'AphrontMultipartParserTestCase' => 'aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php',
+ 'AphrontMultipartPart' => 'aphront/multipartparser/AphrontMultipartPart.php',
'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php',
'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
@@ -265,12 +270,14 @@
'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
'AphrontRequest' => 'aphront/AphrontRequest.php',
'AphrontRequestExceptionHandler' => 'aphront/handler/AphrontRequestExceptionHandler.php',
+ 'AphrontRequestStream' => 'aphront/requeststream/AphrontRequestStream.php',
'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php',
'AphrontResponse' => 'aphront/response/AphrontResponse.php',
'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php',
'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php',
'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php',
'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php',
+ 'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php',
'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php',
'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php',
'AphrontSite' => 'aphront/site/AphrontSite.php',
@@ -286,6 +293,7 @@
'AphrontUserListHTTPParameterType' => 'aphront/httpparametertype/AphrontUserListHTTPParameterType.php',
'AphrontView' => 'view/AphrontView.php',
'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php',
+ 'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php',
'ArcanistConduitAPIMethod' => 'applications/arcanist/conduit/ArcanistConduitAPIMethod.php',
'AuditConduitAPIMethod' => 'applications/audit/conduit/AuditConduitAPIMethod.php',
'AuditQueryConduitAPIMethod' => 'applications/audit/conduit/AuditQueryConduitAPIMethod.php',
@@ -6170,6 +6178,8 @@
'AphrontFormView' => 'AphrontView',
'AphrontGlyphBarView' => 'AphrontBarView',
'AphrontHTMLResponse' => 'AphrontResponse',
+ 'AphrontHTTPHeaderParser' => 'Phobject',
+ 'AphrontHTTPHeaderParserTestCase' => 'PhutilTestCase',
'AphrontHTTPParameterType' => 'Phobject',
'AphrontHTTPProxyResponse' => 'AphrontResponse',
'AphrontHTTPSink' => 'Phobject',
@@ -6188,6 +6198,9 @@
'AphrontMalformedRequestException' => 'AphrontException',
'AphrontMoreView' => 'AphrontView',
'AphrontMultiColumnView' => 'AphrontView',
+ 'AphrontMultipartParser' => 'Phobject',
+ 'AphrontMultipartParserTestCase' => 'PhutilTestCase',
+ 'AphrontMultipartPart' => 'Phobject',
'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase',
'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
@@ -6214,11 +6227,13 @@
'AphrontReloadResponse' => 'AphrontRedirectResponse',
'AphrontRequest' => 'Phobject',
'AphrontRequestExceptionHandler' => 'Phobject',
+ 'AphrontRequestStream' => 'Phobject',
'AphrontRequestTestCase' => 'PhabricatorTestCase',
'AphrontResponse' => 'Phobject',
'AphrontRoutingMap' => 'Phobject',
'AphrontRoutingResult' => 'Phobject',
'AphrontSchemaQueryException' => 'AphrontQueryException',
+ 'AphrontScopedUnguardedWriteCapability' => 'Phobject',
'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontSideNavFilterView' => 'AphrontView',
'AphrontSite' => 'Phobject',
@@ -6237,6 +6252,7 @@
'PhutilSafeHTMLProducerInterface',
),
'AphrontWebpageResponse' => 'AphrontHTMLResponse',
+ 'AphrontWriteGuard' => 'Phobject',
'ArcanistConduitAPIMethod' => 'ConduitAPIMethod',
'AuditConduitAPIMethod' => 'ConduitAPIMethod',
'AuditQueryConduitAPIMethod' => 'AuditConduitAPIMethod',
diff --git a/src/aphront/headerparser/AphrontHTTPHeaderParser.php b/src/aphront/headerparser/AphrontHTTPHeaderParser.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/headerparser/AphrontHTTPHeaderParser.php
@@ -0,0 +1,150 @@
+<?php
+
+final class AphrontHTTPHeaderParser extends Phobject {
+
+ private $name;
+ private $content;
+ private $pairs;
+
+ public function parseRawHeader($raw_header) {
+ $this->name = null;
+ $this->content = null;
+
+ $parts = explode(':', $raw_header, 2);
+ $this->name = trim($parts[0]);
+ if (count($parts) > 1) {
+ $this->content = trim($parts[1]);
+ }
+
+ $this->pairs = null;
+
+ return $this;
+ }
+
+ public function getHeaderName() {
+ $this->requireParse();
+ return $this->name;
+ }
+
+ public function getHeaderContent() {
+ $this->requireParse();
+ return $this->content;
+ }
+
+ public function getHeaderContentAsPairs() {
+ $content = $this->getHeaderContent();
+
+
+ $state = 'prekey';
+ $length = strlen($content);
+
+ $pair_name = null;
+ $pair_value = null;
+
+ $pairs = array();
+ $ii = 0;
+ while ($ii < $length) {
+ $c = $content[$ii];
+
+ switch ($state) {
+ case 'prekey';
+ // We're eating space in front of a key.
+ if ($c == ' ') {
+ $ii++;
+ break;
+ }
+ $pair_name = '';
+ $state = 'key';
+ break;
+ case 'key';
+ // We're parsing a key name until we find "=" or ";".
+ if ($c == ';') {
+ $state = 'done';
+ break;
+ }
+
+ if ($c == '=') {
+ $ii++;
+ $state = 'value';
+ break;
+ }
+
+ $ii++;
+ $pair_name .= $c;
+ break;
+ case 'value':
+ // We found an "=", so now figure out if the value is quoted
+ // or not.
+ if ($c == '"') {
+ $ii++;
+ $state = 'quoted';
+ break;
+ }
+ $state = 'unquoted';
+ break;
+ case 'quoted':
+ // We're in a quoted string, parse until we find the closing quote.
+ if ($c == '"') {
+ $ii++;
+ $state = 'done';
+ break;
+ }
+
+ $ii++;
+ $pair_value .= $c;
+ break;
+ case 'unquoted':
+ // We're in an unquoted string, parse until we find a space or a
+ // semicolon.
+ if ($c == ' ' || $c == ';') {
+ $state = 'done';
+ break;
+ }
+ $ii++;
+ $pair_value .= $c;
+ break;
+ case 'done':
+ // We parsed something, so eat any trailing whitespace and semicolons
+ // and look for a new value.
+ if ($c == ' ' || $c == ';') {
+ $ii++;
+ break;
+ }
+
+ $pairs[] = array(
+ $pair_name,
+ $pair_value,
+ );
+
+ $pair_name = null;
+ $pair_value = null;
+
+ $state = 'prekey';
+ break;
+ }
+ }
+
+ if ($state == 'quoted') {
+ throw new Exception(
+ pht(
+ 'Header has unterminated double quote for key "%s".',
+ $pair_name));
+ }
+
+ if ($pair_name !== null) {
+ $pairs[] = array(
+ $pair_name,
+ $pair_value,
+ );
+ }
+
+ return $pairs;
+ }
+
+ private function requireParse() {
+ if ($this->name === null) {
+ throw new PhutilInvalidStateException('parseRawHeader');
+ }
+ }
+
+}
diff --git a/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/headerparser/__tests__/AphrontHTTPHeaderParserTestCase.php
@@ -0,0 +1,108 @@
+<?php
+
+final class AphrontHTTPHeaderParserTestCase extends PhutilTestCase {
+
+ public function testHeaderParser() {
+ $cases = array(
+ array(
+ 'Key: x; y; z',
+ 'Key',
+ 'x; y; z',
+ array(
+ array('x', null),
+ array('y', null),
+ array('z', null),
+ ),
+ ),
+ array(
+ 'Content-Disposition: form-data; name="label"',
+ 'Content-Disposition',
+ 'form-data; name="label"',
+ array(
+ array('form-data', null),
+ array('name', 'label'),
+ ),
+ ),
+ array(
+ 'Content-Type: multipart/form-data; charset=utf-8',
+ 'Content-Type',
+ 'multipart/form-data; charset=utf-8',
+ array(
+ array('multipart/form-data', null),
+ array('charset', 'utf-8'),
+ ),
+ ),
+ array(
+ 'Content-Type: application/octet-stream; charset="ut',
+ 'Content-Type',
+ 'application/octet-stream; charset="ut',
+ false,
+ ),
+ array(
+ 'Content-Type: multipart/form-data; boundary=ABCDEFG',
+ 'Content-Type',
+ 'multipart/form-data; boundary=ABCDEFG',
+ array(
+ array('multipart/form-data', null),
+ array('boundary', 'ABCDEFG'),
+ ),
+ ),
+ array(
+ 'Content-Type: multipart/form-data; boundary="ABCDEFG"',
+ 'Content-Type',
+ 'multipart/form-data; boundary="ABCDEFG"',
+ array(
+ array('multipart/form-data', null),
+ array('boundary', 'ABCDEFG'),
+ ),
+ ),
+ );
+
+ foreach ($cases as $case) {
+ $input = $case[0];
+ $expect_name = $case[1];
+ $expect_content = $case[2];
+
+ $parser = id(new AphrontHTTPHeaderParser())
+ ->parseRawHeader($input);
+
+ $actual_name = $parser->getHeaderName();
+ $actual_content = $parser->getHeaderContent();
+
+ $this->assertEqual(
+ $expect_name,
+ $actual_name,
+ pht('Header name for: %s', $input));
+
+ $this->assertEqual(
+ $expect_content,
+ $actual_content,
+ pht('Header content for: %s', $input));
+
+ if (isset($case[3])) {
+ $expect_pairs = $case[3];
+
+ $caught = null;
+ try {
+ $actual_pairs = $parser->getHeaderContentAsPairs();
+ } catch (Exception $ex) {
+ $caught = $ex;
+ }
+
+ if ($expect_pairs === false) {
+ $this->assertEqual(
+ true,
+ ($caught instanceof Exception),
+ pht('Expect exception for header pairs of: %s', $input));
+ } else {
+ $this->assertEqual(
+ $expect_pairs,
+ $actual_pairs,
+ pht('Header pairs for: %s', $input));
+ }
+ }
+ }
+ }
+
+
+}
diff --git a/src/aphront/multipartparser/AphrontMultipartParser.php b/src/aphront/multipartparser/AphrontMultipartParser.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/AphrontMultipartParser.php
@@ -0,0 +1,249 @@
+<?php
+
+final class AphrontMultipartParser extends Phobject {
+
+ private $contentType;
+ private $boundary;
+
+ private $buffer;
+ private $body;
+ private $state;
+
+ private $part;
+ private $parts;
+
+ public function setContentType($content_type) {
+ $this->contentType = $content_type;
+ return $this;
+ }
+
+ public function getContentType() {
+ return $this->contentType;
+ }
+
+ public function beginParse() {
+ $content_type = $this->getContentType();
+ if ($content_type === null) {
+ throw new PhutilInvalidStateException('setContentType');
+ }
+
+ if (!preg_match('(^multipart/form-data)', $content_type)) {
+ throw new Exception(
+ pht(
+ 'Expected "multipart/form-data" content type when executing a '.
+ 'multipart body read.'));
+ }
+
+ $type_parts = preg_split('(\s*;\s*)', $content_type);
+ $boundary = null;
+ foreach ($type_parts as $type_part) {
+ $matches = null;
+ if (preg_match('(^boundary=(.*))', $type_part, $matches)) {
+ $boundary = $matches[1];
+ break;
+ }
+ }
+
+ if ($boundary === null) {
+ throw new Exception(
+ pht('Received "multipart/form-data" request with no "boundary".'));
+ }
+
+ $this->parts = array();
+ $this->part = null;
+
+ $this->buffer = '';
+ $this->boundary = $boundary;
+
+ // We're looking for a (usually empty) body before the first boundary.
+ $this->state = 'bodynewline';
+ }
+
+ public function continueParse($bytes) {
+ $this->buffer .= $bytes;
+
+ $continue = true;
+ while ($continue) {
+ switch ($this->state) {
+ case 'endboundary':
+ // We've just parsed a boundary. Next, we expect either "--" (which
+ // indicates we've reached the end of the parts) or "\r\n" (which
+ // indicates we should read the headers for the next part).
+
+ if (strlen($this->buffer) < 2) {
+ // We don't have enough bytes yet, so wait for more.
+ $continue = false;
+ break;
+ }
+
+ if (!strncmp($this->buffer, '--', 2)) {
+ // This is "--" after a boundary, so we're done. We'll read the
+ // rest of the body (the "epilogue") and discard it.
+ $this->buffer = substr($this->buffer, 2);
+ $this->state = 'epilogue';
+
+ $this->part = null;
+ break;
+ }
+
+ if (!strncmp($this->buffer, "\r\n", 2)) {
+ // This is "\r\n" after a boundary, so we're going to going to
+ // read the headers for a part.
+ $this->buffer = substr($this->buffer, 2);
+ $this->state = 'header';
+
+ // Create the object to hold the part we're about to read.
+ $part = new AphrontMultipartPart();
+ $this->parts[] = $part;
+ $this->part = $part;
+ break;
+ }
+
+ throw new Exception(
+ pht('Expected "\r\n" or "--" after multipart data boundary.'));
+ case 'header':
+ // We've just parsed a boundary, followed by "\r\n". We are going
+ // to read the headers for this part. They are in the form of HTTP
+ // headers and terminated by "\r\n". The section is terminated by
+ // a line with no header on it.
+
+ if (strlen($this->buffer) < 2) {
+ // We don't have enough data to find a "\r\n", so wait for more.
+ $continue = false;
+ break;
+ }
+
+ if (!strncmp("\r\n", $this->buffer, 2)) {
+ // This line immediately began "\r\n", so we're done with parsing
+ // headers. Start parsing the body.
+ $this->buffer = substr($this->buffer, 2);
+ $this->state = 'body';
+ break;
+ }
+
+ // This is an actual header, so look for the end of it.
+ $header_len = strpos($this->buffer, "\r\n");
+ if ($header_len === false) {
+ // We don't have a full header yet, so wait for more data.
+ $continue = false;
+ break;
+ }
+
+ $header_buf = substr($this->buffer, 0, $header_len);
+ $this->part->appendRawHeader($header_buf);
+
+ $this->buffer = substr($this->buffer, $header_len + 2);
+ break;
+ case 'body':
+ // We've parsed a boundary and headers, and are parsing the data for
+ // this part. The data is terminated by "\r\n--", then the boundary.
+
+ // We'll look for "\r\n", then switch to the "bodynewline" state if
+ // we find it.
+
+ $marker = "\r";
+ $marker_pos = strpos($this->buffer, $marker);
+
+ if ($marker_pos === false) {
+ // There's no "\r" anywhere in the buffer, so we can just read it
+ // as provided. Then, since we read all the data, we're done until
+ // we get more.
+
+ // Note that if we're in the preamble, we won't have a "part"
+ // object and will just discard the data.
+ if ($this->part) {
+ $this->part->appendData($this->buffer);
+ }
+ $this->buffer = '';
+ $continue = false;
+ break;
+ }
+
+ if ($marker_pos > 0) {
+ // If there are bytes before the "\r",
+ if ($this->part) {
+ $this->part->appendData(substr($this->buffer, 0, $marker_pos));
+ }
+ $this->buffer = substr($this->buffer, $marker_pos);
+ }
+
+ $expect = "\r\n";
+ $expect_len = strlen($expect);
+ if (strlen($this->buffer) < $expect_len) {
+ // We don't have enough bytes yet to know if this is "\r\n"
+ // or not.
+ $continue = false;
+ break;
+ }
+
+ if (strncmp($this->buffer, $expect, $expect_len)) {
+ // The next two bytes aren't "\r\n", so eat them and go looking
+ // for more newlines.
+ if ($this->part) {
+ $this->part->appendData(substr($this->buffer, 0, $expect_len));
+ }
+ $this->buffer = substr($this->buffer, $expect_len);
+ break;
+ }
+
+ // Eat the "\r\n".
+ $this->buffer = substr($this->buffer, $expect_len);
+ $this->state = 'bodynewline';
+ break;
+ case 'bodynewline':
+ // We've parsed a newline in a body, or we just started parsing the
+ // request. In either case, we're looking for "--", then the boundary.
+ // If we find it, this section is done. If we don't, we consume the
+ // bytes and move on.
+
+ $expect = '--'.$this->boundary;
+ $expect_len = strlen($expect);
+
+ if (strlen($this->buffer) < $expect_len) {
+ // We don't have enough bytes yet, so wait for more.
+ $continue = false;
+ break;
+ }
+
+ if (strncmp($this->buffer, $expect, $expect_len)) {
+ // This wasn't the boundary, so return to the "body" state and
+ // consume it. (But first, we need to append the "\r\n" which we
+ // ate earlier.)
+ if ($this->part) {
+ $this->part->appendData("\r\n");
+ }
+ $this->state = 'body';
+ break;
+ }
+
+ // This is the boundary, so toss it and move on.
+ $this->buffer = substr($this->buffer, $expect_len);
+ $this->state = 'endboundary';
+ break;
+ case 'epilogue':
+ // We just discard any epilogue.
+ $this->buffer = '';
+ $continue = false;
+ break;
+ default:
+ throw new Exception(
+ pht(
+ 'Unknown parser state "%s".\n',
+ $this->state));
+ }
+ }
+ }
+
+ public function endParse() {
+ if ($this->state !== 'epilogue') {
+ throw new Exception(
+ pht(
+ 'Expected "multipart/form-data" parse to end '.
+ 'in state "epilogue".'));
+ }
+
+ return $this->parts;
+ }
+
+
+}
diff --git a/src/aphront/multipartparser/AphrontMultipartPart.php b/src/aphront/multipartparser/AphrontMultipartPart.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/AphrontMultipartPart.php
@@ -0,0 +1,96 @@
+<?php
+
+final class AphrontMultipartPart extends Phobject {
+
+ private $headers = array();
+ private $value = '';
+
+ private $name;
+ private $filename;
+ private $tempFile;
+ private $byteSize = 0;
+
+ public function appendRawHeader($bytes) {
+ $parser = id(new AphrontHTTPHeaderParser())
+ ->parseRawHeader($bytes);
+
+ $header_name = $parser->getHeaderName();
+
+ $this->headers[] = array(
+ $header_name,
+ $parser->getHeaderContent(),
+ );
+
+ if (strtolower($header_name) === 'content-disposition') {
+ $pairs = $parser->getHeaderContentAsPairs();
+ foreach ($pairs as $pair) {
+ list($key, $value) = $pair;
+ switch ($key) {
+ case 'filename':
+ $this->filename = $value;
+ break;
+ case 'name':
+ $this->name = $value;
+ break;
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ public function appendData($bytes) {
+ $this->byteSize += strlen($bytes);
+
+ if ($this->isVariable()) {
+ $this->value .= $bytes;
+ } else {
+ if (!$this->tempFile) {
+ $this->tempFile = new TempFile(getmypid().'.upload');
+ }
+ Filesystem::appendFile($this->tempFile, $bytes);
+ }
+
+ return $this;
+ }
+
+ public function isVariable() {
+ return ($this->filename === null);
+ }
+
+ public function getName() {
+ return $this->name;
+ }
+
+ public function getVariableValue() {
+ if (!$this->isVariable()) {
+ throw new Exception(pht('This part is not a variable!'));
+ }
+
+ return $this->value;
+ }
+
+ public function getPHPFileDictionary() {
+ if (!$this->tempFile) {
+ $this->appendData('');
+ }
+
+ $mime_type = 'application/octet-stream';
+ foreach ($this->headers as $header) {
+ list($name, $value) = $header;
+ if (strtolower($name) == 'content-type') {
+ $mime_type = $value;
+ break;
+ }
+ }
+
+ return array(
+ 'name' => $this->filename,
+ 'type' => $mime_type,
+ 'tmp_name' => (string)$this->tempFile,
+ 'error' => 0,
+ 'size' => $this->byteSize,
+ );
+ }
+
+}
diff --git a/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/__tests__/AphrontMultipartParserTestCase.php
@@ -0,0 +1,45 @@
+<?php
+
+final class AphrontMultipartParserTestCase extends PhutilTestCase {
+
+ public function testParser() {
+ $map = array(
+ array(
+ 'data' => 'simple.txt',
+ 'variables' => array(
+ array('a', 'b'),
+ ),
+ ),
+ );
+
+ $data_dir = dirname(__FILE__).'/data/';
+ foreach ($map as $test_case) {
+ $data = Filesystem::readFile($data_dir.$test_case['data']);
+ $data = str_replace("\n", "\r\n", $data);
+
+ $parser = id(new AphrontMultipartParser())
+ ->setContentType('multipart/form-data; boundary=ABCDEFG');
+ $parser->beginParse();
+ $parser->continueParse($data);
+ $parts = $parser->endParse();
+
+ $variables = array();
+ foreach ($parts as $part) {
+ if (!$part->isVariable()) {
+ continue;
+ }
+
+ $variables[] = array(
+ $part->getName(),
+ $part->getVariableValue(),
+ );
+ }
+
+ $expect_variables = idx($test_case, 'variables', array());
+ $this->assertEqual($expect_variables, $variables);
+ }
+ }
+
+
+
+}
diff --git a/src/aphront/multipartparser/__tests__/data/simple.txt b/src/aphront/multipartparser/__tests__/data/simple.txt
new file mode 100644
--- /dev/null
+++ b/src/aphront/multipartparser/__tests__/data/simple.txt
@@ -0,0 +1,5 @@
+--ABCDEFG
+Content-Disposition: form-data; name="a"
+
+b
+--ABCDEFG--
diff --git a/src/aphront/requeststream/AphrontRequestStream.php b/src/aphront/requeststream/AphrontRequestStream.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/requeststream/AphrontRequestStream.php
@@ -0,0 +1,92 @@
+<?php
+
+final class AphrontRequestStream extends Phobject {
+
+ private $encoding;
+ private $stream;
+ private $closed;
+ private $iterator;
+
+ public function setEncoding($encoding) {
+ $this->encoding = $encoding;
+ return $this;
+ }
+
+ public function getEncoding() {
+ return $this->encoding;
+ }
+
+ public function getIterator() {
+ if (!$this->iterator) {
+ $this->iterator = new PhutilStreamIterator($this->getStream());
+ }
+ return $this->iterator;
+ }
+
+ public function readData() {
+ if (!$this->iterator) {
+ $iterator = $this->getIterator();
+ $iterator->rewind();
+ } else {
+ $iterator = $this->getIterator();
+ }
+
+ if (!$iterator->valid()) {
+ return null;
+ }
+
+ $data = $iterator->current();
+ $iterator->next();
+
+ return $data;
+ }
+
+ private function getStream() {
+ if (!$this->stream) {
+ $this->stream = $this->newStream();
+ }
+
+ return $this->stream;
+ }
+
+ private function newStream() {
+ $stream = fopen('php://input', 'rb');
+ if (!$stream) {
+ throw new Exception(
+ pht(
+ 'Failed to open stream "%s" for reading.',
+ 'php://input'));
+ }
+
+ $encoding = $this->getEncoding();
+ if ($encoding === 'gzip') {
+ // This parameter is magic. Values 0-15 express a time/memory tradeoff,
+ // but the largest value (15) corresponds to only 32KB of memory and
+ // data encoded with a smaller window size than the one we pass can not
+ // be decompressed. Always pass the maximum window size.
+
+ // Additionally, you can add 16 (to enable gzip) or 32 (to enable both
+ // gzip and zlib). Add 32 to support both.
+ $zlib_window = 15 + 32;
+
+ $ok = stream_filter_append(
+ $stream,
+ 'zlib.inflate',
+ STREAM_FILTER_READ,
+ array(
+ 'window' => $zlib_window,
+ ));
+ if (!$ok) {
+ throw new Exception(
+ pht(
+ 'Failed to append filter "%s" to input stream while processing '.
+ 'a request with "%s" encoding.',
+ 'zlib.inflate',
+ $encoding));
+ }
+ }
+
+ return $stream;
+ }
+
+}
diff --git a/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/writeguard/AphrontScopedUnguardedWriteCapability.php
@@ -0,0 +1,9 @@
+<?php
+
+final class AphrontScopedUnguardedWriteCapability extends Phobject {
+
+ public function __destruct() {
+ AphrontWriteGuard::endUnguardedWrites();
+ }
+
+}
diff --git a/src/aphront/writeguard/AphrontWriteGuard.php b/src/aphront/writeguard/AphrontWriteGuard.php
new file mode 100644
--- /dev/null
+++ b/src/aphront/writeguard/AphrontWriteGuard.php
@@ -0,0 +1,267 @@
+<?php
+
+/**
+ * Guard writes against CSRF. The Aphront structure takes care of most of this
+ * for you, you just need to call:
+ *
+ * AphrontWriteGuard::willWrite();
+ *
+ * ...before executing a write against any new kind of storage engine. MySQL
+ * databases and the default file storage engines are already covered, but if
+ * you introduce new types of datastores make sure their writes are guarded. If
+ * you don't guard writes and make a mistake doing CSRF checks in a controller,
+ * a CSRF vulnerability can escape undetected.
+ *
+ * If you need to execute writes on a page which doesn't have CSRF tokens (for
+ * example, because you need to do logging), you can temporarily disable the
+ * write guard by calling:
+ *
+ * AphrontWriteGuard::beginUnguardedWrites();
+ * do_logging_write();
+ * AphrontWriteGuard::endUnguardedWrites();
+ *
+ * This is dangerous, because it disables the backup layer of CSRF protection
+ * this class provides. You should need this only very, very rarely.
+ *
+ * @task protect Protecting Writes
+ * @task disable Disabling Protection
+ * @task manage Managing Write Guards
+ * @task internal Internals
+ */
+final class AphrontWriteGuard extends Phobject {
+
+ private static $instance;
+ private static $allowUnguardedWrites = false;
+
+ private $callback;
+ private $allowDepth = 0;
+
+
+/* -( Managing Write Guards )---------------------------------------------- */
+
+
+ /**
+ * Construct a new write guard for a request. Only one write guard may be
+ * active at a time. You must explicitly call @{method:dispose} when you are
+ * done with a write guard:
+ *
+ * $guard = new AphrontWriteGuard($callback);
+ * // ...
+ * $guard->dispose();
+ *
+ * Normally, you do not need to manage guards yourself -- the Aphront stack
+ * handles it for you.
+ *
+ * This class accepts a callback, which will be invoked when a write is
+ * attempted. The callback should validate the presence of a CSRF token in
+ * the request, or abort the request (e.g., by throwing an exception) if a
+ * valid token isn't present.
+ *
+ * @param callable CSRF callback.
+ * @return this
+ * @task manage
+ */
+ public function __construct($callback) {
+ if (self::$instance) {
+ throw new Exception(
+ pht(
+ 'An %s already exists. Dispose of the previous guard '.
+ 'before creating a new one.',
+ __CLASS__));
+ }
+ if (self::$allowUnguardedWrites) {
+ throw new Exception(
+ pht(
+ 'An %s is being created in a context which permits '.
+ 'unguarded writes unconditionally. This is not allowed and '.
+ 'indicates a serious error.',
+ __CLASS__));
+ }
+ $this->callback = $callback;
+ self::$instance = $this;
+ }
+
+
+ /**
+ * Dispose of the active write guard. You must call this method when you are
+ * done with a write guard. You do not normally need to call this yourself.
+ *
+ * @return void
+ * @task manage
+ */
+ public function dispose() {
+ if (!self::$instance) {
+ throw new Exception(pht(
+ 'Attempting to dispose of write guard, but no write guard is active!'));
+ }
+
+ if ($this->allowDepth > 0) {
+ throw new Exception(
+ pht(
+ 'Imbalanced %s: more %s calls than %s calls.',
+ __CLASS__,
+ 'beginUnguardedWrites()',
+ 'endUnguardedWrites()'));
+ }
+ self::$instance = null;
+ }
+
+
+ /**
+ * Determine if there is an active write guard.
+ *
+ * @return bool
+ * @task manage
+ */
+ public static function isGuardActive() {
+ return (bool)self::$instance;
+ }
+
+ /**
+ * Return on instance of AphrontWriteGuard if it's active, or null
+ *
+ * @return AphrontWriteGuard|null
+ */
+ public static function getInstance() {
+ return self::$instance;
+ }
+
+
+/* -( Protecting Writes )-------------------------------------------------- */
+
+
+ /**
+ * Declare intention to perform a write, validating that writes are allowed.
+ * You should call this method before executing a write whenever you implement
+ * a new storage engine where information can be permanently kept.
+ *
+ * Writes are permitted if:
+ *
+ * - The request has valid CSRF tokens.
+ * - Unguarded writes have been temporarily enabled by a call to
+ * @{method:beginUnguardedWrites}.
+ * - All write guarding has been disabled with
+ * @{method:allowDangerousUnguardedWrites}.
+ *
+ * If none of these conditions are true, this method will throw and prevent
+ * the write.
+ *
+ * @return void
+ * @task protect
+ */
+ public static function willWrite() {
+ if (!self::$instance) {
+ if (!self::$allowUnguardedWrites) {
+ throw new Exception(
+ pht(
+ 'Unguarded write! There must be an active %s to perform writes.',
+ __CLASS__));
+ } else {
+ // Unguarded writes are being allowed unconditionally.
+ return;
+ }
+ }
+
+ $instance = self::$instance;
+ if ($instance->allowDepth == 0) {
+ call_user_func($instance->callback);
+ }
+ }
+
+
+/* -( Disabling Write Protection )----------------------------------------- */
+
+
+ /**
+ * Enter a scope which permits unguarded writes. This works like
+ * @{method:beginUnguardedWrites} but returns an object which will end
+ * the unguarded write scope when its __destruct() method is called. This
+ * is useful to more easily handle exceptions correctly in unguarded write
+ * blocks:
+ *
+ * // Restores the guard even if do_logging() throws.
+ * function unguarded_scope() {
+ * $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
+ * do_logging();
+ * }
+ *
+ * @return AphrontScopedUnguardedWriteCapability Object which ends unguarded
+ * writes when it leaves scope.
+ * @task disable
+ */
+ public static function beginScopedUnguardedWrites() {
+ self::beginUnguardedWrites();
+ return new AphrontScopedUnguardedWriteCapability();
+ }
+
+
+ /**
+ * Begin a block which permits unguarded writes. You should use this very
+ * sparingly, and only for things like logging where CSRF is not a concern.
+ *
+ * You must pair every call to @{method:beginUnguardedWrites} with a call to
+ * @{method:endUnguardedWrites}:
+ *
+ * AphrontWriteGuard::beginUnguardedWrites();
+ * do_logging();
+ * AphrontWriteGuard::endUnguardedWrites();
+ *
+ * @return void
+ * @task disable
+ */
+ public static function beginUnguardedWrites() {
+ if (!self::$instance) {
+ return;
+ }
+ self::$instance->allowDepth++;
+ }
+
+ /**
+ * Declare that you have finished performing unguarded writes. You must
+ * call this exactly once for each call to @{method:beginUnguardedWrites}.
+ *
+ * @return void
+ * @task disable
+ */
+ public static function endUnguardedWrites() {
+ if (!self::$instance) {
+ return;
+ }
+ if (self::$instance->allowDepth <= 0) {
+ throw new Exception(
+ pht(
+ 'Imbalanced %s: more %s calls than %s calls.',
+ __CLASS__,
+ 'endUnguardedWrites()',
+ 'beginUnguardedWrites()'));
+ }
+ self::$instance->allowDepth--;
+ }
+
+
+ /**
+ * Allow execution of unguarded writes. This is ONLY appropriate for use in
+ * script contexts or other contexts where you are guaranteed to never be
+ * vulnerable to CSRF concerns. Calling this method is EXTREMELY DANGEROUS
+ * if you do not understand the consequences.
+ *
+ * If you need to perform unguarded writes on an otherwise guarded workflow
+ * which is vulnerable to CSRF, use @{method:beginUnguardedWrites}.
+ *
+ * @return void
+ * @task disable
+ */
+ public static function allowDangerousUnguardedWrites($allow) {
+ if (self::$instance) {
+ throw new Exception(
+ pht(
+ 'You can not unconditionally disable %s by calling %s while a write '.
+ 'guard is active. Use %s to temporarily allow unguarded writes.',
+ __CLASS__,
+ __FUNCTION__.'()',
+ 'beginUnguardedWrites()'));
+ }
+ self::$allowUnguardedWrites = true;
+ }
+
+}

File Metadata

Mime Type
text/plain
Expires
Sun, May 12, 4:34 AM (1 w, 4 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/2n/mo/ukml7bxlesabxnmu
Default Alt Text
D20975.diff (35 KB)

Event Timeline