From 2c8960f956fd7d37dee49b25062edab7f8c824e4 Mon Sep 17 00:00:00 2001 From: Daniel Lenski Date: Fri, 28 May 2021 12:42:57 -0700 Subject: [PATCH] Assume that a 'portal-*cookie' will allow us to bypass gateway SAML MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit For many GlobalProtect VPNs with SAML, the 'portal-userauthcookie' appears to be *the* mechanism by which gateway authentication can be bypassed once portal authentication is complete. Unfortunately, there are exceptions which will require a more complex resolution involved a re-entrant SAML flow (https://gitlab.com/openconnect/openconnect/-/issues/147#note_587163143), but this patch will at least not make them worse. This can work in many cases… - When the user's password is only usable one time (already working as of 008aefd7), - When the portal requires SAML but the gateway doesn't (already working in 008aefd7), - When the gateway requires SAML even though the portal doesn't (fixed here) Additionally, this patch adds tests (tests/{fake-gp-server.py,gp-auth-and-config}) of OpenConnect's ability to complete the following SAML flows: - (SAML to portal after acquiring prelogin-cookie externally) → (complete gateway login using portal-userauthcookie) - (SAML to gateway after acquiring prelogin-cookie externally) Signed-off-by: Daniel Lenski --- auth-globalprotect.c | 17 +++++++---- tests/fake-gp-server.py | 65 ++++++++++++++++++++++++++++++---------- tests/gp-auth-and-config | 16 +++++++++- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/auth-globalprotect.c b/auth-globalprotect.c index b466bbc6..eebb0916 100644 --- a/auth-globalprotect.c +++ b/auth-globalprotect.c @@ -115,7 +115,14 @@ static int parse_prelogin_xml(struct openconnect_info *vpninfo, xmlNode *xml_nod /* XX: Alt-secret form field must be specified for SAML, because we can't autodetect it */ if (saml_method || saml_path) { - if (!ctx->alt_secret) { + if (ctx->portal_userauthcookie) + vpn_progress(vpninfo, PRG_DEBUG, _("SAML authentication required; using portal-userauthcookie to continue SAML.\n")); + else if (ctx->portal_prelogonuserauthcookie) + vpn_progress(vpninfo, PRG_DEBUG, _("SAML authentication required; using portal-prelogonuserauthcookie to continue SAML.\n")); + else if (ctx->alt_secret) + vpn_progress(vpninfo, PRG_DEBUG, _("Destination form field %s was specified; assuming SAML %s authentication is complete.\n"), + ctx->alt_secret, saml_method); + else { if (saml_method && !strcmp(saml_method, "REDIRECT")) vpn_progress(vpninfo, PRG_ERR, _("SAML %s authentication is required via %s\n"), @@ -126,12 +133,10 @@ static int parse_prelogin_xml(struct openconnect_info *vpninfo, xmlNode *xml_nod saml_method); vpn_progress(vpninfo, PRG_ERR, _("When SAML authentication is complete, specify destination form field by appending :field_name to login URL.\n")); - /* XX: EINVAL will lead to "failure to parse response", with unnecessary/confusing extra logging output */ + /* XX: EINVAL will lead to "failure to parse response", with unnecessary/confusing extra logging output */ result = -EPERM; goto out; - } else - vpn_progress(vpninfo, PRG_DEBUG, _("Destination form field %s was specified; assuming SAML %s authentication is complete.\n"), - saml_method, ctx->alt_secret); + } } /* Replace old form */ @@ -341,7 +346,7 @@ static int parse_login_xml(struct openconnect_info *vpninfo, xmlNode *xml_node, } else if (arg->check && (!value || strcmp(value, arg->check))) { unknown_args++; fatal_args += arg->err_missing; - vpn_progress(vpninfo, PRG_ERR, + vpn_progress(vpninfo, PRG_ERR, _("GlobalProtect login returned %s=%s (expected %s)\n"), arg->opt, value, arg->check); } else if ((arg->err_missing || arg->warn_missing) && !value) { diff --git a/tests/fake-gp-server.py b/tests/fake-gp-server.py index fb284a32..dcc13701 100755 --- a/tests/fake-gp-server.py +++ b/tests/fake-gp-server.py @@ -60,29 +60,38 @@ def check_form_against_session(*fields, use_query=False, on_failure=None): ######################################## +if_path2name = {'global-protect':'portal', 'ssl-vpn':'gateway'} # Get parameters into the initial session setup in order to configure: # gateways: list of gateway names for portal to offer (all will point to same HOST:PORT as portal) # portal_2fa: if set, require challenge-based 2FA to complete /global-protect/getconfig.esp request # gateway_2fa: if set, require challenge-based 2FA to complete /ssl-vpn/login.esp request +# portal_saml: set to 'portal-userauthcookie' or 'prelogin-cookie' to require SAML on portal (and +# expect the named cookie to be provided to signal SAML completion) +# gateway_saml: likewise, set to require SAML on gateway # portal_cookie: if set (to 'portal-userauthcookie' or 'portal-prelogonuserauthcookie'), then # the portal getconfig response will include the named "cookie" field which should # be used to automatically continue login on the gateway -@app.route('/global-protect/testconfig.esp', methods=('GET','POST',)) -@app.route('/ssl-vpn/testconfig.esp', methods=('GET','POST',)) -def testconfig(): - gateways, portal_2fa, gw_2fa, portal_cookie = request.args.get('gateways'), request.args.get('portal_2fa'), request.args.get('gw_2fa'), request.args.get('portal_cookie') +@app.route('//testconfig.esp', methods=('GET','POST',)) +def testconfig(interface): + gateways, portal_2fa, gw_2fa, portal_cookie, portal_saml, gateway_saml = request.args.get('gateways'), request.args.get('portal_2fa'), request.args.get('gw_2fa'), request.args.get('portal_cookie'), request.args.get('portal_saml'), request.args.get('gateway_saml') session.update(gateways=gateways and gateways.split(','), portal_cookie=portal_cookie, - portal_2fa=portal_2fa and bool(portal_2fa), gw_2fa=gw_2fa and bool(gw_2fa)) - prelogin = '/'.join(request.path.split('/')[:-1] + ['prelogin.esp']) + portal_2fa=portal_2fa and bool(portal_2fa), gw_2fa=gw_2fa and bool(gw_2fa), + portal_saml=portal_saml, gateway_saml=gateway_saml) + prelogin = url_for('prelogin', interface=interface) return redirect(prelogin) # 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='%s-prelogin' % ('portal' if 'global-protect' in request.path else 'gateway')) +@app.route('//prelogin.esp', methods=('GET','POST',)) +def prelogin(interface): + ifname = if_path2name[interface] + if session.get(ifname + '_saml'): + saml = 'REDIRECT{}'.format( + base64.urlsafe_b64encode(url_for('saml_form', interface=interface, _external=True).encode()).decode()) + else: + saml = '' + session.update(step='%s-prelogin' % ifname) return ''' Success @@ -90,12 +99,18 @@ def prelogin(): false -Please login to this fake VPN +Please login to this fake GP VPN {ifname} Username Password -1 +1{saml} EARTH -'''.format(request.path) +'''.format(ifname=ifname, saml=saml) + + +# Simple SAML form (not actually hooked up, for now) +@app.route('//SAML_FORM') +def saml_form(interface): + abort(503) def challenge_2fa(where): @@ -115,14 +130,24 @@ def challenge_2fa(where): @app.route('/global-protect/getconfig.esp', methods=('POST',)) def portal_config(): portal_2fa = session.get('portal_2fa') + portal_saml = session.get('portal_saml') portal_cookie = session.get('portal_cookie') inputStr = request.form.get('inputStr') or None + if portal_2fa and not inputStr: return challenge_2fa('portal') - if not (request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr')): + + okay = False + if portal_saml and request.form.get('user') and request.form.get(portal_saml): + okay = True + elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'): + okay = True + if not okay: return 'Invalid username or password', 512 session.update(step='portal-config', user=request.form.get('user'), passwd=request.form.get('passwd'), + # clear SAML result fields to ensure failure if blindly retried on gateway + saml_user=None, saml_value=None, # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form: inputStr=None) gateways = session.get('gateways') or ('Default gateway',) @@ -144,14 +169,22 @@ def portal_config(): @app.route('/ssl-vpn/login.esp', methods=('POST',)) def gateway_login(): gw_2fa = session.get('gw_2fa') + gateway_saml = session.get('gateway_saml') inputStr = request.form.get('inputStr') or None + if session.get('portal_cookie') and request.form.get(session['portal_cookie']) == session.get(session['portal_cookie']): # a correct portal_cookie explicitly allows us to bypass other gateway login forms pass elif gw_2fa and not inputStr: return challenge_2fa('gateway') - elif not (request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr')): - return 'Invalid username or password', 512 + else: + okay = False + if gateway_saml and request.form.get('user') and request.form.get(gateway_saml): + okay = True + elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'): + okay = True + if not okay: + return 'Invalid username or password', 512 session.update(step='gateway-login', user=request.form.get('user'), passwd=request.form.get('passwd'), # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form: inputStr=None) diff --git a/tests/gp-auth-and-config b/tests/gp-auth-and-config index 289af1ef..2ddef59f 100755 --- a/tests/gp-auth-and-config +++ b/tests/gp-auth-and-config @@ -59,6 +59,20 @@ echo -n "Authenticating with username/password/token via portal, then using port echo ok +echo -n "Simulating completed SAML to portal, then using portal-userauthcookie to continue through SAML-requiring gateway... " +( echo "prelogin-cookie" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp --disable-ipv6 -q "$ADDRESS:443/global-protect/testconfig.esp?portal_cookie=portal-userauthcookie&portal_saml=prelogin-cookie&gateway_saml=prelogin-cookie:prelogin-cookie" -u test $FINGERPRINT --cookieonly >/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 + +echo -n "Simulating completed SAML to gateway... " +( echo "prelogin-cookie" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp --disable-ipv6 -q "$ADDRESS:443/ssl-vpn/testconfig.esp?gateway_saml=prelogin-cookie:prelogin-cookie" -u test $FINGERPRINT --cookieonly >/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 + echo -n "Authenticating with username/password via portal, then +token via gateway... " ( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q "$ADDRESS:443/global-protect/testconfig.esp?gw_2fa=1" -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) || fail $PID "Could not receive cookie from fake GlobalProtect server" @@ -69,7 +83,7 @@ echo -n "Authenticating with username/password/token via gateway... " ( echo "test" | LD_PRELOAD=libsocket_wrapper.so $OPENCONNECT --protocol=gp -q "$ADDRESS:443/ssl-vpn/testconfig.esp?gw_2fa=1" -u test --token-mode=totp --token-secret=FAKE $FINGERPRINT --cookieonly >/dev/null 2>&1) || fail $PID "Could not receive cookie from fake GlobalProtect server" -ok +echo ok 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 -- 2.50.1