Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Limit HTTP headers, respond with 431 "Entity Too Large" #100

Closed
thekid opened this issue Apr 20, 2023 · 7 comments
Closed

Limit HTTP headers, respond with 431 "Entity Too Large" #100

thekid opened this issue Apr 20, 2023 · 7 comments
Labels

Comments

@thekid
Copy link
Member Author

thekid commented Apr 20, 2023

Note: Our HTTP server always runs behind a proxy in production scenarios, but we shouldn't solely rely on these blocking such requests.

@thekid
Copy link
Member Author

thekid commented Apr 30, 2023

First implementation:

diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php
index 1303e2b..0cc6200 100755
--- a/src/main/php/xp/web/srv/HttpProtocol.class.php
+++ b/src/main/php/xp/web/srv/HttpProtocol.class.php
@@ -153,17 +153,27 @@ class HttpProtocol implements ServerProtocol {
         clearstatcache();
         \xp::gc();
       }
-    } else if (Input::CLOSE === $input->kind) {
-      $socket->close();
+      return;
+    }
+
+    // Handle request errors, closing the socket
+    if (Input::CLOSE === $input->kind) {
+      goto close;
+    } else if (Input::EXCESSIVE === $input->kind) {
+      $status= '431 Request Header Fields Too Large';
+      $error= 'Error processing status line and request headers';
     } else {
-      $error= 'Incomplete HTTP request: "'.addcslashes($input->kind, "\0..\37!\177..\377").'"';
-      $socket->write(sprintf(
-        "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
-        strlen($error),
-        $error
-      ));
-      $socket->close();
+      $status= '400 Bad Request';
+      $error= 'Incomplete HTTP request: "'.addcslashes($input->buffer, "\0..\37!\177..\377").'"';
     }
+
+    $socket->write(sprintf(
+      "HTTP/1.1 %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
+      $status,
+      strlen($error),
+      $error
+    ));
+    close: $socket->close();
   }
 
   /**
diff --git a/src/main/php/xp/web/srv/Input.class.php b/src/main/php/xp/web/srv/Input.class.php
index 30fec44..862e3bd 100755
--- a/src/main/php/xp/web/srv/Input.class.php
+++ b/src/main/php/xp/web/srv/Input.class.php
@@ -6,13 +6,15 @@ use web\Headers;
 use web\io\{ReadChunks, ReadLength, Parts, Input as Base};
 
 class Input implements Base {
-  const CLOSE   = 0;
-  const REQUEST = 1;
+  const CLOSE      = 0;
+  const REQUEST    = 1;
+  const INCOMPLETE = 2;
+  const EXCESSIVE  = 3;
 
   public $kind;
+  public $buffer= null;
   private $socket;
   private $method, $uri, $version;
-  private $buffer= null;
   private $incoming= null;
 
   /**
@@ -29,21 +31,26 @@ class Input implements Base {
       return;
     }
 
-    // Read status line cautiously. If a client does not send complete line
-    // with the initial write (which it typically does), wait for another
-    // 100 milliseconds. If no more data is transmitted, give up.
-    if (false === ($p= strpos($initial, "\r\n"))) {
-      if ($socket->canRead(0.1)) {
-        $initial.= $socket->readBinary();
+    // Read status line and headers. Limit status line and headers to 16 kB,
+    // and the time taken to process headers to 500 milliseconds.
+    $start= microtime(true);
+    while (false === ($p= strpos($initial, "\r\n\r\n"))) {
+      $socket->canRead(0.05) && $initial.= $socket->readBinary();
+
+      if (strlen($initial) > 16384 || microtime(true) - $start > 0.5) {
+        $this->buffer= $initial;
+        $this->kind= self::EXCESSIVE;
+        return;
       }
     }
 
     if (3 === sscanf($initial, "%s %s HTTP/%[0-9.]\r\n", $this->method, $this->uri, $this->version)) {
-      $this->buffer= substr($initial, $p + 2);
+      $this->buffer= substr($initial, strpos($initial, "\r\n") + 2);
       $this->socket= $socket;
       $this->kind= self::REQUEST;
     } else {
-      $this->kind= rtrim($initial);
+      $this->buffer= $initial;
+      $this->kind= self::INCOMPLETE;
     }
   }
 
diff --git a/src/test/php/web/unittest/HttpProtocolTest.class.php b/src/test/php/web/unittest/HttpProtocolTest.class.php
index 13dfa71..fd0f6f8 100755
--- a/src/test/php/web/unittest/HttpProtocolTest.class.php
+++ b/src/test/php/web/unittest/HttpProtocolTest.class.php
@@ -2,7 +2,7 @@
 
 use io\streams\Streams;
 use peer\SocketException;
-use test\{Assert, Test, Values};
+use test\{Assert, AssertionFailed, Test, Values};
 use web\{Application, Environment, Logging};
 use xp\web\srv\{CannotWrite, HttpProtocol};
 
@@ -37,7 +37,7 @@ class HttpProtocolTest {
   private function assertHttp($expected, $out) {
     $actual= implode('', $out);
     if (!preg_match('#^'.$expected.'$#', $actual)) {
-      $this->fail('=~', $actual, $expected);
+      throw new AssertionFailed($actual.' =~ '.$expected);
     }
   }
 
diff --git a/src/test/php/web/unittest/server/InputTest.class.php b/src/test/php/web/unittest/server/InputTest.class.php
index 8335323..0f04116 100755
--- a/src/test/php/web/unittest/server/InputTest.class.php
+++ b/src/test/php/web/unittest/server/InputTest.class.php
@@ -56,8 +56,14 @@ class InputTest {
   }
 
   #[Test]
-  public function malformed_kind() {
-    Assert::equals('EHLO example.org', (new Input($this->socket("EHLO example.org\r\n")))->kind);
+  public function incomplete_request() {
+    Assert::equals(Input::INCOMPLETE, (new Input($this->socket("EHLO example.org\r\n\r\n")))->kind);
+  }
+
+  #[Test]
+  public function header_limit_exceeded() {
+    $headers= 'Cookie: excess='.str_repeat('x', 16384)."\r\n";
+    Assert::equals(Input::EXCESSIVE, (new Input($this->socket("GET / HTTP/1.1\r\n{$headers}\r\n")))->kind);
   }
 
   #[Test]

Is 500 milliseconds too high / too low?

@thekid
Copy link
Member Author

thekid commented Apr 30, 2023

@thekid
Copy link
Member Author

thekid commented Apr 30, 2023

The default header timeout is 60 seconds in Nginx, see https://nginx.org/en/docs/http/ngx_http_core_module.html#client_header_timeout, and exceeding this limit yields an HTTP 408.

@thekid
Copy link
Member Author

thekid commented Apr 30, 2023

@thekid
Copy link
Member Author

thekid commented May 1, 2023

Is 500 milliseconds too high / too low?

Worked around this by making header reading async. We now use the default socket timeout for reading headers.

@thekid
Copy link
Member Author

thekid commented May 8, 2023

@thekid thekid closed this as completed May 8, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant