linkchecker/linkcheck/checker/httpsurl.py
Bastian Kleineidam c806be5c15 Updated copyright
2014-01-08 22:33:04 +01:00

179 lines
6.9 KiB
Python

# -*- coding: iso-8859-1 -*-
# Copyright (C) 2000-2014 Bastian Kleineidam
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""
Handle https links.
"""
import time
from . import httpurl
from .const import WARN_HTTPS_CERTIFICATE
from .. import log, LOG_CHECK, strformat
class HttpsUrl (httpurl.HttpUrl):
"""
Url link with https scheme.
"""
def local_check (self):
"""
Check connection if SSL is supported, else ignore.
"""
if httpurl.supportHttps:
super(HttpsUrl, self).local_check()
else:
self.add_info(_("%s URL ignored.") % self.scheme.capitalize())
def get_http_object (self, scheme, host, port):
"""Open a HTTP connection and check the SSL certificate."""
super(HttpsUrl, self).get_http_object(scheme, host, port)
self.check_ssl_certificate(self.url_connection.sock, host)
def check_ssl_certificate(self, ssl_sock, host):
"""Run all SSL certificate checks that have not yet been done.
OpenSSL already checked the SSL notBefore and notAfter dates.
"""
if not hasattr(ssl_sock, "getpeercert"):
# the URL was a HTTPS -> HTTP redirect
return
cert = ssl_sock.getpeercert()
log.debug(LOG_CHECK, "Got SSL certificate %s", cert)
if not cert:
return
if 'subject' in cert:
self.check_ssl_hostname(ssl_sock, cert, host)
else:
msg = _('certificate did not include "subject" information')
self.add_ssl_warning(ssl_sock, msg)
if 'notAfter' in cert:
self.check_ssl_valid_date(ssl_sock, cert)
else:
msg = _('certificate did not include "notAfter" information')
self.add_ssl_warning(ssl_sock, msg)
def check_ssl_hostname(self, ssl_sock, cert, host):
"""Check the hostname against the certificate according to
RFC2818.
"""
try:
match_hostname(cert, host)
except CertificateError as msg:
self.add_ssl_warning(ssl_sock, msg)
def check_ssl_valid_date(self, ssl_sock, cert):
"""Check if the certificate is still valid, or if configured check
if it's at least a number of days valid.
"""
import ssl
checkDaysValid = self.aggregate.config["warnsslcertdaysvalid"]
try:
notAfter = ssl.cert_time_to_seconds(cert['notAfter'])
except ValueError as msg:
msg = _('invalid certficate "notAfter" value %r') % cert['notAfter']
self.add_ssl_warning(ssl_sock, msg)
return
curTime = time.time()
# Calculate seconds until certifcate expires. Can be negative if
# the certificate is already expired.
secondsValid = notAfter - curTime
if secondsValid < 0:
msg = _('certficate is expired on %s') % cert['notAfter']
self.add_ssl_warning(ssl_sock, msg)
elif checkDaysValid > 0 and \
secondsValid < (checkDaysValid * strformat.SECONDS_PER_DAY):
strSecondsValid = strformat.strduration_long(secondsValid)
msg = _('certificate is only %s valid') % strSecondsValid
self.add_ssl_warning(ssl_sock, msg)
def add_ssl_warning(self, ssl_sock, msg):
"""Add a warning message about an SSL certificate error."""
cipher_name, ssl_protocol, secret_bits = ssl_sock.cipher()
err = _(u"SSL warning: %(msg)s. Cipher %(cipher)s, %(protocol)s.")
attrs = dict(msg=msg, cipher=cipher_name, protocol=ssl_protocol)
self.add_warning(err % attrs, tag=WARN_HTTPS_CERTIFICATE)
# Copied from ssl.py in Python 3:
# Wrapper module for _ssl, providing some additional facilities
# implemented in Python. Written by Bill Janssen.
import re
class CertificateError(ValueError):
"""Raised on certificate errors."""
pass
def _dnsname_to_pat(dn, max_wildcards=1):
"""Convert a DNS certificate name to a hostname matcher."""
pats = []
for frag in dn.split(r'.'):
if frag.count('*') > max_wildcards:
# Issue #17980: avoid denials of service by refusing more
# than one wildcard per fragment. A survery of established
# policy among SSL implementations showed it to be a
# reasonable choice.
raise CertificateError(
"too many wildcards in certificate DNS name: " + repr(dn))
if frag == '*':
# When '*' is a fragment by itself, it matches a non-empty dotless
# fragment.
pats.append('[^.]+')
else:
# Otherwise, '*' matches any dotless fragment.
frag = re.escape(frag)
pats.append(frag.replace(r'\*', '[^.]*'))
return re.compile(r'\A' + r'\.'.join(pats) + r'\Z', re.IGNORECASE)
def match_hostname(cert, hostname):
"""Verify that *cert* (in decoded format as returned by
SSLSocket.getpeercert()) matches the *hostname*. RFC 2818 rules
are mostly followed, but IP addresses are not accepted for *hostname*.
CertificateError is raised on failure. On success, the function
returns nothing.
"""
if not cert:
raise ValueError("empty or no certificate")
dnsnames = []
san = cert.get('subjectAltName', ())
for key, value in san:
if key == 'DNS':
if _dnsname_to_pat(value).match(hostname):
return
dnsnames.append(value)
if not dnsnames:
# The subject is only checked when there is no dNSName entry
# in subjectAltName
for sub in cert.get('subject', ()):
for key, value in sub:
# XXX according to RFC 2818, the most specific Common Name
# must be used.
if key == 'commonName':
if _dnsname_to_pat(value).match(hostname):
return
dnsnames.append(value)
if len(dnsnames) > 1:
raise CertificateError("hostname %r "
"doesn't match either of %s"
% (hostname, ', '.join(map(repr, dnsnames))))
elif len(dnsnames) == 1:
raise CertificateError("hostname %r "
"doesn't match %r"
% (hostname, dnsnames[0]))
else:
raise CertificateError("no appropriate commonName or "
"subjectAltName fields were found")