Repository: Mailu/Mailu

Issue:
MAIL FROM: does-not-exist@[X.X.X.X] results in an admin exeption stacktrace
If a client (read: malicious attacker) connects to Postfix and uses an IP address within brackets instead of a domain name for the sender, e.g.:

```
$ telnet mail.example.com 25

Connected to example.com
Escape character is '^]'.
220 mail.example.com ESMTP ready
EHLO dummy.example.com
250-mail.example.com
250 STARTTLS
MAIL FROM: does-not-exist@[116.203.165.200]
250 2.0.0 OK
RCPT TO: some-user@example.com
451 4.3.0 <does-not-exist@[116.203.165.200]>: Temporary lookup failure
```

we see the following stracktrace in the admin container:

```
[2019-07-27 14:26:27,015] ERROR in app: Exception on /internal/postfix/domain/[116.203.165.200] [GET]
Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 271, in alabel
    ulabel(label)
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 307, in ulabel
    check_label(label)
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 261, in check_label
    raise InvalidCodepoint('Codepoint {0} at position {1} of {2} not allowed'.format(_unot(cp_value), pos+1, repr(label)))
idna.core.InvalidCodepoint: Codepoint U+005B at position 1 of '[116' not allowed

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 1127, in _execute_context
    context = constructor(dialect, self, conn, *args)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/default.py", line 669, in _init_compiled
    param.append(processors[key](compiled_params[key]))
  File "/usr/lib/python3.7/site-packages/sqlalchemy/sql/type_api.py", line 1182, in process
    return process_param(value, dialect)
  File "/app/mailu/models.py", line 30, in process_bind_param
    return idna.encode(value).decode("ascii").lower()
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 361, in encode
    s = alabel(label)
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 273, in alabel
    raise IDNAError('The label {0} is not a valid A-label'.format(label))
idna.core.IDNAError: The label b'[116' is not a valid A-label

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/flask/app.py", line 2292, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/lib/python3.7/site-packages/flask/app.py", line 1815, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/lib/python3.7/site-packages/flask/app.py", line 1718, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/lib/python3.7/site-packages/flask/_compat.py", line 35, in reraise
    raise value
  File "/usr/lib/python3.7/site-packages/flask/app.py", line 1813, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/lib/python3.7/site-packages/flask/app.py", line 1799, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/app/mailu/internal/views/postfix.py", line 9, in postfix_mailbox_domain
    domain = models.Domain.query.get(domain_name) or \
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 924, in get
    ident, loading.load_on_pk_identity)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 1007, in _get_impl
    return db_load_fn(self, primary_key_identity)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/loading.py", line 250, in load_on_pk_identity
    return q.one()
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 2954, in one
    ret = self.one_or_none()
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 2924, in one_or_none
    ret = list(self)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 2995, in __iter__
    return self._execute_and_instances(context)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/orm/query.py", line 3018, in _execute_and_instances
    result = conn.execute(querycontext.statement, self._params)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 948, in execute
    return meth(self, multiparams, params)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/sql/elements.py", line 269, in _execute_on_connection
    return connection._execute_clauseelement(self, multiparams, params)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 1060, in _execute_clauseelement
    compiled_sql, distilled_params
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 1132, in _execute_context
    None, None)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 1413, in _handle_dbapi_exception
    exc_info
  File "/usr/lib/python3.7/site-packages/sqlalchemy/util/compat.py", line 265, in raise_from_cause
    reraise(type(exception), exception, tb=exc_tb, cause=cause)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/util/compat.py", line 248, in reraise
    raise value.with_traceback(tb)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/base.py", line 1127, in _execute_context
    context = constructor(dialect, self, conn, *args)
  File "/usr/lib/python3.7/site-packages/sqlalchemy/engine/default.py", line 669, in _init_compiled
    param.append(processors[key](compiled_params[key]))
  File "/usr/lib/python3.7/site-packages/sqlalchemy/sql/type_api.py", line 1182, in process
    return process_param(value, dialect)
  File "/app/mailu/models.py", line 30, in process_bind_param
    return idna.encode(value).decode("ascii").lower()
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 361, in encode
    s = alabel(label)
  File "/usr/lib/python3.7/site-packages/idna/core.py", line 273, in alabel
    raise IDNAError('The label {0} is not a valid A-label'.format(label))
sqlalchemy.exc.StatementError: (idna.core.IDNAError) The label b'[116' is not a valid A-label [SQL: 'SELECT domain.created_at AS domain_created_at, domain.updated_at AS domain_updated_at, domain.comment AS domain_comment, domain.name AS domain_name, domain.max_users AS domain_max_users, domain.max_aliases AS domain_max_aliases, domain.max_quota_bytes AS domain_max_quota_bytes, domain.signup_enabled AS domain_signup_enabled \nFROM domain \nWHERE domain.name = ?'] [parameters: [{'%(140312238936248 param)s': '[116.203.165.200]'}]]
10.36.0.0 - - [27/Jul/2019:14:26:27 +0000] "GET /internal/postfix/domain/%5B116.203.165.200%5D HTTP/1.1" 500 291 "-" "Python/3.7 aiohttp/3.5.4"
10.44.0.0 - - [27/Jul/2019:14:26:31 +0000] "GET /internal/auth/email HTTP/1.0" 200 0 "-" "-"
```

