internationalized domain names (DNS, web, CSRs, normalize to Unicode in database, prohibit non-ASCII characters in user account names)

* For non-ASCII domain names, we will keep the Unicode encoding in our users/aliases table. This is nice for the user and also simplifies things like sorting domain names (using Unicode lexicographic order is good, using ASCII lexicogrpahic order on IDNA is confusing).
* Write nsd config, nsd zone files, nginx config, and SSL CSRs with domains in IDNA-encoded ASCII.
* When checking SSL certificates, treat the CN and SANs as IDNA.
* Since Chrome has an interesting feature of converting Unicode to IDNA in <input type="email"> form fields, we'll also forcibly convert IDNA to Unicode in the domain part of email addresses before saving email addresses in the users/aliases tables so that the table is normalized to Unicode.
* Don't allow non-ASCII characters in user account email addresses. Dovecot gets confused when querying the Sqlite database (which we observed even for non-word ASCII characters too, so it may not be related to the character encoding).
This commit is contained in:
Joshua Tauberer 2015-01-17 13:41:53 +00:00
parent d155aa8745
commit 1bf8f1991f
7 changed files with 87 additions and 18 deletions

View File

@ -4,19 +4,21 @@ CHANGELOG
Development Development
----------- -----------
DNS:
* If a custom CNAME record is set, don't add a default A/AAAA record, e.g. for 'www', which end up preventing the CNAME record from working.
Control panel: Control panel:
* Status checks now check that system services are actually running by pinging each port that should have something running on it. * Status checks now check that system services are actually running by pinging each port that should have something running on it.
* If a custom CNAME record is set on a 'www' subdomain, the default A/AAAA records were preventing the CNAME from working.
Setup: Setup:
* Install cron if it isn't already installed. * Install cron if it isn't already installed.
* Fix a units problem in the minimum memory check. * Fix a units problem in the minimum memory check.
Miscellaneous:
* Internationalized domain names (IDNs) are now supported for DNS and web, but email is not yet tested.
v0.06 (January 4, 2015) v0.06 (January 4, 2015)
----------------------- -----------------------

View File

@ -382,17 +382,26 @@ $TTL 1800 ; default time to live
""" """
# Replace replacement strings. # Replace replacement strings.
zone = zone.format(domain=domain, primary_domain=env["PRIMARY_HOSTNAME"]) zone = zone.format(domain=domain.encode("idna").decode("ascii"), primary_domain=env["PRIMARY_HOSTNAME"].encode("idna").decode("ascii"))
# Add records. # Add records.
for subdomain, querytype, value, explanation in records: for subdomain, querytype, value, explanation in records:
if subdomain: if subdomain:
zone += subdomain zone += subdomain.encode("idna").decode("ascii")
zone += "\tIN\t" + querytype + "\t" zone += "\tIN\t" + querytype + "\t"
if querytype == "TXT": if querytype == "TXT":
# Quote and escape.
value = value.replace('\\', '\\\\') # escape backslashes value = value.replace('\\', '\\\\') # escape backslashes
value = value.replace('"', '\\"') # escape quotes value = value.replace('"', '\\"') # escape quotes
value = '"' + value + '"' # wrap in quotes value = '"' + value + '"' # wrap in quotes
elif querytype in ("NS", "CNAME"):
# These records must be IDNA-encoded.
value = value.encode("idna").decode("ascii")
elif querytype == "MX":
# Also IDNA-encoded, but must parse first.
priority, host = value.split(" ", 1)
host = host.encode("idna").decode("ascii")
value = priority + " " + host
zone += value + "\n" zone += value + "\n"
# DNSSEC requires re-signing a zone periodically. That requires # DNSSEC requires re-signing a zone periodically. That requires
@ -486,7 +495,7 @@ server:
zone: zone:
name: %s name: %s
zonefile: %s zonefile: %s
""" % (domain, zonefile) """ % (domain.encode("idna").decode("ascii"), zonefile)
# If a custom secondary nameserver has been set, allow zone transfers # If a custom secondary nameserver has been set, allow zone transfers
# and notifies to that nameserver. # and notifies to that nameserver.
@ -531,6 +540,9 @@ def sign_zone(domain, zonefile, env):
algo = dnssec_choose_algo(domain, env) algo = dnssec_choose_algo(domain, env)
dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo)) dnssec_keys = load_env_vars_from_file(os.path.join(env['STORAGE_ROOT'], 'dns/dnssec/%s.conf' % algo))
# From here, use the IDNA encoding of the domain name.
domain = domain.encode("idna").decode("ascii")
# In order to use the same keys for all domains, we have to generate # In order to use the same keys for all domains, we have to generate
# a new .key file with a DNSSEC record for the specific domain. We # a new .key file with a DNSSEC record for the specific domain. We
# can reuse the same key, but it won't validate without a DNSSEC # can reuse the same key, but it won't validate without a DNSSEC

View File

@ -14,7 +14,7 @@ def validate_email(email, mode=None):
if mode == 'user': if mode == 'user':
# For Dovecot's benefit, only allow basic characters. # For Dovecot's benefit, only allow basic characters.
ATEXT = r'[\w\-]' ATEXT = r'[a-zA-Z0-9_\-]'
elif mode in (None, 'alias'): elif mode in (None, 'alias'):
# For aliases, we can allow any valid email address. # For aliases, we can allow any valid email address.
# Based on RFC 2822 and https://github.com/SyrusAkbary/validate_email/blob/master/validate_email.py, # Based on RFC 2822 and https://github.com/SyrusAkbary/validate_email/blob/master/validate_email.py,
@ -36,9 +36,34 @@ def validate_email(email, mode=None):
DOT_ATOM_TEXT_HOST = ATEXT + r'+(?:\.' + ATEXT + r'+)+' DOT_ATOM_TEXT_HOST = ATEXT + r'+(?:\.' + ATEXT + r'+)+'
# per RFC 2822 3.4.1 # per RFC 2822 3.4.1
ADDR_SPEC = '^%s@%s$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST) ADDR_SPEC = '^(%s)@(%s)$' % (DOT_ATOM_TEXT_LOCAL, DOT_ATOM_TEXT_HOST)
return re.match(ADDR_SPEC, email) # Check the regular expression.
m = re.match(ADDR_SPEC, email)
if not m: return False
# Check that the domain part is IDNA-encodable.
localpart, domainpart = m.groups()
try:
domainpart.encode("idna")
except:
return False
return True
def sanitize_idn_email_address(email):
# Convert an IDNA-encoded email address (domain part) into Unicode
# before storing in our database. Chrome may IDNA-ize <input type="email">
# values before POSTing, so we want to normalize before putting
# values into the database.
try:
localpart, domainpart = email.split("@")
domainpart = domainpart.encode("ascii").decode("idna")
return localpart + "@" + domainpart
except:
# Domain part is already Unicode or not IDNA-valid, so
# leave unchanged.
return email
def open_database(env, with_connection=False): def open_database(env, with_connection=False):
conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite") conn = sqlite3.connect(env["STORAGE_ROOT"] + "/mail/users.sqlite")
@ -230,6 +255,9 @@ def get_mail_domains(env, filter_aliases=lambda alias : True):
) )
def add_mail_user(email, pw, privs, env): def add_mail_user(email, pw, privs, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# validate email # validate email
if email.strip() == "": if email.strip() == "":
return ("No email address provided.", 400) return ("No email address provided.", 400)
@ -284,6 +312,10 @@ def add_mail_user(email, pw, privs, env):
return kick(env, "mail user added") return kick(env, "mail user added")
def set_mail_password(email, pw, env): def set_mail_password(email, pw, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# validate that password is acceptable
validate_password(pw) validate_password(pw)
# hash the password # hash the password
@ -298,6 +330,10 @@ def set_mail_password(email, pw, env):
return "OK" return "OK"
def remove_mail_user(email, env): def remove_mail_user(email, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# remove
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM users WHERE email=?", (email,)) c.execute("DELETE FROM users WHERE email=?", (email,))
if c.rowcount != 1: if c.rowcount != 1:
@ -311,6 +347,10 @@ def parse_privs(value):
return [p for p in value.split("\n") if p.strip() != ""] return [p for p in value.split("\n") if p.strip() != ""]
def get_mail_user_privileges(email, env): def get_mail_user_privileges(email, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# get privs
c = open_database(env) c = open_database(env)
c.execute('SELECT privileges FROM users WHERE email=?', (email,)) c.execute('SELECT privileges FROM users WHERE email=?', (email,))
rows = c.fetchall() rows = c.fetchall()
@ -324,6 +364,9 @@ def validate_privilege(priv):
return None return None
def add_remove_mail_user_privilege(email, priv, action, env): def add_remove_mail_user_privilege(email, priv, action, env):
# accept IDNA domain names but normalize to Unicode before going into database
email = sanitize_idn_email_address(email)
# validate # validate
validation = validate_privilege(priv) validation = validate_privilege(priv)
if validation: return validation if validation: return validation
@ -351,6 +394,9 @@ def add_remove_mail_user_privilege(email, priv, action, env):
return "OK" return "OK"
def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True): def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=True):
# accept IDNA domain names but normalize to Unicode before going into database
source = sanitize_idn_email_address(source)
# validate source # validate source
if source.strip() == "": if source.strip() == "":
return ("No incoming email address provided.", 400) return ("No incoming email address provided.", 400)
@ -363,13 +409,14 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
if validate_email(destination, mode='alias'): if validate_email(destination, mode='alias'):
# Oostfix allows a single @domain.tld as the destination, which means # Oostfix allows a single @domain.tld as the destination, which means
# the local part on the address is preserved in the rewrite. # the local part on the address is preserved in the rewrite.
dests.append(destination) dests.append(sanitize_idn_email_address(destination))
else: else:
# Parse comma and \n-separated destination emails & validate. In this # Parse comma and \n-separated destination emails & validate. In this
# case, the recipients must be complete email addresses. # case, the recipients must be complete email addresses.
for line in destination.split("\n"): for line in destination.split("\n"):
for email in line.split(","): for email in line.split(","):
email = email.strip() email = email.strip()
email = sanitize_idn_email_address(email) # Unicode => IDNA
if email == "": continue if email == "": continue
if not validate_email(email): if not validate_email(email):
return ("Invalid destination email address (%s)." % email, 400) return ("Invalid destination email address (%s)." % email, 400)
@ -397,6 +444,10 @@ def add_mail_alias(source, destination, env, update_if_exists=False, do_kick=Tru
return kick(env, return_status) return kick(env, return_status)
def remove_mail_alias(source, env, do_kick=True): def remove_mail_alias(source, env, do_kick=True):
# accept IDNA domain names but normalize to Unicode before going into database
source = sanitize_idn_email_address(source)
# remove
conn, c = open_database(env, with_connection=True) conn, c = open_database(env, with_connection=True)
c.execute("DELETE FROM aliases WHERE source=?", (source,)) c.execute("DELETE FROM aliases WHERE source=?", (source,))
if c.rowcount != 1: if c.rowcount != 1:

View File

@ -552,6 +552,7 @@ def check_certificate(domain, ssl_certificate, ssl_private_key):
if m: if m:
cert_expiration_date = dateutil.parser.parse(m.group(1)) cert_expiration_date = dateutil.parser.parse(m.group(1))
domain = domain.encode("idna").decode("ascii")
wildcard_domain = re.sub("^[^\.]+", "*", domain) wildcard_domain = re.sub("^[^\.]+", "*", domain)
if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names: if domain is not None and domain not in certificate_names and wildcard_domain not in certificate_names:
return ("The certificate is for the wrong domain name. It is for %s." return ("The certificate is for the wrong domain name. It is for %s."

View File

@ -27,6 +27,7 @@
<label for="addaliasEmail" class="col-sm-1 control-label">Alias</label> <label for="addaliasEmail" class="col-sm-1 control-label">Alias</label>
<div class="col-sm-10"> <div class="col-sm-10">
<input type="email" class="form-control" id="addaliasEmail"> <input type="email" class="form-control" id="addaliasEmail">
<div style="margin-top: 3px; padding-left: 3px; font-size: 90%" class="text-muted">You may use international (non-ASCII) characters, but this has not yet been well tested.</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -12,7 +12,7 @@
<h3>Add a mail user</h3> <h3>Add a mail user</h3>
<p>Add an email address to this system. This will create a new login username/password. (Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.)</p> <p>Add an email address to this system. This will create a new login username/password.</p>
<form class="form-inline" role="form" onsubmit="return do_add_user(); return false;"> <form class="form-inline" role="form" onsubmit="return do_add_user(); return false;">
<div class="form-group"> <div class="form-group">
@ -31,10 +31,12 @@
</div> </div>
<button type="submit" class="btn btn-primary">Add User</button> <button type="submit" class="btn btn-primary">Add User</button>
</form> </form>
<p style="margin-top: .5em"><small> <ul style="margin-top: 1em; padding-left: 1.5em; font-size: 90%;">
Passwords must be at least four characters and may not contain spaces. <li>Passwords must be at least four characters and may not contain spaces.</li>
Administrators get access to this control panel. <li>Use <a href="javascript:show_panel('aliases')">aliases</a> to create email addresses that forward to existing accounts.</li>
</small></p> <li>Administrators get access to this control panel.</li>
<li>User accounts cannot contain any international (non-ASCII) characters, but <a href="javascript:show_panel('aliases')">aliases</a> can.</li>
</ul>
<h3>Existing mail users</h3> <h3>Existing mail users</h3>
<table id="user_table" class="table" style="width: auto"> <table id="user_table" class="table" style="width: auto">

View File

@ -89,7 +89,7 @@ def make_domain_config(domain, template, template_for_primaryhost, env):
# Replace substitution strings in the template & return. # Replace substitution strings in the template & return.
nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT']) nginx_conf = nginx_conf.replace("$STORAGE_ROOT", env['STORAGE_ROOT'])
nginx_conf = nginx_conf.replace("$HOSTNAME", domain) nginx_conf = nginx_conf.replace("$HOSTNAME", domain.encode("idna").decode("ascii"))
nginx_conf = nginx_conf.replace("$ROOT", root) nginx_conf = nginx_conf.replace("$ROOT", root)
nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key) nginx_conf = nginx_conf.replace("$SSL_KEY", ssl_key)
nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate) nginx_conf = nginx_conf.replace("$SSL_CERTIFICATE", ssl_certificate)
@ -210,7 +210,7 @@ def create_csr(domain, ssl_key, env):
"-key", ssl_key, "-key", ssl_key,
"-out", "/dev/stdout", "-out", "/dev/stdout",
"-sha256", "-sha256",
"-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain)]) "-subj", "/C=%s/ST=/L=/O=/CN=%s" % (env["CSR_COUNTRY"], domain.encode("idna").decode("ascii"))])
def install_cert(domain, ssl_cert, ssl_chain, env): def install_cert(domain, ssl_cert, ssl_chain, env):
if domain not in get_web_domains(env): if domain not in get_web_domains(env):