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
Closed
17 changes: 16 additions & 1 deletion docs/websockify.1
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,27 @@ Here is an example of using websockify to wrap the vncserver command (which back

`./websockify 5901 --wrap-mode=ignore -- vncserver -geometry 1024x768 :1`

Here is an example of wrapping telnetd (from krb5-telnetd).telnetd exits after the connection closes so the wrap mode is set to respawn the command:
Here is an example of wrapping telnetd (from krb5-telnetd). telnetd exits after the connection closes so the wrap mode is set to respawn the command:

`sudo ./websockify 2023 --wrap-mode=respawn -- telnetd -debug 2023`

The wstelnet.html page demonstrates a simple WebSockets based telnet client.

.SS Use client certificate verification

This feature requires Python 2.7.9 or newer or Python 3.4 or newer.

The --verify-client option makes the server ask the client for a SSL certificate. Presenting a valid (not expired and trusted by any supplied certificate authority) certificate is required for the client connection. With -auth-plugin=ClientCertCNAuth, the client certificate can be checked against a list of authorised certificate users. Non-encrypted connection attempts always fail during authentication.

Here is an example of a vncsevrer with password-less, certificate-driven authentication:

`./websockify 5901 --cert=fullchain.pem --key=privkey.pem --ssl-only --verify-client --cafile=ca-certificates.crt --auth-plugin=ClientCertCNAuth --auth-source='[email protected] Joe User9824510' --web=noVNC/ --wrap-mode=ignore -- vncserver :1 -geometry 1024x768 -SecurityTypes=None`

The --auth-source option takes a white-space separated list of common names. Depending on your clients certificates they can be verified email addresses, user-names or any other string used for identification.

The --cafile option selects a file containing concatenated certificates of authorities trusted for validating clients. If this option is omitted, system default list of CAs is used. Upon connect, the client should supply the whole certificate chain. If your clients are known not to send intermediate certificates, they can be appended to the ca-file as well.

Note: Most browsers ask the user to select a certificate only while connecting via HTTPS, not WebSockets. Connecting directly to the SSL secured WebSocket may cause the browser to abort the connection. If you want to connect via noVNC, the --web option should point to a copy of noVNC, so it is loaded from the same host.

.SH AUTHOR
Joel Martin ([email protected])
Expand Down
19 changes: 18 additions & 1 deletion tests/test_websockifyserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,25 @@ def fake_select(rlist, wlist, xlist, timeout=None):
def fake_wrap_socket(*args, **kwargs):
raise ssl.SSLError(ssl.SSL_ERROR_EOF)

class fake_create_default_context():
def __init__(self, purpose):
self.verify_mode = None
def load_cert_chain(self, certfile, keyfile):
pass
def set_default_verify_paths(self):
pass
def load_verify_locations(self, cafile):
pass
def wrap_socket(self, *args, **kwargs):
raise ssl.SSLError(ssl.SSL_ERROR_EOF)

self.stubs.Set(select, 'select', fake_select)
self.stubs.Set(ssl, 'wrap_socket', fake_wrap_socket)
if (hasattr(ssl, 'create_default_context')):
# for recent versions of python
self.stubs.Set(ssl, 'create_default_context', fake_create_default_context)
else:
# for fallback for old versions of python
self.stubs.Set(ssl, 'wrap_socket', fake_wrap_socket)
self.assertRaises(
websockifyserver.WebSockifyServer.EClose, server.do_handshake,
sock, '127.0.0.1')
Expand Down
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 ClientCertCNAuth(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)
21 changes: 21 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,13 @@ 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 "
"(needs Python 2.7.9 or newer or Python 3.4 or newer)")
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.")
parser.add_option("--unix-target",
help="connect to unix socket target", metavar="FILE")
parser.add_option("--inetd",
Expand Down
37 changes: 30 additions & 7 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,31 @@ def do_handshake(self, sock, address):
% self.cert)
retsock = None
try:
retsock = ssl.wrap_socket(
sock,
server_side=True,
certfile=self.cert,
keyfile=self.key)
try:
# try creating new-style SSL wrapping for extended features
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)
except AttributeError as ae:
if str(ae) != "'module' object has no attribute 'create_default_context'":
Copy link
Member

Choose a reason for hiding this comment

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

This can be done a lot cleaner. E.g.

if hasattr(ssl, 'create_default_context'):

Copy link
Member

Choose a reason for hiding this comment

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

When fixed this should also be merged in to the original commit.

# this exception is not caused by create_default_context not existing in old version. re-raise exception to be handled somewhere elese.
raise
elif self.verify_client:
raise self.EClose("Client certificate verification requested, but not Python is too old.")
else:
# new-style SSL wrapping is not needed, falling back to old style
retsock = ssl.wrap_socket(
sock,
server_side=True,
certfile=self.cert,
keyfile=self.key)
except ssl.SSLError:
_, x, _ = sys.exc_info()
if x.args[0] == ssl.SSL_ERROR_EOF:
Expand Down