Python 2.7.9 y Python 3.4.3 pasan a verificar las conexiones HTTPS por defecto

Python tiene una política de actualizaciones muy clara: Las actualizaciones menores (en la versión 2.7.9, el "9") de las versiones activas de Python solucionan exclusivamente bugs de estabilidad y seguridad. Las actualizaciones deberían ser indoloras en el sentido de que ningún programa Python que funcionase antes debería fallar tras actualizar. Es decir se prohíben los cambios de comportamiento.

Pero a veces hay que saltarse las normas. Python 3 es el futuro del lenguaje y la rama 2 se considera cerrada, así que la versión 2.7 de Python va a ser mantenida hasta, al menos, 2020. La idea es meter presión para migrar a Python 3, pero permitiendo una migración tranquila y sosegada. 2020... Eso son muchos años simplemente parcheando los bugs mínimos para que Python 2.7 siga funcionando. En particular los protocolos de seguridad de red van variando y las valoraciones que se tenían en cuenta en 2010 (el año en que se publicó Python 2.7) no son válidas en 2015, ya no digamos 2020.

Por eso se definió, en marzo de 2014, el PEP 466: Network Security Enhancements for Python 2.7.x. En dicho PEP (Python Enhancement Proposal) se propone portar las mejoras de seguridad de red de Python 3.4 a la rama 2.7. Cosas como SNI, validación de certificados X.509, etc.

La sección Backwards compatibility considerations del PEP empieza con un párrafo interesante:

As in the Python 3 series, the backported ssl.create_default_context() API is granted a backwards compatibility exemption that permits the protocol, options, cipher and other settings of the created SSL context to be updated in maintenance releases to use higher default security settings. This allows them to appropriately balance compatibility and security at the time of the maintenance release, rather than at the time of the original feature release.

Básicamente lo que se dice es que los desarrolladores de Python se reservan el derecho de actualizar la configuración SSL por defecto (en realidad en 2015 hablamos de TLS) en las actualizaciones menores cuando sea necesario por motivos de seguridad. Puedes leer un análisis de la discusión sobre el asunto en Python, SSL/TLS certificates and default validation.

Y esto es lo que ha ocurrido con las actualizaciones Python 2.7.9 y 3.4.3:

  • Issue #22638: SSLv3 is now disabled throughout the standard library. It can still be enabled by instantiating a SSLContext manually.
  • Issue #22417: Verify certificates by default in httplib (PEP 476).
  • PEP 476: Enabling certificate verification by default for stdlib http clients.

Python 2.7.9 se publicó el 10 de diciembre de 2014. Python 3.4.3 se publicó el 25 de febrero de 2015.

El problema

A partir de Python 2.7.9 y Python 3.4.3 las conexiones HTTPS, por defecto, verifican el certificado X.509 del servidor. Los certificados autofirmados dejan de funcionar. Los certificados caducados o emitidos para un nombre DNS diferente dejan de funcionar. Tus conexiones HTTPS dejarán de funcionar si Python no puede encontrar un listado de entidades de certificación reconocidas en tu sistema. Por defecto.

Un error típico sería algo así:

Python 2.7.9 (dtrace-issue13405_2.7:05d8fd4c57a1, Dec 15 2014, 00:21:54)
[GCC 4.9.2] on sunos5
Type "help", "copyright", "credits" or "license" for more information.
>>> import httplib
>>> httplib.HTTPS("twitter.com").connect()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python2.7/httplib.py", line 1129, in connect
    self._conn.connect()
  File "/usr/local/lib/python2.7/httplib.py", line 1212, in connect
    server_hostname=server_hostname)
  File "/usr/local/lib/python2.7/ssl.py", line 350, in wrap_socket
    _context=self)
  File "/usr/local/lib/python2.7/ssl.py", line 566, in __init__
    self.do_handshake()
  File "/usr/local/lib/python2.7/ssl.py", line 788, in do_handshake
    self._sslobj.do_handshake()
ssl.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:581)

Algunas veces los errores son más misteriosos porque las librerías se comen las excepciones y ocultan los detalles. En estos casos la investigación puede ser complicada:

Un ejemplo:

