Skip to content

Commit 5572cb7

Browse files
committed
manually merged PR100 to tighten openid to only redirect to site-local URLs. Synchronize error handling and add unit tests.
git-svn-id: svn+ssh://svn.code.sf.net/p/migrid/code/trunk@6106 b75ad72c-e7d7-11dd-a971-7dbc132099af
1 parent f3762e8 commit 5572cb7

File tree

3 files changed

+205
-32
lines changed

3 files changed

+205
-32
lines changed

mig/server/grid_openid.py

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
valid_path, valid_ascii, valid_job_id, valid_base_url, valid_url, \
100100
valid_complex_url, InputException
101101
from mig.shared.tlsserver import hardened_ssl_context
102-
from mig.shared.url import urlparse, urlencode
102+
from mig.shared.url import urlparse, urlencode, check_local_site_url
103103
from mig.shared.useradm import get_openid_user_dn, check_password_scramble, \
104104
check_hash
105105
from mig.shared.userdb import default_db_path
@@ -189,9 +189,16 @@ def filter_why_pw(configuration, why):
189189
if isinstance(why, server.EncodingError):
190190
text = why.response.encodeToKVForm()
191191
return strip_password(configuration, text)
192+
elif isinstance(why, server.ProtocolError):
193+
text = why.message
194+
return strip_password(configuration, text)
192195
else:
193-
_logger.warning("can't filter password in unknown 'why': %s" % why)
194-
return why
196+
# IMPORTANT: do NOT log or show raw why as it may contain credentials
197+
# NOTE: we should not get here, but return generic safe msg if we do.
198+
why_type = type(why)
199+
_logger.warning("can't filter unknown 'why' (%s) - show generic err" %
200+
why_type)
201+
return 'Unexpected %s to filter for safe log and output.' % why_type
195202

196203

197204
def lookup_full_user(username):
@@ -468,7 +475,7 @@ def do_GET(self):
468475
# Do not disclose internal details
469476
filtered_exc = cgitb.text(sys.exc_info(), context=10)
470477
# IMPORTANT: do NOT ever print or log raw password
471-
pw = self.password
478+
pw = self.password or ''
472479
filtered_exc = filtered_exc.replace(pw, '*' * len(pw))
473480
logger.debug("Traceback %s: %s" % (error_ref, filtered_exc))
474481
err_msg = """<p class='leftpad'>
@@ -581,9 +588,20 @@ def handleAllow(self, query):
581588
except server.ProtocolError as why:
582589
# IMPORTANT: NEVER log or show raw why or query with password!
583590
safe_query = strip_password(configuration, query)
584-
logger.error("handleAllow got broken request: %s" % safe_query)
585-
# NOTE: let displayResponse filter pw
586-
self.displayResponse(why)
591+
safe_why = filter_why_pw(configuration, why)
592+
logger.error("handleAllow got broken request %s: %s" %
593+
(safe_query, safe_why))
594+
# IMPORTANT: do NOT use displayResponse here! It would a.o.
595+
# uncritically forward user any unverified return_to
596+
# value in query.
597+
self.showErrorPage('''<h2>Error in Communication</h2>
598+
You may have discovered a bug in the %s OpenID 2.0 service. Please report it
599+
to the site admins if you keep getting here. If you arrived here using the
600+
browser "back" button, however, that is expected since it results in
601+
inconsistent session state.
602+
<h3>Error details:</h3>
603+
<pre>%s</pre>
604+
''' % (configuration.short_title, cgi.escape(safe_why)))
587605
return
588606

589607
logger.debug("handleAllow with last request %s from user %s" %
@@ -743,9 +761,20 @@ def serverEndPoint(self, query):
743761
except server.ProtocolError as why:
744762
# IMPORTANT: NEVER log or show raw why or query with password!
745763
safe_query = strip_password(configuration, query)
746-
logger.error("serverEndPoint got broken request: %s" % safe_query)
747-
# NOTE: let displayResponse filter pw
748-
self.displayResponse(why)
764+
safe_why = filter_why_pw(configuration, why)
765+
logger.error("serverEndPoint got broken request %s: %s" %
766+
(safe_query, safe_why))
767+
# IMPORTANT: do NOT use displayResponse here! It would a.o.
768+
# uncritically forward user any unverified return_to
769+
# value in query.
770+
self.showErrorPage('''<h2>Error in Communication</h2>
771+
You may have discovered a bug in the %s OpenID 2.0 service. Please report it
772+
to the site admins if you keep getting here. If you arrived here using the
773+
browser "back" button, however, that is expected since it results in
774+
inconsistent session state.
775+
<h3>Error details:</h3>
776+
<pre>%s</pre>
777+
''' % (configuration.short_title, cgi.escape(safe_why)))
749778
return
750779

751780
if request is None:
@@ -825,18 +854,17 @@ def displayResponse(self, response):
825854
try:
826855
webresponse = self.server.openid.encodeResponse(response)
827856
except server.EncodingError as why:
828-
# IMPORTANT: always mask passwords in output for security
829-
text = filter_why_pw(configuration, why)
857+
# IMPORTANT: NEVER log or show raw why or query with password!
858+
safe_why = filter_why_pw(configuration, why)
859+
logger.error("displayResponse failed encode: %s" % safe_why)
830860
self.showErrorPage('''<h2>Error in Communication</h2>
831-
<p>
832-
You may have discovered a bug in the OpenID service. Please report it to the
833-
site admins if you keep getting here. If you arrived here using the browser
834-
"back" button, however, that is expected since it results in inconsistent
835-
session state.
836-
</p>
861+
You may have discovered a bug in the %s OpenID 2.0 service. Please report it
862+
to the site admins if you keep getting here. If you arrived here using the
863+
browser "back" button, however, that is expected since it results in
864+
inconsistent session state.
837865
<h3>Error details:</h3>
838866
<pre>%s</pre>
839-
''' % cgi.escape(text))
867+
''' % (configuration.short_title, cgi.escape(safe_why)))
840868
return
841869

842870
self.send_response(webresponse.code)
@@ -938,7 +966,8 @@ def doLogin(self):
938966
self.user = self.query['user']
939967
else:
940968
self.clearUser()
941-
self.redirect(self.query['success_to'])
969+
success_to_url = self.query.get('success_to', None)
970+
self.redirect(success_to_url)
942971
return
943972

944973
if hit_rate_limit(configuration, "openid",
@@ -992,7 +1021,8 @@ def doLogin(self):
9921021
)
9931022

9941023
if authorized:
995-
self.redirect(self.query['success_to'])
1024+
success_to_url = self.query.get('success_to', None)
1025+
self.redirect(success_to_url)
9961026
else:
9971027
logger.warning("login failed for %s" % self.user)
9981028
logger.debug("full query: %s" % self.query)
@@ -1012,23 +1042,30 @@ def doLogin(self):
10121042
retry_url = self.server.base_url
10131043
self.redirect(retry_url)
10141044
elif 'cancel' in self.query:
1015-
self.redirect(self.query['fail_to'])
1045+
fail_to_url = self.query.get('fail_to', None)
1046+
self.redirect(fail_to_url)
10161047
else:
10171048
assert 0, 'strange login %r' % (self.query,)
10181049

10191050
def doLogout(self):
10201051
"""Logout handler"""
10211052
logger.debug("logout clearing user %s" % self.user)
10221053
self.clearUser()
1023-
if 'return_to' in self.query:
1024-
# print "logout redirecting to %(return_to)s" % self.query
1025-
self.redirect(self.query['return_to'])
1054+
return_to_url = self.query.get('return_to', None)
1055+
if return_to_url:
1056+
self.redirect(return_to_url)
10261057

10271058
def redirect(self, url):
1028-
"""Redirect helper"""
1029-
self.send_response(302)
1030-
self.send_header('Location', url)
1031-
self.writeUserHeader()
1059+
"""Redirect helper with built-in check for safe destination URL"""
1060+
1061+
if url and not check_local_site_url(configuration, url):
1062+
logger.error("reject redirect to external URL %r" % url)
1063+
self.send_response(400)
1064+
else:
1065+
logger.debug("redirect to local site URL %r" % url)
1066+
self.send_response(302)
1067+
self.send_header('Location', url)
1068+
self.writeUserHeader()
10321069

10331070
self.end_headers()
10341071

@@ -1152,7 +1189,7 @@ def showErrorPage(self, error_message, error_code=400):
11521189

11531190
def showDecidePage(self, request):
11541191
"""Decide page provider"""
1155-
id_url_base = self.server.base_url+'id/'
1192+
id_url_base = self.server.base_url + 'id/'
11561193
# XXX: This may break if there are any synonyms for id_url_base,
11571194
# such as referring to it by IP address or a CNAME.
11581195
assert (request.identity.startswith(id_url_base) or
@@ -1297,7 +1334,7 @@ def showIdPage(self, path):
12971334
link_tag = '<link rel="openid.server" href="%sopenidserver">' % \
12981335
self.server.base_url
12991336
yadis_loc_tag = '<meta http-equiv="x-xrds-location" content="%s"/>' % \
1300-
(self.server.base_url+'yadis/'+path[4:])
1337+
(self.server.base_url + 'yadis/' + path[4:])
13011338
disco_tags = link_tag + yadis_loc_tag
13021339
ident = self.server.base_url + path[1:]
13031340

@@ -1689,7 +1726,7 @@ def start_service(configuration):
16891726
'root_dir': os.path.abspath(configuration.user_home),
16901727
'db_path': os.path.abspath(default_db_path(configuration)),
16911728
'session_store': os.path.abspath(configuration.openid_store),
1692-
'session_ttl': 24*3600,
1729+
'session_ttl': 24 * 3600,
16931730
'allow_password': 'password' in configuration.user_openid_auth,
16941731
'allow_digest': 'digest' in configuration.user_openid_auth,
16951732
'allow_publickey': 'publickey' in configuration.user_openid_auth,

mig/shared/url.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,40 @@ def openid_basic_logout_url(
145145
'logout?return_to=%s' % return_url)
146146
_logger.debug("basic openid logout url: %s" % oid_logout_url)
147147
return oid_logout_url
148+
149+
150+
def _get_site_urls(configuration):
151+
"""Helper to extract actually enabled site URLs from configuration. Namely,
152+
the ones that are non-empty.
153+
"""
154+
site_url_list = (configuration.migserver_http_url,
155+
configuration.migserver_https_url,
156+
configuration.migserver_public_url,
157+
configuration.migserver_public_alias_url,
158+
configuration.migserver_https_mig_cert_url,
159+
configuration.migserver_https_ext_cert_url,
160+
configuration.migserver_https_mig_oid_url,
161+
configuration.migserver_https_ext_oid_url,
162+
configuration.migserver_https_mig_oidc_url,
163+
configuration.migserver_https_ext_oidc_url,
164+
configuration.migserver_https_sid_url)
165+
return [url for url in site_url_list if url.strip()]
166+
167+
168+
def check_local_site_url(configuration, url):
169+
"""Check if provided url can possibly belongs to this site based on the
170+
configuration. Only inspects the PROTOCOL and FQDN part of the url and
171+
makes sure it fits one of the configured migserver_X_url names - not
172+
whether the remaining part makes sense. The URL is expected to already be
173+
basically validated for invalid character contents e.g. with something
174+
like the mig.shared.safeinput.valid_complex_url helper.
175+
"""
176+
_logger = configuration.logger
177+
proto, fqdn = urlsplit(url)[:2]
178+
url_base = '%s://%s' % (proto, fqdn)
179+
if proto and fqdn and not url_base in _get_site_urls(configuration):
180+
_logger.error("Not a valid URL %r in local site URL check for %r" %
181+
(url, url_base))
182+
return False
183+
_logger.debug("Verified URL %r to be on local site" % url)
184+
return True

tests/test_mig_shared_url.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# --- BEGIN_HEADER ---
4+
#
5+
# test_mig_shared_url - unit test of the corresponding mig shared module
6+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
7+
#
8+
# This file is part of MiG.
9+
#
10+
# MiG is free software: you can redistribute it and/or modify
11+
# it under the terms of the GNU General Public License as published by
12+
# the Free Software Foundation; either version 2 of the License, or
13+
# (at your option) any later version.
14+
#
15+
# MiG is distributed in the hope that it will be useful,
16+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
# GNU General Public License for more details.
19+
#
20+
# You should have received a copy of the GNU General Public License
21+
# along with this program; if not, write to the Free Software
22+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
23+
# USA.
24+
#
25+
# --- END_HEADER ---
26+
#
27+
28+
"""Unit tests for the migrid module pointed to in the filename"""
29+
30+
import os
31+
import sys
32+
33+
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__))))
34+
35+
from tests.support import MigTestCase, FakeConfiguration, testmain
36+
from mig.shared.url import _get_site_urls, check_local_site_url
37+
38+
39+
def _generate_dynamic_site_urls(url_base_list):
40+
"""Simple helper to construct a list of similar URLs for each base url in
41+
url_base_list.
42+
"""
43+
site_urls = []
44+
for url_base in url_base_list:
45+
site_urls += ['%s' % url_base, '%s/' % url_base,
46+
'%s/wsgi-bin/home.py' % url_base,
47+
'%s/wsgi-bin/logout.py' % url_base,
48+
'%s/wsgi-bin/logout.py?return_url=' % url_base,
49+
'%s/wsgi-bin/logout.py?return_url=%s' % (url_base,
50+
ENC_URL)
51+
]
52+
return site_urls
53+
54+
55+
DUMMY_CONF = FakeConfiguration(migserver_http_url='http://myfqdn.org',
56+
migserver_https_url='https://myfqdn.org',
57+
migserver_https_mig_cert_url='',
58+
migserver_https_ext_cert_url='',
59+
migserver_https_mig_oid_url='',
60+
migserver_https_ext_oid_url='',
61+
migserver_https_mig_oidc_url='',
62+
migserver_https_ext_oidc_url='',
63+
migserver_https_sid_url='',
64+
migserver_public_url='',
65+
migserver_public_alias_url='')
66+
ENC_URL = 'https%3A%2F%2Fsomewhere.org%2Fsub%0A'
67+
# Static site-anchored and dynamic full URLs for local site URL check success
68+
LOCAL_SITE_URLS = ['', 'abc', 'abc.txt', '/', '/bla', '/bla#anchor',
69+
'/bla/', '/bla/#anchor', '/bla/bla', '/bla/bla/bla',
70+
'//bla//', './bla', './bla/', './bla/bla',
71+
'./bla/bla/bla', 'logout.py', 'logout.py?bla=',
72+
'/cgi-sid/logout.py', '/cgi-sid/logout.py?bla=bla',
73+
'/cgi-sid/logout.py?return_url=%s' % ENC_URL,
74+
]
75+
LOCAL_BASE_URLS = _get_site_urls(DUMMY_CONF)
76+
LOCAL_SITE_URLS += _generate_dynamic_site_urls(LOCAL_SITE_URLS)
77+
# Dynamic full URLs for local site URL check failure
78+
REMOTE_BASE_URLS = ['https://someevilsite.com', 'ftp://someevilsite.com']
79+
REMOTE_SITE_URLS = _generate_dynamic_site_urls(REMOTE_BASE_URLS)
80+
81+
82+
class BasicUrl(MigTestCase):
83+
"""Wrap unit tests for the corresponding module"""
84+
85+
def test_valid_local_site_urls(self):
86+
"""Check known valid static and dynamic URLs"""
87+
for url in LOCAL_SITE_URLS:
88+
self.assertTrue(check_local_site_url(DUMMY_CONF, url),
89+
"Local site url should succeed for %s" % url)
90+
91+
def test_invalid_local_site_urls(self):
92+
"""Check known invalid URLs"""
93+
for url in REMOTE_SITE_URLS:
94+
self.assertFalse(check_local_site_url(DUMMY_CONF, url),
95+
"Local site url should fail for %s" % url)
96+
97+
98+
if __name__ == '__main__':
99+
testmain()

0 commit comments

Comments
 (0)