fake-gp-server.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. #!/usr/bin/env python3
  2. #
  3. # Copyright © 2021 Daniel Lenski
  4. #
  5. # This file is part of openconnect.
  6. #
  7. # This is free software; you can redistribute it and/or
  8. # modify it under the terms of the GNU Lesser General Public License
  9. # as published by the Free Software Foundation; either version 2.1 of
  10. # the License, or (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful, but
  13. # WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  15. # Lesser General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Lesser General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>
  19. import sys
  20. import ssl
  21. from random import randint
  22. import base64
  23. from json import dumps
  24. from functools import wraps
  25. from flask import Flask, request, abort, url_for, session
  26. from dataclasses import dataclass
  27. host, port, *cert_and_maybe_keyfile = sys.argv[1:]
  28. context = ssl.SSLContext()
  29. context.load_cert_chain(*cert_and_maybe_keyfile)
  30. app = Flask(__name__)
  31. app.config.update(SECRET_KEY=b'fake', DEBUG=True, HOST=host, PORT=int(port), SESSION_COOKIE_NAME='fake')
  32. ########################################
  33. def cookify(jsonable):
  34. return base64.urlsafe_b64encode(dumps(jsonable).encode())
  35. def check_form_against_session(*fields, use_query=False, on_failure=None):
  36. def inner(fn):
  37. @wraps(fn)
  38. def wrapped(*args, **kwargs):
  39. source = request.args if use_query else request.form
  40. source_name = 'args' if use_query else 'form'
  41. for f in fields:
  42. fs = f.replace('_', '-')
  43. if on_failure:
  44. if session.get(f) != source.get(fs):
  45. return on_failure
  46. else:
  47. assert session.get(f) == source.get(fs), \
  48. f'at step {session.get("step")}: {source_name} {f!r} {source.get(fs)!r} != session {f!r} {session.get(f)!r}'
  49. return fn(*args, **kwargs)
  50. return wrapped
  51. return inner
  52. ########################################
  53. if_path2name = {'global-protect': 'portal', 'ssl-vpn': 'gateway'}
  54. # Configure the fake server. These settings will persist unless/until reconfigured or restarted:
  55. # gateways: list of gateway names for portal to offer (all will point to same HOST:PORT as portal)
  56. # portal_2fa: if set, require challenge-based 2FA to complete /global-protect/getconfig.esp request
  57. # gw_2fa: if set, require challenge-based 2FA to complete /ssl-vpn/login.esp request
  58. # portal_saml: set to 'portal-userauthcookie' or 'prelogin-cookie' to require SAML on portal (and
  59. # expect the named cookie to be provided to signal SAML completion)
  60. # gateway_saml: likewise, set to require SAML on gateway
  61. # portal_cookie: if set (to 'portal-userauthcookie' or 'portal-prelogonuserauthcookie'), then
  62. # the portal getconfig response will include the named "cookie" field which should
  63. # be used to automatically continue login on the gateway
  64. @dataclass
  65. class TestConfiguration:
  66. gateways: list = ('Default gateway',)
  67. portal_2fa: bool = False
  68. gw_2fa: bool = False
  69. portal_cookie: str = None
  70. portal_saml: str = None
  71. gateway_saml: str = None
  72. C = TestConfiguration()
  73. OUTSTANDING_SAML_TOKENS = set()
  74. @app.route('/CONFIGURE', methods=('POST', 'GET'))
  75. def configure():
  76. global C
  77. if request.method == 'POST':
  78. gateways, portal_2fa, gw_2fa, portal_cookie, portal_saml, gateway_saml = request.form.get('gateways'), request.form.get('portal_2fa'), request.form.get('gw_2fa'), request.form.get('portal_cookie'), request.form.get('portal_saml'), request.form.get('gateway_saml')
  79. C.gateways = gateways.split(',') if gateways else ('Default gateway',)
  80. C.portal_cookie = portal_cookie
  81. C.portal_2fa = bool(portal_2fa)
  82. C.gw_2fa = bool(gw_2fa)
  83. C.portal_saml = portal_saml
  84. C.gateway_saml = gateway_saml
  85. return '', 201
  86. else:
  87. return 'Current configuration of fake GP server configuration:\n{}\n'.format(C)
  88. # Respond to initial prelogin requests
  89. @app.route('/<any("global-protect", "ssl-vpn"):interface>/prelogin.esp', methods=('GET','POST',))
  90. def prelogin(interface):
  91. ifname = if_path2name[interface]
  92. demand_saml = getattr(C, ifname + '_saml')
  93. if demand_saml:
  94. # The (cookie-based) session isn't shared between OpenConnect and the external browser
  95. # that does the SAML auth, so we need another way to track that the SAML form gets
  96. # returned. Use a global variable for now.
  97. token = '%08x' % randint(0x10000000, 0xffffffff)
  98. OUTSTANDING_SAML_TOKENS.add((ifname, token))
  99. saml = '<saml-auth-method>REDIRECT</saml-auth-method><saml-request>{}</saml-request>'.format(
  100. base64.standard_b64encode(url_for('saml_handler', ifname=ifname, token=token, _external=True).encode()).decode())
  101. else:
  102. saml = ''
  103. session.update(step='%s-prelogin' % ifname)
  104. return '''
  105. <prelogin-response>
  106. <status>Success</status>
  107. <ccusername/>
  108. <autosubmit>false</autosubmit>
  109. <msg/>
  110. <newmsg/>
  111. <authentication-message>Please login to this fake GP VPN {ifname}</authentication-message>
  112. <username-label>Username</username-label>
  113. <password-label>Password</password-label>
  114. <panos-version>1</panos-version>{saml}
  115. <region>EARTH</region>
  116. </prelogin-response>'''.format(ifname=ifname, saml=saml)
  117. # In a "real" GP VPN with SAML, this lives on a completely different server like subdomain.okta.com
  118. # or login.microsoft.com.
  119. # It will be opened by an external browser or SAML-wrangling script, *not* by OpenConnect.
  120. @app.route('/ANOTHER-HOST/SAML-ENDPOINT')
  121. def saml_handler():
  122. ifname, token = request.args.get('ifname'), request.args.get('token')
  123. # Submit to saml_complete endpoint
  124. # In a "real" GP setup, this would be on a different server which is why we use _external=True
  125. saml_complete = url_for('saml_complete', _external=True)
  126. return '''<html><body><p>Please login to this fake GP VPN {ifname} interface via SAML</p>
  127. <form name="saml" method="post" action="{saml_complete}">
  128. <input type="text" name="username" autofocus="1"/><br/>
  129. <input type="password" name="password"/><br/>
  130. <input type="hidden" name="token" value="{token}"/>
  131. <input type="hidden" name="ifname" value="{ifname}"/>
  132. <input type="submit" value="Login"/>
  133. </form></body></html>'''.format(ifname=ifname, saml_complete=saml_complete, token=token)
  134. # This is the "return path" where SAML authentication ends up on real GP servers after
  135. # successfully completing.
  136. # It will be opened by an external browser or SAML-wrangling script, *not* by OpenConnect.
  137. @app.route('/SAML20/SP/ACS', methods=('POST',))
  138. def saml_complete():
  139. ifname, token = request.form.get('ifname'), request.form.get('token')
  140. assert ifname in ('portal', 'gateway')
  141. try:
  142. OUTSTANDING_SAML_TOKENS.remove((ifname, token))
  143. except KeyError:
  144. # Token and/or endpoint were bogus
  145. abort(401)
  146. # Build a response containing the magical headers that indicate SAML completion
  147. saml_headers = {
  148. 'saml-auth-status': 1,
  149. 'saml-username': request.form.get('username'),
  150. getattr(C, ifname + '_saml'): 'FAKE_username_{username}_password_{password}'.format(**request.form),
  151. }
  152. body = '<html><body>Login Successful!</body><!-- {} --></html>'.format(''.join('<{0}>{1}</{0}>'.format(*kv) for kv in saml_headers.items()))
  153. return body, saml_headers
  154. def challenge_2fa(where):
  155. # select a random inputStr of 4 hex digits, and randomly return challenge in either XML or Javascript-y form
  156. inputStr = '%04x' % randint(0x1000, 0xffff)
  157. session.update(step='%s-2FA' % where, inputStr=inputStr)
  158. if randint(1, 2) == 1:
  159. tmpl = '<challenge><respmsg>XML 2FA challenge from %s & throw in an illegal unquoted ampersand</respmsg><inputstr>%s</inputstr></challenge>'
  160. else:
  161. tmpl = ('var respStatus = "Challenge";\n'
  162. 'var respMsg = "Javascript 2FA challenge from %s";\n'
  163. 'thisForm.inputStr.value = "%s";\n')
  164. return tmpl % (where, inputStr)
  165. # Respond to portal getconfig request
  166. @app.route('/global-protect/getconfig.esp', methods=('POST',))
  167. def portal_config():
  168. inputStr = request.form.get('inputStr') or None
  169. if C.portal_2fa and not inputStr:
  170. return challenge_2fa('portal')
  171. okay = False
  172. if C.portal_saml and request.form.get('user') and request.form.get(C.portal_saml):
  173. okay = True
  174. elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'):
  175. okay = True
  176. if not okay:
  177. return 'Invalid username or password', 512
  178. session.update(step='portal-config', user=request.form.get('user'), passwd=request.form.get('passwd'),
  179. # clear SAML result fields to ensure failure if blindly retried on gateway
  180. saml_user=None, saml_value=None,
  181. # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form:
  182. inputStr=None)
  183. gwlist = ''.join('<entry name="{}:{}"><description>{}</description></entry>'.format(app.config['HOST'], app.config['PORT'], gw)
  184. for gw in C.gateways)
  185. if C.portal_cookie:
  186. val = session[C.portal_cookie] = 'portal-cookie-%d' % randint(1, 10)
  187. pc = '<{0}>{1}</{0}>'.format(C.portal_cookie, val)
  188. else:
  189. pc = ''
  190. return '''<?xml version="1.0" encoding="UTF-8" ?>
  191. <policy><gateways><external><list>{}</list></external></gateways>
  192. <hip-collection><hip-report-interval>600</hip-report-interval></hip-collection>
  193. {}</policy>'''.format(gwlist, pc)
  194. # Respond to gateway login request
  195. @app.route('/ssl-vpn/login.esp', methods=('POST',))
  196. def gateway_login():
  197. inputStr = request.form.get('inputStr') or None
  198. if C.portal_cookie and request.form.get(C.portal_cookie) == session.get(C.portal_cookie):
  199. # a correct portal_cookie explicitly allows us to bypass other gateway login forms
  200. pass
  201. elif C.gw_2fa and not inputStr:
  202. return challenge_2fa('gateway')
  203. else:
  204. okay = False
  205. if C.gateway_saml and request.form.get('user') and request.form.get(C.gateway_saml):
  206. okay = True
  207. elif request.form.get('user') and request.form.get('passwd') and inputStr == session.get('inputStr'):
  208. okay = True
  209. if not okay:
  210. return 'Invalid username or password', 512
  211. session.update(step='gateway-login', user=request.form.get('user'), passwd=request.form.get('passwd'),
  212. # clear inputStr to ensure failure if same form fields are blindly retried on another challenge form:
  213. inputStr=None)
  214. for k, v in (('jnlpReady', 'jnlpReady'), ('ok', 'Login'), ('direct', 'yes'), ('clientVer', '4100'), ('prot', 'https:')):
  215. if request.form.get(k) != v:
  216. abort(500)
  217. for k in ('clientos', 'os-version', 'server', 'computer'):
  218. if not request.form.get(k):
  219. abort(500)
  220. portal = 'Portal%d' % randint(1, 10)
  221. auth = 'Auth%d' % randint(1, 10)
  222. domain = 'Domain%d' % randint(1, 10)
  223. preferred_ip = request.form.get('preferred-ip') or '192.168.%d.%d' % (randint(2, 254), randint(2, 254))
  224. if request.form.get('ipv6-support') == 'yes':
  225. preferred_ipv6 = request.form.get('preferred-ipv6') or 'fd00::%x' % randint(0x1000, 0xffff)
  226. else:
  227. preferred_ipv6 = None
  228. session.update(preferred_ip=preferred_ip, portal=portal, auth=auth, domain=domain, computer=request.form.get('computer'),
  229. ipv6_support=request.form.get('ipv6-support'), preferred_ipv6=preferred_ipv6)
  230. session.setdefault('portal-prelogonuserauthcookie', '')
  231. session.setdefault('portal-userauthcookie', '')
  232. session['authcookie'] = cookify(dict(session)).decode()
  233. return '''<?xml version="1.0" encoding="utf-8"?> <jnlp> <application-desc>
  234. <argument>(null)</argument>
  235. <argument>{authcookie}</argument>
  236. <argument>PersistentCookie</argument>
  237. <argument>{portal}</argument>
  238. <argument>{user}</argument>
  239. <argument>TestAuth</argument>
  240. <argument>vsys1</argument>
  241. <argument>{domain}</argument>
  242. <argument>(null)</argument>
  243. <argument/>
  244. <argument></argument>
  245. <argument></argument>
  246. <argument>tunnel</argument>
  247. <argument>-1</argument>
  248. <argument>4100</argument>
  249. <argument>{preferred_ip}</argument>
  250. <argument>{portal-userauthcookie}</argument>
  251. <argument>{portal-prelogonuserauthcookie}</argument>
  252. <argument>{ipv6}</argument>
  253. </application-desc></jnlp>'''.format(ipv6=preferred_ipv6 or '', **session)
  254. # Respond to gateway getconfig request
  255. @app.route('/ssl-vpn/getconfig.esp', methods=('POST',))
  256. @check_form_against_session('user', 'portal', 'domain', 'authcookie', 'preferred_ip', 'preferred_ipv6', 'ipv6_support', on_failure="errors getting SSL/VPN config")
  257. def getconfig():
  258. session.update(step='gateway-config')
  259. addrs = '<ip-address>{}</ip-address>'.format(session['preferred_ip'])
  260. if session['ipv6_support'] == 'yes':
  261. addrs += '<ip-address-v6>{}</ip-address-v6>'.format(session['preferred_ipv6'])
  262. return '''<response>{}<ssl-tunnel-url>/ssl-tunnel-connect.sslvpn</ssl-tunnel-url></response>'''.format(addrs)
  263. # Respond to gateway hipreportcheck request
  264. @app.route('/ssl-vpn/hipreportcheck.esp', methods=('POST',))
  265. @check_form_against_session('user', 'portal', 'domain', 'authcookie', 'computer')
  266. def hipcheck():
  267. session.update(step='gateway-config')
  268. return '''<response><hip-report-needed>no</hip-report-needed></response>'''
  269. # Respond to faux-CONNECT GET-tunnel with 502
  270. # (what the real GP server responds with when it doesn't like the cookie, intended
  271. # to trigger "cookie rejected" error in OpenConnect)
  272. @app.route('/ssl-tunnel-connect.sslvpn')
  273. # Can't use because OpenConnect doesn't send headers here
  274. # @check_form_against_session('user', 'authcookie', use_query=True)
  275. def tunnel():
  276. assert 'user' in request.args and 'authcookie' in request.args
  277. session.update(step='GET-tunnel')
  278. abort(502)
  279. # Respond to 'GET /ssl-vpn/logout.esp' by clearing session and MRHSession
  280. @app.route('/ssl-vpn/logout.esp')
  281. # XX: real server really requires all these fields; see auth-globalprotect.c
  282. @check_form_against_session('authcookie', 'portal', 'user', 'computer')
  283. def logout():
  284. return '<response status="success"/>'
  285. app.run(host=app.config['HOST'], port=app.config['PORT'], debug=True, ssl_context=context)