]> www.infradead.org Git - users/dwmw2/openconnect.git/commitdiff
Add fake-gp-server.py and gp-auth-and-config test
authorDaniel Lenski <dlenski@gmail.com>
Wed, 28 Apr 2021 18:53:02 +0000 (11:53 -0700)
committerDaniel Lenski <dlenski@gmail.com>
Mon, 3 May 2021 21:50:21 +0000 (14:50 -0700)
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 <dlenski@gmail.com>
tests/Makefile.am
tests/fake-gp-server.py [new file with mode: 0755]
tests/gp-auth-and-config [new file with mode: 0755]

index 5295f863b29a9a3000881f788612f37dbab284bc..52dcc1256abc834b8a72410ab7790aaaf45a2d14 100644 (file)
@@ -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 (executable)
index 0000000..375d9d7
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>
+#!/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 '''
+<prelogin-response>
+<status>Success</status>
+<ccusername/>
+<autosubmit>false</autosubmit>
+<msg/>
+<newmsg/>
+<authentication-message>Please login to this fake VPN</authentication-message>
+<username-label>Username</username-label>
+<password-label>Password</password-label>
+<panos-version>1</panos-version>
+<region>EARTH</region>
+</prelogin-response>'''.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('<entry name="{}:{}"><description>{}</description></entry>'.format(app.config['HOST'], app.config['PORT'], gw)
+                     for gw in gateways)
+
+    return '''<?xml version="1.0" encoding="UTF-8" ?>
+    <policy><gateways><external><list>{}</list></external></gateways>
+    <hip-collection><hip-report-interval>600</hip-report-interval></hip-collection>
+    </policy>'''.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 '''<?xml version="1.0" encoding="utf-8"?> <jnlp> <application-desc>
+        <argument>(null)</argument>
+            <argument>{authcookie}</argument>
+            <argument>PersistentCookie</argument>
+            <argument>{portal}</argument>
+            <argument>{user}</argument>
+            <argument>TestAuth</argument>
+            <argument>vsys1</argument>
+            <argument>{domain}</argument>
+            <argument>(null)</argument>
+            <argument/>
+            <argument></argument>
+            <argument></argument>
+            <argument>tunnel</argument>
+            <argument>-1</argument>
+            <argument>4100</argument>
+            <argument>{preferred_ip}</argument>
+            </application-desc></jnlp>'''.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 '''<response><ip-address>{preferred_ip}</ip-address>
+        <ssl-tunnel-url>/ssl-tunnel-connect.sslvpn</ssl-tunnel-url>
+        </response>'''.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 '''<response><hip-report-needed>no</hip-report-needed></response>'''
+
+
+# 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 '<response status="success"/>'
+
+
+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 (executable)
index 0000000..48e37a9
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>
+
+# 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