Repository: certbot/certbot

Issue:
Provide a more meaningful error if the user requests a punycode domain
Hi,

I tried to request a cert for a punycode subdomain for 😸.op-co.de (`xn--z38h.op-co.de` as DNS-ID SAN and CN in the CSR) today, and the resulting response was:

> Error: unauthorized :: The client lacks sufficient authorization :: Error creating new authz :: Syntax error

While that host is not whitelisted, there should be a more appropriate response.

CSR (shortened):

```
# openssl req -noout -text -inform der < catface.op-co.de-2015.csr
Certificate Request:
    Data:
        Version: 0 (0x0)
        Subject: C=DE, CN=xn--z38h.op-co.de
        Subject Public Key Info:
            Public Key Algorithm: rsaEncryption
                Public-Key: (4096 bit)
                Modulus:
                    [...]
                Exponent: 65537 (0x10001)
        Attributes:
        Requested Extensions:
            X509v3 Subject Alternative Name:
                DNS:xn--z38h.op-co.de
    Signature Algorithm: sha256WithRSAEncryption
         [...]
```

I'm skipping the log as @jcjones made me aware on IRC that punycode is currently [forbidden in the code](https://github.com/letsencrypt/boulder/search?utf8=%E2%9C%93&q=punycode&type=Code).

This ticket is therefore mainly for documentation purposes and to track the implementation status, should it be subject to change.



Discussion:
Now this has become a merely cosmetic issue. Should I create a new issue (here or in boulder) for actually supporting IDNAs?