Traceback (most recent call last):
  File "./z.py", line 29, in <module>
    if not is_latch_open() :
  File "./z.py", line 23, in is_latch_open
    return reply['operations'][operationId]['status'] == 'on'
TypeError: string indices must be integers

Otro caso diferente:

Traceback (most recent call last):
  File "/usr/local/lib/python3.4/site-packages/feedparser.py", line 414, in __getattr__
    return self.__getitem__(key)
  File "/usr/local/lib/python3.4/site-packages/feedparser.py", line 375, in __getitem__
    return dict.__getitem__(self, key)
KeyError: 'status'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "./z.py", line 29, in <module>
  File "/usr/local/lib/python3.4/site-packages/feedparser.py", line 416, in __getattr__
    raise AttributeError("object has no attribute '%s'" % key)
AttributeError: object has no attribute 'status'

La causa última es la misma: imposibilidad para verificar el certificado X.509 del servidor en una conexión HTTPS.

Solución

Los desarrolladores Python han cambiado la configuración por defecto. Ahora es más estricta. Si nuestro programa no hacía nada especial con el contexto SSL y confiaba en los valores por defecto, estos han cambiado.

Hay cuatro soluciones sencillas:

  1. Indicarle a Python dónde está nuestra colección de entidades de certificación:

    Python buscará las entidades de certificación en varios directorios, dependiendo de tu plataforma. Si tenemos ese listado, pero Python no es capaz de encontrarlo, podemos ayudarle un poco mediante las variables de entorno SSL_CERT_FILE y SSL_CERT_DIR.

  2. Poner las entidades de certificación en el lugar donde el intérprete Python va a buscarlas:

    Podemos ver dónde está buscando los certificados mediante la función ssl.get_default_verify_paths(). Así podemos meter los certificados donde Python espera encontrarlos.

  3. Modificar el programa para que use entidades de certificación personalizadas:

    A veces no nos sirve con las entidades de certificación del sistema porque podemos estar trabajando con certificados autofirmados o entidades de certificación corporativas. En estos casos es útil modificar el programa para añadirle nuevas entidades de certificación. Algo similar a:

    import ssl
    context = ssl.create_default_context(cafile = "MI_CA_PRIVADA")
    urllib.urlopen("https://INTRANET/", context = context)
    
  4. Si lo que necesitamos es volver al comportamiento previo de no verificar nada, aún podemos hacerlo de forma simple. Por supuesto no recomiendo esta opción pero hay casos en los que es necesario por algún motivo.

    Para dejar desprotegida una conexión HTTPS concreta podemos hacer:

    import ssl
    context = ssl._create_unverified_context()
    urllib.urlopen("https://CERT-INVALIDO", context=context)
    

    Una alternativa más salvaje desactiva la verificación en todas las conexiones HTTPS del programa:

    import ssl
    ssl._create_default_https_context = ssl._create_unverified_context
    

¿Qué he hecho yo?

Mi portátil tiene una librería OpenSSL que no es la habitual del sistema. En fin, cosas mías. En mi portátil Python buscará las entidades de certificación reconocidas en:

>>> import ssl
>>> ssl.get_default_verify_paths()
DefaultVerifyPaths(cafile=None, capath='/usr/local/ssl/certs', openssl_cafile_env='SSL_CERT_FILE', openssl_cafile='/usr/local/ssl/cert.pem', openssl_capath_env='SSL_CERT_DIR', openssl_capath='/usr/local/ssl/certs')

El directorio donde están las entidades de certificación del sistema es /etc/ssl/certs/. Basta con que ponga un enlace simbólico:

$ sudo rmdir /usr/local/ssl/certs  # Directorio vacío
$ sudo ln -s /etc/ssl/certs /usr/local/ssl/certs

Ahora Python ya podrá encontrar las entidades de certificación.

¿No has notado nada raro?

Seguramente será porque tus programas Python solamente interactúan con servidores HTTPS con certificados X.509 válidos y el intérprete de Python ha sido capaz de encontrar un listado de entidades de certificación reconocidas en tu sistema operativo. Bravo por ti :).

Es de señalar, por último, que este cambio en Python se limita a las conexiones HTTPS. Las conexiones SMTP, POP3, IMAP4, FTP, etc, con SSL siguen sin verificarse por defecto. Esto podría cambiar en el futuro.