In general, we handle hostnames as bytes, except where Python forces
them to be unicodes.
This fixes all the tracebacks I was seeing in test-https.t, but
there's still some ECONNRESET weirdness that I can't hunt down...
( )
hg-reviewers |
In general, we handle hostnames as bytes, except where Python forces
them to be unicodes.
This fixes all the tracebacks I was seeing in test-https.t, but
there's still some ECONNRESET weirdness that I can't hunt down...
Automatic diff as part of commit; lint not applicable. |
Automatic diff as part of commit; unit tests not applicable. |
Path | Packages | |||
---|---|---|---|---|
M | mercurial/sslutil.py (40 lines) |
return ssl.wrap_socket(socket, **args) | return ssl.wrap_socket(socket, **args) | ||||
def _hostsettings(ui, hostname): | def _hostsettings(ui, hostname): | ||||
"""Obtain security settings for a hostname. | """Obtain security settings for a hostname. | ||||
Returns a dict of settings relevant to that hostname. | Returns a dict of settings relevant to that hostname. | ||||
""" | """ | ||||
bhostname = pycompat.bytesurl(hostname) | |||||
s = { | s = { | ||||
# Whether we should attempt to load default/available CA certs | # Whether we should attempt to load default/available CA certs | ||||
# if an explicit ``cafile`` is not defined. | # if an explicit ``cafile`` is not defined. | ||||
'allowloaddefaultcerts': True, | 'allowloaddefaultcerts': True, | ||||
# List of 2-tuple of (hash algorithm, hash). | # List of 2-tuple of (hash algorithm, hash). | ||||
'certfingerprints': [], | 'certfingerprints': [], | ||||
# Path to file containing concatenated CA certs. Used by | # Path to file containing concatenated CA certs. Used by | ||||
# SSLContext.load_verify_locations(). | # SSLContext.load_verify_locations(). | ||||
# Let people know they are borderline secure. | # Let people know they are borderline secure. | ||||
# We don't document this config option because we want people to see | # We don't document this config option because we want people to see | ||||
# the bold warnings on the web site. | # the bold warnings on the web site. | ||||
# internal config: hostsecurity.disabletls10warning | # internal config: hostsecurity.disabletls10warning | ||||
if not ui.configbool('hostsecurity', 'disabletls10warning'): | if not ui.configbool('hostsecurity', 'disabletls10warning'): | ||||
ui.warn(_('warning: connecting to %s using legacy security ' | ui.warn(_('warning: connecting to %s using legacy security ' | ||||
'technology (TLS 1.0); see ' | 'technology (TLS 1.0); see ' | ||||
'https://mercurial-scm.org/wiki/SecureConnections for ' | 'https://mercurial-scm.org/wiki/SecureConnections for ' | ||||
'more info\n') % hostname) | 'more info\n') % bhostname) | ||||
defaultprotocol = 'tls1.0' | defaultprotocol = 'tls1.0' | ||||
key = 'minimumprotocol' | key = 'minimumprotocol' | ||||
protocol = ui.config('hostsecurity', key, defaultprotocol) | protocol = ui.config('hostsecurity', key, defaultprotocol) | ||||
validateprotocol(protocol, key) | validateprotocol(protocol, key) | ||||
key = '%s:minimumprotocol' % hostname | key = '%s:minimumprotocol' % bhostname | ||||
protocol = ui.config('hostsecurity', key, protocol) | protocol = ui.config('hostsecurity', key, protocol) | ||||
validateprotocol(protocol, key) | validateprotocol(protocol, key) | ||||
# If --insecure is used, we allow the use of TLS 1.0 despite config options. | # If --insecure is used, we allow the use of TLS 1.0 despite config options. | ||||
# We always print a "connection security to %s is disabled..." message when | # We always print a "connection security to %s is disabled..." message when | ||||
# --insecure is used. So no need to print anything more here. | # --insecure is used. So no need to print anything more here. | ||||
if ui.insecureconnections: | if ui.insecureconnections: | ||||
protocol = 'tls1.0' | protocol = 'tls1.0' | ||||
s['protocol'], s['ctxoptions'], s['protocolui'] = protocolsettings(protocol) | s['protocol'], s['ctxoptions'], s['protocolui'] = protocolsettings(protocol) | ||||
ciphers = ui.config('hostsecurity', 'ciphers') | ciphers = ui.config('hostsecurity', 'ciphers') | ||||
ciphers = ui.config('hostsecurity', '%s:ciphers' % hostname, ciphers) | ciphers = ui.config('hostsecurity', '%s:ciphers' % bhostname, ciphers) | ||||
s['ciphers'] = ciphers | s['ciphers'] = ciphers | ||||
# Look for fingerprints in [hostsecurity] section. Value is a list | # Look for fingerprints in [hostsecurity] section. Value is a list | ||||
# of <alg>:<fingerprint> strings. | # of <alg>:<fingerprint> strings. | ||||
fingerprints = ui.configlist('hostsecurity', '%s:fingerprints' % hostname) | fingerprints = ui.configlist('hostsecurity', '%s:fingerprints' % bhostname) | ||||
for fingerprint in fingerprints: | for fingerprint in fingerprints: | ||||
if not (fingerprint.startswith(('sha1:', 'sha256:', 'sha512:'))): | if not (fingerprint.startswith(('sha1:', 'sha256:', 'sha512:'))): | ||||
raise error.Abort(_('invalid fingerprint for %s: %s') % ( | raise error.Abort(_('invalid fingerprint for %s: %s') % ( | ||||
hostname, fingerprint), | bhostname, fingerprint), | ||||
hint=_('must begin with "sha1:", "sha256:", ' | hint=_('must begin with "sha1:", "sha256:", ' | ||||
'or "sha512:"')) | 'or "sha512:"')) | ||||
alg, fingerprint = fingerprint.split(':', 1) | alg, fingerprint = fingerprint.split(':', 1) | ||||
fingerprint = fingerprint.replace(':', '').lower() | fingerprint = fingerprint.replace(':', '').lower() | ||||
s['certfingerprints'].append((alg, fingerprint)) | s['certfingerprints'].append((alg, fingerprint)) | ||||
# Fingerprints from [hostfingerprints] are always SHA-1. | # Fingerprints from [hostfingerprints] are always SHA-1. | ||||
for fingerprint in ui.configlist('hostfingerprints', hostname): | for fingerprint in ui.configlist('hostfingerprints', bhostname): | ||||
fingerprint = fingerprint.replace(':', '').lower() | fingerprint = fingerprint.replace(':', '').lower() | ||||
s['certfingerprints'].append(('sha1', fingerprint)) | s['certfingerprints'].append(('sha1', fingerprint)) | ||||
s['legacyfingerprint'] = True | s['legacyfingerprint'] = True | ||||
# If a host cert fingerprint is defined, it is the only thing that | # If a host cert fingerprint is defined, it is the only thing that | ||||
# matters. No need to validate CA certs. | # matters. No need to validate CA certs. | ||||
if s['certfingerprints']: | if s['certfingerprints']: | ||||
s['verifymode'] = ssl.CERT_NONE | s['verifymode'] = ssl.CERT_NONE | ||||
s['allowloaddefaultcerts'] = False | s['allowloaddefaultcerts'] = False | ||||
# If --insecure is used, don't take CAs into consideration. | # If --insecure is used, don't take CAs into consideration. | ||||
elif ui.insecureconnections: | elif ui.insecureconnections: | ||||
s['disablecertverification'] = True | s['disablecertverification'] = True | ||||
s['verifymode'] = ssl.CERT_NONE | s['verifymode'] = ssl.CERT_NONE | ||||
s['allowloaddefaultcerts'] = False | s['allowloaddefaultcerts'] = False | ||||
if ui.configbool('devel', 'disableloaddefaultcerts'): | if ui.configbool('devel', 'disableloaddefaultcerts'): | ||||
s['allowloaddefaultcerts'] = False | s['allowloaddefaultcerts'] = False | ||||
# If both fingerprints and a per-host ca file are specified, issue a warning | # If both fingerprints and a per-host ca file are specified, issue a warning | ||||
# because users should not be surprised about what security is or isn't | # because users should not be surprised about what security is or isn't | ||||
# being performed. | # being performed. | ||||
cafile = ui.config('hostsecurity', '%s:verifycertsfile' % hostname) | cafile = ui.config('hostsecurity', '%s:verifycertsfile' % bhostname) | ||||
if s['certfingerprints'] and cafile: | if s['certfingerprints'] and cafile: | ||||
ui.warn(_('(hostsecurity.%s:verifycertsfile ignored when host ' | ui.warn(_('(hostsecurity.%s:verifycertsfile ignored when host ' | ||||
'fingerprints defined; using host fingerprints for ' | 'fingerprints defined; using host fingerprints for ' | ||||
'verification)\n') % hostname) | 'verification)\n') % bhostname) | ||||
# Try to hook up CA certificate validation unless something above | # Try to hook up CA certificate validation unless something above | ||||
# makes it not necessary. | # makes it not necessary. | ||||
if s['verifymode'] is None: | if s['verifymode'] is None: | ||||
# Look at per-host ca file first. | # Look at per-host ca file first. | ||||
if cafile: | if cafile: | ||||
cafile = util.expandpath(cafile) | cafile = util.expandpath(cafile) | ||||
if not os.path.exists(cafile): | if not os.path.exists(cafile): | ||||
raise error.Abort(_('path specified by %s does not exist: %s') % | raise error.Abort(_('path specified by %s does not exist: %s') % | ||||
('hostsecurity.%s:verifycertsfile' % hostname, | ('hostsecurity.%s:verifycertsfile' % ( | ||||
cafile)) | bhostname,), cafile)) | ||||
s['cafile'] = cafile | s['cafile'] = cafile | ||||
else: | else: | ||||
# Find global certificates file in config. | # Find global certificates file in config. | ||||
cafile = ui.config('web', 'cacerts') | cafile = ui.config('web', 'cacerts') | ||||
if cafile: | if cafile: | ||||
cafile = util.expandpath(cafile) | cafile = util.expandpath(cafile) | ||||
if not os.path.exists(cafile): | if not os.path.exists(cafile): | ||||
try: | try: | ||||
sslcontext.load_verify_locations(cafile=settings['cafile']) | sslcontext.load_verify_locations(cafile=settings['cafile']) | ||||
except ssl.SSLError as e: | except ssl.SSLError as e: | ||||
if len(e.args) == 1: # pypy has different SSLError args | if len(e.args) == 1: # pypy has different SSLError args | ||||
msg = e.args[0] | msg = e.args[0] | ||||
else: | else: | ||||
msg = e.args[1] | msg = e.args[1] | ||||
raise error.Abort(_('error loading CA file %s: %s') % ( | raise error.Abort(_('error loading CA file %s: %s') % ( | ||||
settings['cafile'], msg), | settings['cafile'], util.forcebytestr(msg)), | ||||
hint=_('file is empty or malformed?')) | hint=_('file is empty or malformed?')) | ||||
caloaded = True | caloaded = True | ||||
elif settings['allowloaddefaultcerts']: | elif settings['allowloaddefaultcerts']: | ||||
# This is a no-op on old Python. | # This is a no-op on old Python. | ||||
sslcontext.load_default_certs() | sslcontext.load_default_certs() | ||||
caloaded = True | caloaded = True | ||||
else: | else: | ||||
caloaded = False | caloaded = False | ||||
This code is effectively copied from CPython's ssl._dnsname_match. | This code is effectively copied from CPython's ssl._dnsname_match. | ||||
Returns a bool indicating whether the expected hostname matches | Returns a bool indicating whether the expected hostname matches | ||||
the value in ``dn``. | the value in ``dn``. | ||||
""" | """ | ||||
pats = [] | pats = [] | ||||
if not dn: | if not dn: | ||||
return False | return False | ||||
dn = pycompat.bytesurl(dn) | |||||
hostname = pycompat.bytesurl(hostname) | |||||
pieces = dn.split(r'.') | pieces = dn.split('.') | ||||
leftmost = pieces[0] | leftmost = pieces[0] | ||||
remainder = pieces[1:] | remainder = pieces[1:] | ||||
wildcards = leftmost.count('*') | wildcards = leftmost.count('*') | ||||
if wildcards > maxwildcards: | if wildcards > maxwildcards: | ||||
raise wildcarderror( | raise wildcarderror( | ||||
_('too many wildcards in certificate DNS name: %s') % dn) | _('too many wildcards in certificate DNS name: %s') % dn) | ||||
# speed up common case w/o wildcards | # speed up common case w/o wildcards | ||||
dnsnames = [] | dnsnames = [] | ||||
san = cert.get('subjectAltName', []) | san = cert.get('subjectAltName', []) | ||||
for key, value in san: | for key, value in san: | ||||
if key == 'DNS': | if key == 'DNS': | ||||
try: | try: | ||||
if _dnsnamematch(value, hostname): | if _dnsnamematch(value, hostname): | ||||
return | return | ||||
except wildcarderror as e: | except wildcarderror as e: | ||||
return e.args[0] | return util.forcebytestr(e.args[0]) | ||||
dnsnames.append(value) | dnsnames.append(value) | ||||
if not dnsnames: | if not dnsnames: | ||||
# The subject is only checked when there is no DNS in subjectAltName. | # The subject is only checked when there is no DNS in subjectAltName. | ||||
for sub in cert.get('subject', []): | for sub in cert.get(r'subject', []): | ||||
for key, value in sub: | for key, value in sub: | ||||
# According to RFC 2818 the most specific Common Name must | # According to RFC 2818 the most specific Common Name must | ||||
# be used. | # be used. | ||||
if key == 'commonName': | if key == r'commonName': | ||||
# 'subject' entries are unicode. | # 'subject' entries are unicode. | ||||
try: | try: | ||||
value = value.encode('ascii') | value = value.encode('ascii') | ||||
except UnicodeEncodeError: | except UnicodeEncodeError: | ||||
return _('IDN in certificate not supported') | return _('IDN in certificate not supported') | ||||
try: | try: | ||||
if _dnsnamematch(value, hostname): | if _dnsnamematch(value, hostname): | ||||
return | return | ||||
except wildcarderror as e: | except wildcarderror as e: | ||||
return e.args[0] | return util.forcebytestr(e.args[0]) | ||||
dnsnames.append(value) | dnsnames.append(value) | ||||
if len(dnsnames) > 1: | if len(dnsnames) > 1: | ||||
return _('certificate is for %s') % ', '.join(dnsnames) | return _('certificate is for %s') % ', '.join(dnsnames) | ||||
elif len(dnsnames) == 1: | elif len(dnsnames) == 1: | ||||
return _('certificate is for %s') % dnsnames[0] | return _('certificate is for %s') % dnsnames[0] | ||||
else: | else: | ||||
return None | return None | ||||
def validatesocket(sock): | def validatesocket(sock): | ||||
"""Validate a socket meets security requirements. | """Validate a socket meets security requirements. | ||||
The passed socket must have been created with ``wrapsocket()``. | The passed socket must have been created with ``wrapsocket()``. | ||||
""" | """ | ||||
host = sock._hgstate['hostname'] | shost = sock._hgstate['hostname'] | ||||
host = pycompat.bytesurl(shost) | |||||
ui = sock._hgstate['ui'] | ui = sock._hgstate['ui'] | ||||
settings = sock._hgstate['settings'] | settings = sock._hgstate['settings'] | ||||
try: | try: | ||||
peercert = sock.getpeercert(True) | peercert = sock.getpeercert(True) | ||||
peercert2 = sock.getpeercert() | peercert2 = sock.getpeercert() | ||||
except AttributeError: | except AttributeError: | ||||
raise error.Abort(_('%s ssl connection error') % host) | raise error.Abort(_('%s ssl connection error') % host) | ||||
raise error.Abort( | raise error.Abort( | ||||
_('unable to verify security of %s (no loaded CA certificates); ' | _('unable to verify security of %s (no loaded CA certificates); ' | ||||
'refusing to connect') % host, | 'refusing to connect') % host, | ||||
hint=_('see https://mercurial-scm.org/wiki/SecureConnections for ' | hint=_('see https://mercurial-scm.org/wiki/SecureConnections for ' | ||||
'how to configure Mercurial to avoid this error or set ' | 'how to configure Mercurial to avoid this error or set ' | ||||
'hostsecurity.%s:fingerprints=%s to trust this server') % | 'hostsecurity.%s:fingerprints=%s to trust this server') % | ||||
(host, nicefingerprint)) | (host, nicefingerprint)) | ||||
msg = _verifycert(peercert2, host) | msg = _verifycert(peercert2, shost) | ||||
if msg: | if msg: | ||||
raise error.Abort(_('%s certificate error: %s') % (host, msg), | raise error.Abort(_('%s certificate error: %s') % (host, msg), | ||||
hint=_('set hostsecurity.%s:certfingerprints=%s ' | hint=_('set hostsecurity.%s:certfingerprints=%s ' | ||||
'config setting or use --insecure to connect ' | 'config setting or use --insecure to connect ' | ||||
'insecurely') % | 'insecurely') % | ||||
(host, nicefingerprint)) | (host, nicefingerprint)) |