NOTE: the lookup is cached I think and you need to use another IP address to reproduce the error multiple times.

I think we're missing some validation rules, because trying a similar (invalid) MAIL FROM with an IP (but without brackets) is handled properly:

```
telnet mail.example.com 25
Trying ...
Connected to example.com.
Escape character is '^]'.
220 mail.example.com ESMTP ready
EHLO dummy.example.com
250-mail.example.com
250 STARTTLS
MAIL FROM: does-not-exist@192.168.1.1
250 2.0.0 OK
RCPT TO: some-user@example.com
501 5.1.7 Bad sender address syntax
Connection closed by foreign host.
```


Discussion:
Additionally (and what makes it rather irritating and confusing is) we see in the SMTP container the following stracktrace:
```
Jul 27 14:41:00 mail postfix/smtpd[19453]: connect from unknown[10.32.0.1]
ERROR:root:Error when processing request
Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/podop/postfix.py", line 104, in process_request
    result = await table.get(key)
  File "/usr/lib/python3.7/site-packages/podop/table.py", line 35, in get
    raise Exception(request.status)
Exception: 500
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: socketmap:unix:/tmp/podop.socket:domain socketmap server temporary error: unknown error
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: virtual_mailbox_domains: socketmap:unix:/tmp/podop.socket:domain: table lookup problem
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: virtual_mailbox_domains lookup failure
ERROR:root:Error when processing request
Traceback (most recent call last):
  File "/usr/lib/python3.7/site-packages/podop/postfix.py", line 104, in process_request
    result = await table.get(key)
  File "/usr/lib/python3.7/site-packages/podop/table.py", line 35, in get
    raise Exception(request.status)
Exception: 500
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: socketmap:unix:/tmp/podop.socket:domain socketmap server temporary error: unknown error
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: virtual_mailbox_domains: socketmap:unix:/tmp/podop.socket:domain: table lookup problem
Jul 27 14:41:00 mail postfix/trivial-rewrite[19460]: warning: virtual_mailbox_domains lookup failure
Jul 27 14:41:00 mail postfix/smtpd[19453]: NOQUEUE: reject: RCPT from unknown[X.X.X.X]: 451 4.3.0 <does-not-exist@[116.203.165.200]>: Temporary lookup failure; from=<does-not-exist@[116.203.165.200]> to=<some-user@example.com> proto=ESMTP helo=<dummy.example.com>
Jul 27 14:43:11 mail postfix/smtpd[19464]: connect from unknown[10.32.0.1]
```

Resolving patch:
diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py
--- a/core/admin/mailu/internal/views/postfix.py
+++ b/core/admin/mailu/internal/views/postfix.py
@@ -2,10 +2,13 @@
 from mailu.internal import internal
 
 import flask
+import re
 
 
 @internal.route("/postfix/domain/<domain_name>")
 def postfix_mailbox_domain(domain_name):
+    if re.match("^\[.*\]$", domain_name):
+        return flask.abort(404)
     domain = models.Domain.query.get(domain_name) or \
              models.Alternative.query.get(domain_name) or \
              flask.abort(404)
@@ -29,7 +32,7 @@ def postfix_alias_map(alias):
 
 @internal.route("/postfix/transport/<path:email>")
 def postfix_transport(email):
-    if email == '*':
+    if email == '*' or re.match("(^|.*@)\[.*\]$", email):
         return flask.abort(404)
     localpart, domain_name = models.Email.resolve_domain(email)
     relay = models.Relay.query.get(domain_name) or flask.abort(404)


Test patch: