From 19d9d1a8372370f34431fb39346c60c89d1c9848 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Wed, 28 Apr 2021 11:53:02 -0700 Subject: [PATCH] Add fake-gp-server.py and gp-auth-and-config test Another set of Flask-based tests. TODO: need a way to get parameters into the initial session setup (just as fake-{f5,fortinet,juniper}-server.py do), in order to configure gateways, 2FA requirement, etc. Signed-off-by: Daniel Lenski --- tests/Makefile.am | 3 +- tests/fake-gp-server.py | 185 +++++++++++++++++++++++++++++++++++++++ tests/gp-auth-and-config | 61 +++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100755 tests/fake-gp-server.py create mode 100755 tests/gp-auth-and-config diff --git a/tests/Makefile.am b/tests/Makefile.am index 5295f863..52dcc125 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -47,7 +47,7 @@ EXTRA_DIST = certs/ca.pem certs/ca-key.pem certs/user-cert.pem $(USER_KEYS) $(US softhsm2.conf.in softhsm ns.sh configs/test-dtls-psk.config \ scripts/vpnc-script scripts/vpnc-script-detect-disconnect \ suppressions.lsan fake-fortinet-server.py fake-f5-server.py fake-juniper-server.py \ - fake-juniper-sso-server.py fake-tncc.py + fake-juniper-sso-server.py fake-tncc.py fake-gp-server.py dist_check_SCRIPTS = autocompletion @@ -67,6 +67,7 @@ dist_check_SCRIPTS += fortinet-auth-and-config dist_check_SCRIPTS += f5-auth-and-config dist_check_SCRIPTS += juniper-auth dist_check_SCRIPTS += juniper-sso-auth +dist_check_SCRIPTS += gp-auth-and-config endif if TEST_PKCS11 diff --git a/tests/fake-gp-server.py b/tests/fake-gp-server.py new file mode 100755 index 00000000..375d9d75 --- /dev/null +++ b/tests/fake-gp-server.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +# +# Copyright © 2021 Daniel Lenski +# +# This file is part of openconnect. +# +# This is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see +#!/usr/bin/env python3 + +import sys +import ssl +from random import randint +import base64 +import time +from json import dumps +from functools import wraps +from flask import Flask, request, abort, redirect, url_for, make_response, session + +host, port, *cert_and_maybe_keyfile = sys.argv[1:] + +context = ssl.SSLContext() +context.load_cert_chain(*cert_and_maybe_keyfile) + +app = Flask(__name__) +app.config.update(SECRET_KEY=b'fake', DEBUG=True, HOST=host, PORT=int(port), SESSION_COOKIE_NAME='fake') + +######################################## + +def cookify(jsonable): + return base64.urlsafe_b64encode(dumps(jsonable).encode()) + +def check_form_against_session(*fields, use_query=False, on_failure=None): + def inner(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + source = request.args if use_query else request.form + source_name = 'args' if use_query else 'form' + for f in fields: + if on_failure: + if session.get(f) != source.get(f) or f not in source: + return on_failure + else: + assert session.get(f) == source.get(f), \ + f'at step {session.get("step")}: {source_name} {f!r} {source.get(f)!r} != session {f!r} {session.get(f)!r}' + return fn(*args, **kwargs) + return wrapped + return inner + +######################################## + + +# TODO: need a way to get parameters into the initial session setup (just as +# fake-{f5,fortinet,juniper}-server.py do), in order to configure gateways, +# 2FA requirement, etc. + + +# Respond to initial prelogin requests +@app.route('/global-protect/prelogin.esp', methods=('GET','POST',)) +@app.route('/ssl-vpn/prelogin.esp', methods=('GET','POST',)) +def prelogin(): + session.update(step='prelogin') + return ''' + +Success + +false + + +Please login to this fake VPN +Username +Password +1 +EARTH +'''.format(request.path) + + +# Respond to portal getconfig request +@app.route('/global-protect/getconfig.esp', methods=('POST',)) +def portal_config(): + session.update(step='portal-config', user=request.form.get('user'), passwd=request.form.get('passwd')) + if not (request.form.get('user') and request.form.get('passwd')): + return 'Invalid username or password', 512 + + gateways = session.get('gateways') or ('Default gateway',) + gwlist = ''.join('{}'.format(app.config['HOST'], app.config['PORT'], gw) + for gw in gateways) + + return ''' + {} + 600 + '''.format(gwlist) + + +# Respond to gateway login request +@app.route('/ssl-vpn/login.esp', methods=('POST',)) +def gateway_login(): + session.update(step='gateway-login') + if not (request.form.get('user') and request.form.get('passwd')): + return 'Invalid username or password', 512 + session.update(user=request.form.get('user'), passwd=request.form.get('passwd')) + + for k, v in (('jnlpReady', 'jnlpReady'), ('ok', 'Login'), ('direct', 'yes'), ('clientVer', '4100'), ('prot', 'https:')): + if request.form.get(k) != v: + abort(500) + for k in ('clientos', 'os-version', 'server', 'computer'): + if not request.form.get(k): + abort(500) + + portal = 'Portal%d' % randint(1, 10) + auth = 'Auth%d' % randint(1, 10) + domain = 'Domain%d' % randint(1, 10) + preferred_ip = request.form.get('preferred-ip') or '192.168.%d.%d' % (randint(2, 254), randint(2, 254)) + session.update(preferred_ip=preferred_ip, portal=portal, auth=auth, domain=domain, computer=request.form.get('computer')) + session['authcookie'] = cookify(dict(session)).decode() + + return ''' + (null) + {authcookie} + PersistentCookie + {portal} + {user} + TestAuth + vsys1 + {domain} + (null) + + + + tunnel + -1 + 4100 + {preferred_ip} + '''.format(**session) + + +# Respond to gateway getconfig request +@app.route('/ssl-vpn/getconfig.esp', methods=('POST',)) +@check_form_against_session('user', 'portal', 'domain', 'authcookie', on_failure="errors getting SSL/VPN config") +def getconfig(): + session.update(step='gateway-config') + return '''{preferred_ip} + /ssl-tunnel-connect.sslvpn + '''.format(**session) + + +# Respond to gateway getconfig request +@app.route('/ssl-vpn/hipreportcheck.esp', methods=('POST',)) +@check_form_against_session('user', 'portal', 'domain', 'authcookie', 'computer') +def hipcheck(): + session.update(step='gateway-config') + return '''no''' + + +# Respond to faux-CONNECT GET-tunnel with 502 +# (what the real GP server responds with when it doesn't like the cookie, intended +# to trigger "cookie rejected" error in OpenConnect) +@app.route('/ssl-tunnel-connect.sslvpn') +# Can't use because OpenConnect doesn't send headers here +# @check_form_against_session('user', 'authcookie', use_query=True) +def tunnel(): + assert 'user' in request.args and 'authcookie' in request.args + session.update(step='GET-tunnel') + abort(502) + + +# Respond to 'GET /ssl-vpn/logout.esp' by clearing session and MRHSession +@app.route('/ssl-vpn/logout.esp') +# XX: real server really requires all these fields; see auth-globalprotect.c +@check_form_against_session('authcookie', 'portal', 'user', 'computer') +def logout(): + return '' + + +app.run(host=app.config['HOST'], port=app.config['PORT'], debug=True, ssl_context=context) diff --git a/tests/gp-auth-and-config b/tests/gp-auth-and-config new file mode 100755 index 00000000..48e37a92 --- /dev/null +++ b/tests/gp-auth-and-config @@ -0,0 +1,61 @@ +#!/bin/sh +# +# Copyright © 2021 Daniel Lenski +# +# This file is part of openconnect. +# +# This is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public License +# as published by the Free Software Foundation; either version 2.1 of +# the License, or (at your option) any later version. +# +# This library 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 +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see + +# This test uses LD_PRELOAD +PRELOAD=1 +srcdir=${srcdir:-.} +top_builddir=${top_builddir:-..} + +. `dirname $0`/common.sh + +FINGERPRINT="--servercert=d66b507ae074d03b02eafca40d35f87dd81049d3" +CERT=$certdir/server-cert.pem +KEY=$certdir/server-key.pem + +echo "Testing GlobalProtect auth against fake server ... " + +OCSERV=${srcdir}/fake-gp-server.py +launch_simple_sr_server $ADDRESS 443 $CERT $KEY >/dev/null 2>&1 +PID=$! +wait_server $PID 1 + +echo -n "Authenticating with username/password via portal... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q $ADDRESS:443/portal -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake GlobalProtect server" + +echo ok + +echo -n "Authenticating with username/password via gateway... " +( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q $ADDRESS:443/gateway -u test $FINGERPRINT --cookieonly >/dev/null 2>&1) || + fail $PID "Could not receive cookie from fake GlobalProtect server" + +echo ok + +# TODO: add tests with 2FA + +echo -n "Authenticating with username/password via portal, then proceeding to tunnel stage... " +echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q $ADDRESS:443/portal -u test $FINGERPRINT >/dev/null 2>&1 +test $? = 2 || # what OpenConnect returns when server rejects cookie upon tunnel connection, as the fake server does + fail $PID "Something went wrong in fake GlobalProtect server (other than the expected rejection of cookie)" + +echo ok + +cleanup + +exit 0 -- 2.50.1