fake-fortinet-server.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  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. ########################################
  20. # This program emulates the authentication-phase behavior of a Fortinet
  21. # server enough to test OpenConnect's authentication behavior against it.
  22. # Specifically, it emulates the following requests:
  23. #
  24. # GET /[$REALM]
  25. # GET /remote/login[?realm=$REALM]
  26. # POST /remote/logincheck (with username and credential fields)
  27. # No 2FA) Completes the login
  28. # With 2FA) Returns a 2FA challenge
  29. # POST /remote/logincheck (with username and 2FA response fields)
  30. #
  31. # It does not actually validate the credentials in any way, but attempts to
  32. # verify their consistency from one request to the next, by saving their
  33. # values via a (cookie-based) session.
  34. #
  35. # In order to test with 2FA, the initial 'GET /' request should include
  36. # the query string '?want_2fa=1&type_2fa={tokeninfo,html}'. If want_2fa>1,
  37. # multiple rounds of 2FA token entry will be required. If type_2fa is not,
  38. # specified tokeninfo-type 2FA is the default.
  39. ########################################
  40. import sys
  41. import ssl
  42. import random
  43. import base64
  44. from json import dumps
  45. from functools import wraps
  46. from flask import Flask, request, abort, redirect, url_for, make_response, session
  47. host, port, *cert_and_maybe_keyfile = sys.argv[1:]
  48. context = ssl.SSLContext()
  49. context.load_cert_chain(*cert_and_maybe_keyfile)
  50. app = Flask(__name__)
  51. app.config.update(SECRET_KEY=b'fake', DEBUG=True, HOST=host, PORT=int(port), SESSION_COOKIE_NAME='fake')
  52. ########################################
  53. def cookify(jsonable):
  54. return base64.urlsafe_b64encode(dumps(jsonable).encode())
  55. def require_SVPNCOOKIE(fn):
  56. @wraps(fn)
  57. def wrapped(*args, **kwargs):
  58. if not request.cookies.get('SVPNCOOKIE'):
  59. session.clear()
  60. return redirect(url_for('login'))
  61. return fn(*args, **kwargs)
  62. return wrapped
  63. def check_form_against_session(*fields):
  64. def inner(fn):
  65. @wraps(fn)
  66. def wrapped(*args, **kwargs):
  67. for f in fields:
  68. assert session.get(f) == request.form.get(f), \
  69. f'at step {session.get("step")}: form {f!r} {request.form.get(f)!r} != session {f!r} {session.get(f)!r}'
  70. return fn(*args, **kwargs)
  71. return wrapped
  72. return inner
  73. ########################################
  74. # Respond to initial 'GET /' with a login form
  75. # Respond to initial 'GET /<realm>' with a redirect to '/remote/login?realm=<realm>'
  76. # [Save want_2fa and type_2fa query parameters in the session for use later]
  77. @app.route('/')
  78. @app.route('/<realm>')
  79. def realm(realm=None):
  80. session.update(step='GET-realm', want_2fa=int(request.args.get('want_2fa', 0)), type_2fa=request.args.get('type_2fa', 'tokeninfo'))
  81. # print(session)
  82. if realm:
  83. return redirect(url_for('login', realm=realm, lang='en'))
  84. else:
  85. return login()
  86. # Respond to 'GET /remote/login?realm=<realm>' with a placeholder stub (since OpenConnect doesn't even try to parse the form)
  87. # [Save realm in the session for verification of client state later]
  88. @app.route('/remote/login')
  89. def login():
  90. realm = request.args.get('realm')
  91. session.update(step='GET-login-form', realm=realm or '')
  92. return f'login page for realm {realm!r}'
  93. # Respond to 'POST /remote/logincheck'
  94. @app.route('/remote/logincheck', methods=['POST'])
  95. def logincheck():
  96. want_2fa = session.get('want_2fa')
  97. type_2fa = session.get('type_2fa')
  98. if want_2fa:
  99. if ( (type_2fa == 'tokeninfo' and request.form.get('username') and request.form.get('code'))
  100. or (type_2fa == 'html' and request.form.get('username') and request.form.get('magic'))):
  101. # we've received (at least one round of) 2FA login
  102. if want_2fa == 1:
  103. return complete_2fa()
  104. else:
  105. session.update(want_2fa=want_2fa - 1)
  106. return send_2fa_tokeninfo() if type_2fa == 'tokeninfo' else send_2fa_html()
  107. elif request.form.get('username') and request.form.get('credential'):
  108. # we've just received the initial non-2FA login
  109. return send_2fa_tokeninfo() if type_2fa == 'tokeninfo' else send_2fa_html()
  110. elif (request.form.get('username') and request.form.get('credential')):
  111. return complete_non_2fa()
  112. abort(405)
  113. # 2FA completion: ensure that client has parroted back the same values
  114. # for username, reqid, polid, grp/grpid, portal, magic
  115. # [Save code in the session for potential use later]
  116. @check_form_against_session('username', 'reqid', 'polid', 'grp', 'grpid', 'portal', 'magic')
  117. def complete_2fa():
  118. session.update(step='complete-2FA', code=request.form.get('code'))
  119. # print(session)
  120. resp = make_response('ret=1,redir=/remote/fortisslvpn_xml')
  121. resp.set_cookie('SVPNCOOKIE', cookify(dict(session)))
  122. return resp
  123. # Tokeninfo-based 2FA initial login: ensure that client has sent the right realm value, and
  124. # reply with a tokeninfo challenge containing all known fields.
  125. # [Save username, credential, and challenge fields in the session for verification of client state later]
  126. @check_form_against_session('realm')
  127. def send_2fa_tokeninfo():
  128. session.update(step='send-2FA-tokeninfo', username=request.form.get('username'), credential=request.form.get('credential'),
  129. reqid=str(random.randint(10_000_000, 99_000_000)), polid='1-1-'+str(random.randint(10_000_000, 99_000_000)),
  130. magic='1-'+str(random.randint(10_000_000, 99_000_000)), portal=random.choice('ABCD'), grp=random.choice('EFGH'))
  131. # print(session)
  132. return ('ret=2,reqid={reqid},polid={polid},grp={grp},portal={portal},magic={magic},'
  133. 'tokeninfo=,chal_msg=Please enter your tokeninfo code ({want_2fa} remaining)'.format(**session),
  134. {'content-type': 'text/plain'})
  135. # HTML-based 2FA initial login: ensure that client has sent the right realm value, and
  136. # reply with an HTML challenge containing all known fields.
  137. # [Save username, credential, and challenge fields in the session for verification of client state later]
  138. @check_form_against_session('realm')
  139. def send_2fa_html():
  140. session.update(step='send-2FA-html', username=request.form.get('username'), credential=request.form.get('credential'),
  141. reqid=str(random.randint(10_000_000, 99_000_000)), grpid='0,'+str(random.randint(1_000, 9_999))+',1',
  142. magic='1-'+str(random.randint(10_000_000, 99_000_000)))
  143. # print(session)
  144. return ('''
  145. <html><body><form action="{logincheck}" method="POST">
  146. <b>Please enter your HTML 2FA code ({want_2fa} remaining)</b>
  147. <input type="hidden" name="magic" value="{magic}">
  148. <input type="hidden" name="username" value="{username}">
  149. <input type="hidden" name="reqid" value="{reqid}">
  150. <input type="hidden" name="grpid" value="{grpid}">
  151. <input type="password" name="credential">
  152. <input class="button" type="submit" value="OK">
  153. </form></body></html>
  154. '''.format(logincheck=url_for('logincheck'), **session),
  155. 401,
  156. {'content-type': 'text/html'})
  157. # Non-2FA login: ensure that client has sent the right realm value
  158. @check_form_against_session('realm')
  159. def complete_non_2fa():
  160. session.update(step='complete-non-2FA', username=request.form.get('username'), credential=request.form.get('credential'))
  161. # print(session)
  162. resp = make_response('ret=1,redir=/remote/fortisslvpn_xml', {'content-type': 'text/plain'})
  163. resp.set_cookie('SVPNCOOKIE', cookify(dict(session)))
  164. return resp
  165. # Respond to 'GET /fortisslvpn with a placeholder stub (since OpenConnect doesn't even try to parse this)
  166. @app.route('/remote/fortisslvpn')
  167. @require_SVPNCOOKIE
  168. def html_config():
  169. return 'VPN config in HTML format'
  170. # Respond to 'GET /fortisslvpn_xml with a fake config
  171. @app.route('/remote/fortisslvpn_xml')
  172. @require_SVPNCOOKIE
  173. def xml_config():
  174. return ('''
  175. <?xml version="1.0" encoding="utf-8"?>
  176. <sslvpn-tunnel ver="2" dtls="1" patch="1">
  177. <dtls-config heartbeat-interval="10" heartbeat-fail-count="10" heartbeat-idle-timeout="10" client-hello-timeout="10"/>
  178. <tunnel-method value="ppp"/>
  179. <tunnel-method value="tun"/>
  180. <fos platform="FakeFortigate" major="1" minor="2" patch="3" build="4567" branch="4567" mr_num="??"/>
  181. <auth-ses tun-connect-without-reauth="1" check-src-ip="0" tun-user-ses-timeout="240"/>
  182. <ipv4>
  183. <dns ip="1.1.1.1"/>
  184. <dns ip="8.8.8.8" domain="foo.com"/>
  185. <split-dns domains='mydomain1.local,mydomain2.local' dnsserver1='10.10.10.10' dnsserver2='10.10.10.11' />
  186. <assigned-addr ipv4="10.11.1.123"/>
  187. <split-tunnel-info>
  188. <addr ip="10.11.10.10" mask="255.255.255.255"/>
  189. <addr ip="10.11.1.0" mask="255.255.255.0"/>
  190. </split-tunnel-info>
  191. <split-tunnel-info negate="1">
  192. <addr ip="9.9.9.9" mask="255.255.255.255"/>
  193. </split-tunnel-info>
  194. </ipv4>
  195. <!-- Real Fortinet servers are too dumb to send both IPv4 and IPv6 config simultaneously, but we
  196. may as well test it here nonetheless. -->
  197. <ipv6>
  198. <dns ipv6="cafe:1234::5678"/>
  199. <assigned-addr ipv6="faff:ffff::1" prefix-len="64"/>
  200. <split-tunnel-info>
  201. <addr ipv6="fdff:ffff::" prefix-len="120"/>
  202. </split-tunnel-info>
  203. <split-tunnel-info negate="1">
  204. <addr ipv6="2620:fe::fe" prefix-len="128"/>
  205. </split-tunnel-info>
  206. </ipv6>
  207. <idle-timeout val="3600"/>
  208. <auth-timeout val="18000"/>
  209. </sslvpn-tunnel>''',
  210. {'content-type': 'application/xml'})
  211. # Respond to faux-CONNECT 'GET /remote/sslvpn-tunnel' with 403 Forbidden
  212. # (what the real Fortinet server sends when it doesn't like the parameters,
  213. # intended to trigger "cookie rejected" error in OpenConnect)
  214. @app.route('/remote/sslvpn-tunnel')
  215. @require_SVPNCOOKIE
  216. def tunnel():
  217. abort(403)
  218. # Respond to 'GET /remote/logout' by clearing session and SVPNCOOKIE
  219. @app.route('/remote/logout')
  220. @require_SVPNCOOKIE
  221. def logout():
  222. session.clear()
  223. resp = make_response('successful logout')
  224. resp.set_cookie('SVPNCOOKIE', '')
  225. return resp
  226. app.run(host=app.config['HOST'], port=app.config['PORT'], debug=app.config['DEBUG'],
  227. ssl_context=context, use_debugger=False)