Skip to content

Commit

Permalink
Sanitize alias, contacts, mailboxes and users before creating them
Browse files Browse the repository at this point in the history
  • Loading branch information
acasajus committed Aug 1, 2023
1 parent f2dad4c commit 6e7b2f4
Show file tree
Hide file tree
Showing 11 changed files with 101 additions and 63 deletions.
2 changes: 1 addition & 1 deletion app/api/views/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import sanitize_email
Expand Down
2 changes: 1 addition & 1 deletion app/dashboard/views/alias_contact_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
is_valid_email,
generate_reply_email,
parse_full_address,
)
from app.email_validation import is_valid_email
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,
Expand Down
2 changes: 1 addition & 1 deletion app/dashboard/views/mailbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
mailbox_already_used,
render,
send_email,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
Expand Down
33 changes: 0 additions & 33 deletions app/email_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -828,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
return False


def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False


class EmailEncoding(enum.Enum):
BASE64 = "base64"
QUOTED = "quoted-printable"
Expand Down Expand Up @@ -1116,26 +1103,6 @@ def is_reverse_alias(address: str) -> bool:
)


# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"


def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)

ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)

return "".join(ret)


def should_disable(alias: Alias) -> (bool, str):
"""
Return whether an alias should be disabled and if yes, the reason why
Expand Down
38 changes: 38 additions & 0 deletions app/email_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from email_validator import (
validate_email,
EmailNotValidError,
)

from app.utils import convert_to_id

# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"


def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False


def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)

ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)

return "".join(ret)
8 changes: 8 additions & 0 deletions app/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ def error_for_user(self) -> str:
return f"{self.address} is not a valid email address"


class InvalidContactEmailError(SLException):
def __init__(self, website_email: str): # noqa: F821
self.website_email = website_email

def error_for_user(self) -> str:
return f"Cannot create contact with invalid email {self.website_email}"


class ErrContactAlreadyExists(SLException):
"""raised when a contact already exists"""

Expand Down
36 changes: 32 additions & 4 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@
from app import config
from app import s3
from app.db import Session
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import (
AliasInTrashError,
DirectoryInTrashError,
SubdomainInTrashError,
CannotCreateContactForReverseAlias,
InvalidContactEmailError,
)
from app.handler.unsubscribe_encoder import UnsubscribeAction, UnsubscribeEncoder
from app.log import LOG
Expand Down Expand Up @@ -577,6 +579,7 @@ def get_id(self):

@classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs):
email = sanitize_email(email)
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)

if password:
Expand Down Expand Up @@ -1802,18 +1805,22 @@ def create(cls, **kw):
commit = kw.pop("commit", False)
flush = kw.pop("flush", False)

new_contact = cls(**kw)

website_email = kw["website_email"]
# make sure email is lowercase and doesn't have any whitespace
website_email = sanitize_email(website_email)
website_email = sanitize_email(kw["website_email"]).replace("\u200f", "")
kw["website_email"] = website_email
if "reply_email" in kw:
kw["reply_email"] = normalize_reply_email(sanitize_email(kw["reply_email"]))

# make sure contact.website_email isn't a reverse alias
if website_email != config.NOREPLY:
orig_contact = Contact.get_by(reply_email=website_email)
if orig_contact:
raise CannotCreateContactForReverseAlias(str(orig_contact))

if not is_valid_email(website_email):
raise InvalidContactEmailError(website_email)

new_contact = cls(**kw)
Session.add(new_contact)

if commit:
Expand Down Expand Up @@ -2300,6 +2307,7 @@ def get_ownership_dns_txt_value(self):
@classmethod
def create(cls, **kwargs):
domain = kwargs.get("domain")
kwargs["domain"] = domain.replace("\n", "")
if DeletedSubdomain.get_by(domain=domain):
raise SubdomainInTrashError

Expand Down Expand Up @@ -2599,6 +2607,26 @@ def aliases(self) -> [Alias]:

return ret

@classmethod
def create(cls, **kw):
# whether to call Session.commit
commit = kw.pop("commit", False)
flush = kw.pop("flush", False)

if "email" in kw:
kw["email"] = sanitize_email(kw["email"])

r = cls(**kw)
Session.add(r)

if commit:
Session.commit()

if flush:
Session.flush()

return r

def __repr__(self):
return f"<Mailbox {self.id} {self.email}>"

Expand Down
3 changes: 1 addition & 2 deletions cron.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,9 @@
render,
email_can_be_used_as_mailbox,
send_email_with_rate_control,
normalize_reply_email,
is_valid_email,
get_email_domain_part,
)
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import ProtonPartnerNotSetUp
from app.log import LOG
from app.mail_sender import load_unsent_mails_from_fs_and_resend
Expand Down
3 changes: 1 addition & 2 deletions email_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,6 @@
get_header_unicode,
generate_reply_email,
is_reverse_alias,
normalize_reply_email,
is_valid_email,
replace,
should_disable,
parse_id_from_bounce,
Expand All @@ -123,6 +121,7 @@
generate_verp_email,
sl_formataddr,
)
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import (
NonReverseAliasInReplyPhase,
VERPTransactional,
Expand Down
3 changes: 1 addition & 2 deletions tests/test_email_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,8 @@
copy,
get_spam_from_header,
get_header_from_bounce,
is_valid_email,
add_header,
generate_reply_email,
normalize_reply_email,
get_encoding,
encode_text,
EmailEncoding,
Expand All @@ -41,6 +39,7 @@
get_verp_info_from_email,
sl_formataddr,
)
from app.email_validation import is_valid_email, normalize_reply_email
from app.models import (
CustomDomain,
Alias,
Expand Down
34 changes: 17 additions & 17 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,20 @@ def test_website_send_to(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
)
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@SL>'
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@sl>'

# empty name, ascii website_from, easy case
c1.name = None
c1.website_from = f"First Last <{prefix}@example.com>"
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@SL>'
assert c1.website_send_to() == f'"First Last | {prefix} at example.com" <rep@sl>'

# empty name, RFC 2047 website_from
c1.name = None
c1.website_from = f"=?UTF-8?B?TmjGoW4gTmd1eeG7hW4=?= <{prefix}@example.com>"
assert c1.website_send_to() == f'"Nhơn Nguyễn | {prefix} at example.com" <rep@SL>'
assert c1.website_send_to() == f'"Nhơn Nguyễn | {prefix} at example.com" <rep@sl>'


def test_new_addr_default_sender_format(flask_client):
Expand All @@ -103,16 +103,16 @@ def test_new_addr_default_sender_format(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
commit=True,
)

assert contact.new_addr() == f'"First Last - {prefix} at example.com" <rep@SL>'
assert contact.new_addr() == f'"First Last - {prefix} at example.com" <rep@sl>'

# Make sure email isn't duplicated if sender name equals email
contact.name = f"{prefix}@example.com"
assert contact.new_addr() == f'"{prefix} at example.com" <rep@SL>'
assert contact.new_addr() == f'"{prefix} at example.com" <rep@sl>'


def test_new_addr_a_sender_format(flask_client):
Expand All @@ -126,12 +126,12 @@ def test_new_addr_a_sender_format(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
commit=True,
)

assert contact.new_addr() == f'"First Last - {prefix}(a)example.com" <rep@SL>'
assert contact.new_addr() == f'"First Last - {prefix}(a)example.com" <rep@sl>'


def test_new_addr_no_name_sender_format(flask_client):
Expand All @@ -145,12 +145,12 @@ def test_new_addr_no_name_sender_format(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
commit=True,
)

assert contact.new_addr() == "rep@SL"
assert contact.new_addr() == "rep@sl"


def test_new_addr_name_only_sender_format(flask_client):
Expand All @@ -164,12 +164,12 @@ def test_new_addr_name_only_sender_format(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
commit=True,
)

assert contact.new_addr() == "First Last <rep@SL>"
assert contact.new_addr() == "First Last <rep@sl>"


def test_new_addr_at_only_sender_format(flask_client):
Expand All @@ -183,12 +183,12 @@ def test_new_addr_at_only_sender_format(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="First Last",
commit=True,
)

assert contact.new_addr() == f'"{prefix} at example.com" <rep@SL>'
assert contact.new_addr() == f'"{prefix} at example.com" <rep@sl>'


def test_new_addr_unicode(flask_client):
Expand All @@ -200,14 +200,14 @@ def test_new_addr_unicode(flask_client):
user_id=user.id,
alias_id=alias.id,
website_email=f"{random_prefix}@example.com",
reply_email="rep@SL",
reply_email="rep@sl",
name="Nhơn Nguyễn",
commit=True,
)

assert (
contact.new_addr()
== f"=?utf-8?q?Nh=C6=A1n_Nguy=E1=BB=85n_-_{random_prefix}_at_example=2Ecom?= <rep@SL>"
== f"=?utf-8?q?Nh=C6=A1n_Nguy=E1=BB=85n_-_{random_prefix}_at_example=2Ecom?= <rep@sl>"
)

# sanity check
Expand Down

0 comments on commit 6e7b2f4

Please sign in to comment.