Skip to content

Commit d71ea17

Browse files
committed
Fix trailing slash handling regression
Also added tests
1 parent c744aa6 commit d71ea17

3 files changed

Lines changed: 109 additions & 2 deletions

File tree

src/pyff/api.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,9 @@ def _d(x: Optional[str], do_split: bool = True) -> tuple[Optional[str], Optional
205205
# Ugly workaround bc WSGI drops double-slashes.
206206
path = path.replace(':/', '://')
207207

208-
# Ugly workaround bc request.matchdict drops trailing slashes which could be part of the entityID
209-
if request.path and request.path[-1] == "/":
208+
# Ugly workaround bc request.matchdict drops trailing slashes which could be part of the entityID.
209+
# Only for a non-empty path - a bare /entities/ means "everything", not the entity named "/".
210+
if path and request.path and request.path[-1] == "/":
210211
path = path + "/"
211212

212213
msg = "handling entry={}, alias={}, path={}"
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
3+
entityID="https://sp.example.com/saml2/metadata/">
4+
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
5+
<md:Extensions>
6+
<mdui:UIInfo xmlns:mdui="urn:oasis:names:tc:SAML:metadata:ui">
7+
<mdui:DisplayName xml:lang="en">Example Service</mdui:DisplayName>
8+
</mdui:UIInfo>
9+
</md:Extensions>
10+
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:transient</md:NameIDFormat>
11+
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
12+
Location="https://sp.example.com/saml2/acs/" index="0"/>
13+
</md:SPSSODescriptor>
14+
<md:Organization>
15+
<md:OrganizationName xml:lang="en">ExampleOrg</md:OrganizationName>
16+
<md:OrganizationDisplayName xml:lang="en">The Example Organisation</md:OrganizationDisplayName>
17+
<md:OrganizationURL xml:lang="en">https://www.example.com/</md:OrganizationURL>
18+
</md:Organization>
19+
<md:ContactPerson contactType="technical">
20+
<md:Company>Example Organisation</md:Company>
21+
<md:SurName>Example helpdesk</md:SurName>
22+
<md:EmailAddress>helpdesk@example.com</md:EmailAddress>
23+
</md:ContactPerson>
24+
</md:EntityDescriptor>

src/pyff/test/test_md_api.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77

88
import pytest
99
import requests
10+
from lxml import etree
1011
from mako.lookup import TemplateLookup
1112
from wsgi_intercept.interceptor import RequestsInterceptor, UrllibInterceptor
1213

1314
from pyff.api import mkapp
1415
from pyff.constants import config
16+
from pyff.samlmd import iter_entities
1517
from pyff.test import SignerTestCase
1618
from pyff.test.test_pipeline import PipeLineTest
1719

@@ -226,3 +228,83 @@ def test_api_resources(self):
226228
assert (last_seen - now).total_seconds() < 60
227229

228230
assert os.path.exists(os.path.join(config.local_copy_dir, urlescape(f'file://{self.test01}')))
231+
232+
233+
class PyFFAPITestTrailingSlash(PipeLineTest):
234+
"""
235+
A trailing slash is part of an entityID, except when there is no entityID at all -
236+
a bare /entities/ selects everything, just like /entities does.
237+
"""
238+
239+
mdx = None
240+
app = None
241+
idp = 'https://idp.example.com/saml2/idp/metadata.php'
242+
sp = 'https://sp.example.com/saml2/metadata/'
243+
244+
@classmethod
245+
def setUpClass(cls):
246+
SignerTestCase.setUpClass()
247+
config.local_copy_dir = tempfile.mkdtemp()
248+
cls.test01 = os.path.join(cls.datadir, 'metadata', 'test01.xml')
249+
cls.test04 = os.path.join(cls.datadir, 'metadata', 'test04-trailing-slash-sp.xml')
250+
cls.mdx = tempfile.NamedTemporaryFile('w').name
251+
with open(cls.mdx, "w") as fd:
252+
fd.write(
253+
f"""
254+
- when update:
255+
- load:
256+
- {cls.test01}
257+
- {cls.test04}
258+
- when request:
259+
- select
260+
- pipe:
261+
- when accept application/xml:
262+
- finalize:
263+
cacheDuration: PT5H
264+
validUntil: P10D
265+
- emit application/xml
266+
- break
267+
"""
268+
)
269+
cls._app = mkapp(cls.mdx)
270+
cls.app = lambda *args, **kwargs: cls._app
271+
272+
@classmethod
273+
def tearDownClass(cls):
274+
SignerTestCase.tearDownClass()
275+
if os.path.exists(cls.mdx):
276+
os.unlink(cls.mdx)
277+
if os.path.exists(config.local_copy_dir):
278+
shutil.rmtree(config.local_copy_dir)
279+
280+
def _entity_ids(self, url, path):
281+
"""Return the set of entityIDs the API serves for path"""
282+
r = requests.get(f'{url}{path}', headers={'Accept': 'application/xml'})
283+
assert r.status_code == 200, f'{path} -> {r.status_code}'
284+
t = etree.fromstring(r.content)
285+
return {e.get('entityID') for e in iter_entities(t)}
286+
287+
def test_entities_without_trailing_slash(self):
288+
with RequestsInterceptor(self.app, host='127.0.0.1', port=80) as url:
289+
assert requests.post(f'{url}/api/call/update').status_code == 200
290+
assert self._entity_ids(url, '/entities') == {self.idp, self.sp}
291+
292+
def test_entities_with_trailing_slash(self):
293+
with RequestsInterceptor(self.app, host='127.0.0.1', port=80) as url:
294+
assert requests.post(f'{url}/api/call/update').status_code == 200
295+
assert self._entity_ids(url, '/entities/') == {self.idp, self.sp}
296+
297+
def test_entity_id_keeps_its_trailing_slash(self):
298+
"""An entityID ending in a slash must not be truncated - cf. issue #298"""
299+
with RequestsInterceptor(self.app, host='127.0.0.1', port=80) as url:
300+
assert requests.post(f'{url}/api/call/update').status_code == 200
301+
# the unescaped form - WSGI hands us a single slash after the scheme
302+
assert self._entity_ids(url, '/entities/https:/sp.example.com/saml2/metadata/') == {self.sp}
303+
# ... and the escaped form
304+
assert self._entity_ids(url, f'/entities/{urlescape(self.sp, safe="")}') == {self.sp}
305+
306+
def test_entity_id_without_trailing_slash_is_not_found(self):
307+
"""Dropping the slash from an entityID that has one must not match it"""
308+
with RequestsInterceptor(self.app, host='127.0.0.1', port=80) as url:
309+
assert requests.post(f'{url}/api/call/update').status_code == 200
310+
assert self._entity_ids(url, '/entities/https:/sp.example.com/saml2/metadata') == set()

0 commit comments

Comments
 (0)