Commit 861563f4 authored by Stephane Bortzmeyer's avatar Stephane Bortzmeyer
Browse files

[DoT] New, RFC6215-compliant host name validation. Closes #11

parent 9f053941
...@@ -75,6 +75,13 @@ def is_valid_hostname(name): ...@@ -75,6 +75,13 @@ def is_valid_hostname(name):
name = str(name.encode('idna').lower()) name = str(name.encode('idna').lower())
return re_host.search(name) return re_host.search(name)
def is_valid_ip_address(addr):
try:
baddr = netaddr.IPAddress(addr)
except netaddr.core.AddrFormatError:
return False
return True
def is_valid_url(url): def is_valid_url(url):
try: try:
result = urllib.parse.urlparse(url) # A very poor validation, many result = urllib.parse.urlparse(url) # A very poor validation, many
...@@ -93,27 +100,44 @@ def get_certificate_san(x509cert): ...@@ -93,27 +100,44 @@ def get_certificate_san(x509cert):
san = str(ext) san = str(ext)
return san return san
def validate_hostname(hostname, cert): # Try one possible name. Names must be already canonicalized.
hostname = hostname.lower() def match_hostname(hostname, possibleMatch):
cn = cert.get_subject().commonName.lower() print("Testing %s against %s" % (hostname, possibleMatch))
if cn.startswith("*."): # Wildcard if possibleMatch.startswith("*."): # Wildcard
(start, base) = cn.split("*.") base = possibleMatch[1:] # Skip the star
if hostname.endswith(base): # RFC 6125 says that we MAY accept left-most labels with
# wildcards included (foo*bar). We don't do it here.
try:
(first, rest) = hostname.split(".", maxsplit=1)
except ValueError: # One-label name
rest = hostname
if rest == base[1:]:
return True return True
else: if hostname == base[1:]:
if hostname == cn:
return True return True
return False
else:
return hostname == possibleMatch
# Try all the names in the certificate
def validate_hostname(hostname, cert):
# Complete specification is in RFC 6125. It is long and
# complicated and I'm not sure we do it perfectly.
is_addr = is_valid_ip_address(hostname)
hostname = hostname.lower()
hostname = hostname.encode('idna').decode()
for alt_name in get_certificate_san(cert).split(", "): for alt_name in get_certificate_san(cert).split(", "):
if alt_name.startswith("DNS:"): if alt_name.startswith("DNS:") and not is_addr:
(start, base) = alt_name.split("DNS:") (start, base) = alt_name.split("DNS:")
base = base.lower() base = base.lower()
if hostname == base: # We assume the certificate contains only
# A-labels. Otherwise, we would need to: "base =
# str(base.encode('idna'))"
found = match_hostname(hostname, base)
if found:
return True return True
elif alt_name.startswith("IP Address:"): elif alt_name.startswith("IP Address:") and is_addr:
try:
host_i = netaddr.IPAddress(hostname) host_i = netaddr.IPAddress(hostname)
except netaddr.core.AddrFormatError:
continue # If hostname is not an IP address, we cannot use it for comparison
(start, base) = alt_name.split("IP Address:") (start, base) = alt_name.split("IP Address:")
if base.endswith("\n"): if base.endswith("\n"):
base = base[:-1] base = base[:-1]
...@@ -121,16 +145,23 @@ def validate_hostname(hostname, cert): ...@@ -121,16 +145,23 @@ def validate_hostname(hostname, cert):
base_i = netaddr.IPAddress(base) base_i = netaddr.IPAddress(base)
except netaddr.core.AddrFormatError: except netaddr.core.AddrFormatError:
continue # Ignore broken IP addresses in certificates. Are we too liberal? continue # Ignore broken IP addresses in certificates. Are we too liberal?
print("Testing %s against %s" % (hostname, base))
if host_i == base_i: if host_i == base_i:
return True return True
else: else:
pass # Ignore unknown alternative name types pass # Ignore unknown alternative name types. May be
# accept URI alternative names for DoH,
# According to RFC 6125, we MUST NOT try the Common Name before the Subject Alternative Names.
cn = cert.get_subject().commonName.lower()
found = match_hostname(hostname, cn)
if found:
return True
return False return False
class Connection: class Connection:
def __init__(self, server, servername=None, dot=False, verbose=verbose, insecure=insecure, post=post, head=head): def __init__(self, server, servername=None, dot=False, verbose=verbose, insecure=insecure, post=post, head=head):
if dot and not is_valid_hostname(server): if dot and not is_valid_hostname(server):
error("DoT requires a host name, not \"%s\"" % server) error("DoT requires a host name or IP address, not \"%s\"" % server)
if not dot and not is_valid_url(url): if not dot and not is_valid_url(url):
error("DoH requires a valid HTTPS URL, not \"%s\"" % server) error("DoH requires a valid HTTPS URL, not \"%s\"" % server)
self.server = server self.server = server
...@@ -166,6 +197,7 @@ class Connection: ...@@ -166,6 +197,7 @@ class Connection:
self.session = OpenSSL.SSL.Connection(self.context, self.sock) self.session = OpenSSL.SSL.Connection(self.context, self.sock)
self.session.set_tlsext_host_name(check.encode()) # Server Name Indication (SNI) self.session.set_tlsext_host_name(check.encode()) # Server Name Indication (SNI)
self.session.connect((self.server, 853)) self.session.connect((self.server, 853))
# TODO We may here have exceptions such as OpenSSL.SSL.ZeroReturnError
self.session.do_handshake() self.session.do_handshake()
self.cert = self.session.get_peer_certificate() self.cert = self.session.get_peer_certificate()
if not insecure: if not insecure:
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment