Repository: aio-libs/aiosmtpd

Issue:
Exception when parsing binary data
aiosmtpd will try to ASCII decode binary data as SMTP commands, raising a `UnicodeDecodeError`.  A simple test case that triggers this is shown at the end of this report.  This can be caught by a generic exception handler, however it would be better to do this inside the library.

This can be trivially fixed with a `try`/`except` handler.  PR incoming.

``` python
from base64 import b64decode
import socket


TCP_IP = '127.0.0.1'
TCP_PORT = 8025
BUFFER_SIZE = 1024

RAW = "gUMBAwMBGgAAACAAwDAAwCwAwCgAwCQAwBQAwAoAwCIAwCEAAKMAAJ8AAGsAAGoAADkAADgAAIgAAIcAwBkAwCAAwDIAwC4AwCoAwCYAwA8AwAUAAJ0AAD0AADUAAIQAwBIAwAgAwBwAwBsAABYAABMAwBcAwBoAwA0AwAMAAAoHAMAAwC8AwCsAwCcAwCMAwBMAwAkAwB8AwB4AAKIAAJ4AAGcAAEAAADMAADIAAJoAAJkAAEUAAEQAwBgAwB0AwDEAwC0AwCkAwCUAwA4AwAQAAJwAADwAAC8AAJYAAEEAAAcAwBEAwAcAwBYAwAwAwAIAAAUAAAQFAIADAIABAIAAABUAABIAAAkGAEAAABQAABEAAAgAAAYAAAMEAIACAIAAAP8cXF6WB1DBTAUUZfksmYhwy/mtOvciiLZb+ZNMaF/tYg=="
MESSAGE = b64decode(RAW)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((TCP_IP, TCP_PORT))
data = s.recv(BUFFER_SIZE)
print("Received: {}".format(data))
s.send(MESSAGE)
data = s.recv(BUFFER_SIZE)
print("Received: {}".format(data))
s.close()
```

Comments:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 6f1e947..cdecf91 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -9,6 +9,11 @@ matrix:
       env: INTERP=py35 PYTHONASYNCIODEBUG=1
     - python: "3.6"
       env: INTERP=py36 PYTHONASYNCIODEBUG=1
+before_script:
+  # Disable IPv6. Ref travis-ci/travis-ci#8711
+  - echo 0 | sudo tee /proc/sys/net/ipv6/conf/all/disable_ipv6
 script:
   - tox -e $INTERP-nocov,$INTERP-cov,qa,docs
   - 'if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then tox -e $INTERP-diffcov; fi'
+before_script:
+  - echo 0 | sudo tee /proc/sys/net/ipv6/conf/all/disable_ipv6
diff --git a/aiosmtpd/controller.py b/aiosmtpd/controller.py
index c529ff9..1df9c2c 100644
--- a/aiosmtpd/controller.py
+++ b/aiosmtpd/controller.py
@@ -10,6 +10,11 @@ from public import public
 class Controller:
     def __init__(self, handler, loop=None, hostname=None, port=8025, *,
                  ready_timeout=1.0, enable_SMTPUTF8=True, ssl_context=None):
+        """
+        `Documentation can be found here
+        <http://aiosmtpd.readthedocs.io/en/latest/aiosmtpd\
+/docs/controller.html#controller-api>`_.
+        """
         self.handler = handler
         self.hostname = '::1' if hostname is None else hostname
         self.port = port
diff --git a/aiosmtpd/docs/controller.rst b/aiosmtpd/docs/controller.rst
index ca134b6..ab0a81a 100644
--- a/aiosmtpd/docs/controller.rst
+++ b/aiosmtpd/docs/controller.rst
@@ -168,7 +168,15 @@ Controller API
    *loop* is the asyncio event loop to use.  If not given,
    :meth:`asyncio.new_event_loop()` is called to create the event loop.
 
-   *hostname* and *port* are passed directly to your loop's
+   *hostname* is passed to your loop's
+   :meth:`AbstractEventLoop.create_server` method as the
+   ``host`` parameter,
+   except None (default) is translated to '::1'. To bind
+   dual-stack locally, use 'localhost'. To bind `dual-stack
+   <https://en.wikipedia.org/wiki/IPv6#Dual-stack_IP_implementation>`_
+   on all interfaces, use ''.
+
+   *port* is passed directly to your loop's
    :meth:`AbstractEventLoop.create_server` method.
 
    *ready_timeout* is float number of seconds that the controller will wait in
diff --git a/aiosmtpd/smtp.py b/aiosmtpd/smtp.py
index 681732a..24661a6 100644
--- a/aiosmtpd/smtp.py
+++ b/aiosmtpd/smtp.py
@@ -234,10 +234,20 @@ class SMTP(asyncio.StreamReaderProtocol):
                 # re-encoded back to the original bytes when the SMTP command
                 # is handled.
                 if i < 0:
-                    command = line.upper().decode(encoding='ascii')
+                    try:
+                        command = line.upper().decode(encoding='ascii')
+                    except UnicodeDecodeError:
+                        await self.push('500 Error: bad syntax')
+                        continue
+
                     arg = None
                 else:
-                    command = line[:i].upper().decode(encoding='ascii')
+                    try:
+                        command = line[:i].upper().decode(encoding='ascii')
+                    except UnicodeDecodeError:
+                        await self.push('500 Error: bad syntax')
+                        continue
+
                     arg = line[i+1:].strip()
                     # Remote SMTP servers can send us UTF-8 content despite
                     # whether they've declared to do so or not.  Some old
diff --git a/examples/server.py b/examples/server.py
index 792be44..db8b78b 100644
--- a/examples/server.py
+++ b/examples/server.py
@@ -6,7 +6,7 @@ from aiosmtpd.handlers import Sink
 
 
 async def amain(loop):
-    cont = Controller(Sink(), hostname='::0', port=8025)
+    cont = Controller(Sink(), hostname='', port=8025)
     cont.start()
 
 


Test patch:
diff --git a/aiosmtpd/tests/test_smtp.py b/aiosmtpd/tests/test_smtp.py
index be1da96..975f252 100644
--- a/aiosmtpd/tests/test_smtp.py
+++ b/aiosmtpd/tests/test_smtp.py
@@ -197,6 +197,20 @@ class TestSMTP(unittest.TestCase):
         self.addCleanup(controller.stop)
         self.address = (controller.hostname, controller.port)
 
+    def test_binary(self):
+        with SMTP(*self.address) as client:
+            client.sock.send(b"\x80FAIL\r\n")
+            code, response = client.getreply()
+            self.assertEqual(code, 500)
+            self.assertEqual(response, b'Error: bad syntax')
+
+    def test_binary_space(self):
+        with SMTP(*self.address) as client:
+            client.sock.send(b"\x80 FAIL\r\n")
+            code, response = client.getreply()
+            self.assertEqual(code, 500)
+            self.assertEqual(response, b'Error: bad syntax')
+
     def test_helo(self):
         with SMTP(*self.address) as client:
             code, response = client.helo('example.com')