Not supporting IDNs is a Boulder issue which it appears they already have an issue for (see letsencrypt/boulder#597)

@ge0rg and we probably want to keep this issue open to fix error reporting on the client side


Resolving patch:
diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py
--- a/letsencrypt/cli.py
+++ b/letsencrypt/cli.py
@@ -28,6 +28,7 @@
 from letsencrypt import constants
 from letsencrypt import client
 from letsencrypt import crypto_util
+from letsencrypt import errors
 from letsencrypt import interfaces
 from letsencrypt import le_util
 from letsencrypt import log
@@ -36,7 +37,6 @@
 
 from letsencrypt.display import util as display_util
 from letsencrypt.display import ops as display_ops
-from letsencrypt.errors import Error, PluginSelectionError, CertStorageError
 from letsencrypt.plugins import disco as plugins_disco
 
 
@@ -106,7 +106,7 @@ def _find_domains(args, installer):
         domains = args.domains
 
     if not domains:
-        raise Error("Please specify --domains, or --installer that "
+        raise errors.Error("Please specify --domains, or --installer that "
                     "will help in domain names autodiscovery")
 
     return domains
@@ -159,9 +159,9 @@ def _tos_cb(regr):
             try:
                 acc, acme = client.register(
                     config, account_storage, tos_cb=_tos_cb)
-            except Error as error:
+            except errors.Error as error:
                 logger.debug(error, exc_info=True)
-                raise Error(
+                raise errors.Error(
                     "Unable to register an account with ACME server")
 
     args.account = acc.id
@@ -195,7 +195,7 @@ def _find_duplicative_certs(config, domains):
         try:
             full_path = os.path.join(configs_dir, renewal_file)
             candidate_lineage = storage.RenewableCert(full_path, cli_config)
-        except (CertStorageError, IOError):
+        except (errors.CertStorageError, IOError):
             logger.warning("Renewal configuration file %s is broken. "
                            "Skipping.", full_path)
             continue
@@ -267,7 +267,7 @@ def _treat_as_renewal(config, domains):
                     br=os.linesep
                 ),
                 reporter_util.HIGH_PRIORITY)
-            raise Error(
+            raise errors.Error(
                 "User did not use proper CLI and would like "
                 "to reinvoke the client.")
 
@@ -327,7 +327,7 @@ def _auth_from_domains(le_client, config, domains, plugins):
         # TREAT AS NEW REQUEST
         lineage = le_client.obtain_and_enroll_certificate(domains, plugins)
         if not lineage:
-            raise Error("Certificate could not be obtained")
+            raise errors.Error("Certificate could not be obtained")
 
     _report_new_cert(lineage.cert, lineage.fullchain)
 
@@ -346,7 +346,7 @@ def set_configurator(previously, now):
     if previously:
         if previously != now:
             msg = "Too many flags setting configurators/installers/authenticators {0} -> {1}"
-            raise PluginSelectionError(msg.format(repr(previously), repr(now)))
+            raise errors.PluginSelectionError(msg.format(repr(previously), repr(now)))
     return now
 
 
@@ -379,7 +379,7 @@ def diagnose_configurator_problem(cfg_type, requested, plugins):
                    '"letsencrypt-auto certonly" to get a cert you can install manually')
     else:
         msg = "{0} could not be determined or is not installed".format(cfg_type)
-    raise PluginSelectionError(msg)
+    raise errors.PluginSelectionError(msg)
 
 
 def choose_configurator_plugins(args, config, plugins, verb):
@@ -439,7 +439,7 @@ def run(args, config, plugins):  # pylint: disable=too-many-branches,too-many-lo
     """Obtain a certificate and install."""
     try:
         installer, authenticator = choose_configurator_plugins(args, config, plugins, "run")
-    except PluginSelectionError, e:
+    except errors.PluginSelectionError, e:
         return e.message
 
     domains = _find_domains(args, installer)
@@ -472,7 +472,7 @@ def obtaincert(args, config, plugins):
     try:
         # installers are used in auth mode to determine domain names
         installer, authenticator = choose_configurator_plugins(args, config, plugins, "certonly")
-    except PluginSelectionError, e:
+    except errors.PluginSelectionError, e:
         return e.message
 
     # TODO: Handle errors from _init_le_client?
@@ -497,7 +497,7 @@ def install(args, config, plugins):
     try:
         installer, _ = choose_configurator_plugins(args, config,
                                                    plugins, "install")
-    except PluginSelectionError, e:
+    except errors.PluginSelectionError, e:
         return e.message
 
     domains = _find_domains(args, installer)
@@ -1060,7 +1060,7 @@ def _handle_exception(exc_type, exc_value, trace, args):
                 sys.exit("".join(
                     traceback.format_exception(exc_type, exc_value, trace)))
 
-        if issubclass(exc_type, Error):
+        if issubclass(exc_type, errors.Error):
             sys.exit(exc_value)
         else:
             # Tell the user a bit about what happened, without overwhelming
@@ -1085,6 +1085,7 @@ def main(cli_args=sys.argv[1:]):
     # note: arg parser internally handles --help (and exits afterwards)
     plugins = plugins_disco.PluginsRegistry.find_all()
     args = prepare_and_parse_args(plugins, cli_args)
+    # Check command line parameters sanity, and error out in case of problem.
     config = configuration.NamespaceConfig(args)
     zope.component.provideUtility(config)
 
@@ -1124,7 +1125,7 @@ def main(cli_args=sys.argv[1:]):
         disclaimer = pkg_resources.resource_string("letsencrypt", "DISCLAIMER")
         if not zope.component.getUtility(interfaces.IDisplay).yesno(
                 disclaimer, "Agree", "Cancel"):
-            raise Error("Must agree to TOS")
+            raise errors.Error("Must agree to TOS")
 
     if not os.geteuid() == 0:
         logger.warning(
@@ -1139,7 +1140,6 @@ def main(cli_args=sys.argv[1:]):
 
     return args.func(args, config, plugins)
 
-
 if __name__ == "__main__":
     err_string = main()
     if err_string:
diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py
--- a/letsencrypt/configuration.py
+++ b/letsencrypt/configuration.py
@@ -1,6 +1,7 @@
 """Let's Encrypt user-supplied configuration."""
 import os
 import urlparse
+import re
 
 import zope.interface
 
@@ -36,11 +37,7 @@ class NamespaceConfig(object):
 
     def __init__(self, namespace):
         self.namespace = namespace
-
-        if self.http01_port == self.tls_sni_01_port:
-            raise errors.Error(
-                "Trying to run http-01 and tls-sni-01 "
-                "on the same port ({0})".format(self.tls_sni_01_port))
+        check_config_sanity(self)
 
     def __getattr__(self, name):
         return getattr(self.namespace, name)
@@ -111,3 +108,45 @@ def renewal_configs_dir(self):  # pylint: disable=missing-docstring
     def renewer_config_file(self):  # pylint: disable=missing-docstring
         return os.path.join(
             self.namespace.config_dir, constants.RENEWER_CONFIG_FILENAME)
+
+
+def check_config_sanity(config):
+    """Validate command line options and display error message if
+    requirements are not met.
+
+    :param config: IConfig instance holding user configuration
+    :type args: :class:`letsencrypt.interfaces.IConfig`
+
+    """
+    # Port check
+    if config.http01_port == config.tls_sni_01_port:
+        raise errors.Error(
+            "Trying to run http-01 and tls-sni-01 "
+            "on the same port ({0})".format(config.tls_sni_01_port))
+
+    # Domain checks
+    if config.namespace.domains is not None:
+        _check_config_domain_sanity(config.namespace.domains)
+
+
+def _check_config_domain_sanity(domains):
+    """Helper method for check_config_sanity which validates
+    domain flag values and errors out if the requirements are not met.
+
+    :param domains: List of domains
+    :type args: `list` of `string`
+
+    """
+    # Check if there's a wildcard domain
+    if any(d.startswith("*.") for d in domains):
+        raise errors.ConfigurationError(
+            "Wildcard domains are not supported")
+    # Punycode
+    if any("xn--" in d for d in domains):
+        raise errors.ConfigurationError(
+            "Punycode domains are not supported")
+    # FQDN, checks:
+    #  Characters used, domain parts < 63 chars, tld > 1 < 7 chars
+    fqdn = re.compile("^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,6}$")
+    if any(True for d in domains if not fqdn.match(d)):
+        raise errors.ConfigurationError("Requested domain is not FQDN")
diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py
--- a/letsencrypt/errors.py
+++ b/letsencrypt/errors.py
@@ -94,3 +94,6 @@ def __init__(self, socket_error, port):
             "Problem binding to port {0}: {1}".format(port, socket_error))
         self.socket_error = socket_error
         self.port = port
+
+class ConfigurationError(Error):
+    """Configuration sanity error."""


Test patch:
diff --git a/letsencrypt/tests/cli_test.py b/letsencrypt/tests/cli_test.py
--- a/letsencrypt/tests/cli_test.py
+++ b/letsencrypt/tests/cli_test.py
@@ -175,6 +175,24 @@ def test_certonly_bad_args(self):
         ret, _, _, _ = self._call(['-a', 'bad_auth', 'certonly'])
         self.assertEqual(ret, 'The requested bad_auth plugin does not appear to be installed')
 
+    def test_check_config_sanity_domain(self):
+        # Punycode
+        self.assertRaises(errors.ConfigurationError,
+                          self._call,
+                          ['-d', 'this.is.xn--ls8h.tld'])
+        # FQDN
+        self.assertRaises(errors.ConfigurationError,
+                          self._call,
+                          ['-d', 'comma,gotwrong.tld'])
+        # FQDN 2
+        self.assertRaises(errors.ConfigurationError,
+                          self._call,
+                          ['-d', 'illegal.character=.tld'])
+        # Wildcard
+        self.assertRaises(errors.ConfigurationError,
+                          self._call,
+                          ['-d', '*.wildcard.tld'])
+
     @mock.patch('letsencrypt.crypto_util.notAfter')
     @mock.patch('letsencrypt.cli.zope.component.getUtility')
     def test_certonly_new_request_success(self, mock_get_utility, mock_notAfter):
diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py
--- a/letsencrypt/tests/renewer_test.py
+++ b/letsencrypt/tests/renewer_test.py
@@ -691,6 +691,7 @@ def test_renew(self, mock_c, mock_acc_storage, mock_pd):
         self.test_rc.configfile["renewalparams"]["tls_sni_01_port"] = "4430"
         self.test_rc.configfile["renewalparams"]["http01_port"] = "1234"
         self.test_rc.configfile["renewalparams"]["account"] = "abcde"
+        self.test_rc.configfile["renewalparams"]["domains"] = ["example.com"]
         mock_auth = mock.MagicMock()
         mock_pd.PluginsRegistry.find_all.return_value = {"apache": mock_auth}
         # Fails because "fake" != "apache"