Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSL client validation (certificate-based authentication) #295

Closed
wants to merge 6 commits into from
20 changes: 20 additions & 0 deletions websockify/auth_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,23 @@ def authenticate(self, headers, target_host, target_port):
origin = headers.get('Origin', None)
if origin is None or origin not in self.source:
raise InvalidOriginError(expected=self.source, actual=origin)

class ClientCertAuth(object):
"""Verifies client by SSL certificate. Specify src as whitespace separated list of common names."""

def __init__(self, src=None):
if src is None:
self.source = []
else:
self.source = src.split()

def authenticate(self, headers, target_host, target_port):
try:
if (headers.get('SSL_CLIENT_S_DN_CN') not in self.source):
raise AuthenticationError(response_code=403)
except AuthenticationError:
# re-raise AuthenticationError (raised by common name not in configured source list)
raise
except:
# deny access in case any error occurs (i.e. no data provided)
raise AuthenticationError(response_code=403)
20 changes: 20 additions & 0 deletions websockify/websocketproxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,20 @@ def validate_connection(self):
self.server.target_port = port

if self.server.auth_plugin:

try:
# get client certificate data
client_cert_data = self.request.getpeercert()
# extract subject information
client_cert_subject = client_cert_data['subject']
# flatten data structure
client_cert_subject = dict([x[0] for x in client_cert_subject])
# add common name to headers (apache +StdEnvVars style)
self.headers['SSL_CLIENT_S_DN_CN'] = client_cert_subject['commonName']
except:
# not a SSL connection or client presented no certificate with valid data
pass

try:
self.server.auth_plugin.authenticate(
headers=self.headers, target_host=self.server.target_host,
Expand Down Expand Up @@ -392,6 +406,12 @@ def websockify_init():
help="disallow non-encrypted client connections")
parser.add_option("--ssl-target", action="store_true",
help="connect to SSL target as SSL client")
parser.add_option("--verify-client", action="store_true",
help="require encrypted client to present a valid certificate")
parser.add_option("--cafile", metavar="FILE",
help="file of concatenated certificates of authorities trusted "
"for validating clients (only effective with --verify-client). "
"If omitted, system default list of CAs is used.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't completely true, is it? The code suggests that system CAs will always be used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. I should move the call to context.set_default_verify_paths() into the else alternative.

parser.add_option("--unix-target",
help="connect to unix socket target", metavar="FILE")
parser.add_option("--inetd",
Expand Down
20 changes: 14 additions & 6 deletions websockify/websockifyserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,7 @@ class Terminate(Exception):
def __init__(self, RequestHandlerClass, listen_fd=None,
listen_host='', listen_port=None, source_is_ipv6=False,
verbose=False, cert='', key='', ssl_only=None,
verify_client=False, cafile=None,
daemon=False, record='', web='',
file_only=False,
run_once=False, timeout=0, idle_timeout=0, traffic=False,
Expand All @@ -333,6 +334,7 @@ def __init__(self, RequestHandlerClass, listen_fd=None,
self.listen_port = listen_port
self.prefer_ipv6 = source_is_ipv6
self.ssl_only = ssl_only
self.verify_client = verify_client
self.daemon = daemon
self.run_once = run_once
self.timeout = timeout
Expand All @@ -352,13 +354,15 @@ def __init__(self, RequestHandlerClass, listen_fd=None,

# Make paths settings absolute
self.cert = os.path.abspath(cert)
self.key = self.web = self.record = ''
self.key = self.web = self.record = self.cafile = ''
if key:
self.key = os.path.abspath(key)
if web:
self.web = os.path.abspath(web)
if record:
self.record = os.path.abspath(record)
if cafile:
self.cafile = os.path.abspath(cafile)

if self.web:
os.chdir(self.web)
Expand Down Expand Up @@ -518,7 +522,6 @@ def do_handshake(self, sock, address):
"""
ready = select.select([sock], [], [], 3)[0]


if not ready:
raise self.EClose("ignoring socket not ready")
# Peek, but do not read the data so that we have a opportunity
Expand All @@ -538,11 +541,16 @@ def do_handshake(self, sock, address):
% self.cert)
retsock = None
try:
retsock = ssl.wrap_socket(
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile=self.cert, keyfile=self.key)
if self.verify_client:
context.verify_mode = ssl.CERT_REQUIRED
context.set_default_verify_paths()
if self.cafile:
context.load_verify_locations(cafile=self.cafile)
retsock = context.wrap_socket(
sock,
server_side=True,
certfile=self.cert,
keyfile=self.key)
server_side=True)
except ssl.SSLError:
_, x, _ = sys.exc_info()
if x.args[0] == ssl.SSL_ERROR_EOF:
Expand Down