Skip to content

Commit 53f6bf4

Browse files
author
Florent
committed
Support Responses that don't have AttributeStatements
1 parent fa33877 commit 53f6bf4

5 files changed

Lines changed: 82 additions & 7 deletions

File tree

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
extras_require={
4040
'test': (
4141
'coverage==3.7.1',
42+
'freezegun==0.3.5',
4243
'pylint==1.3.1',
4344
'pep8==1.5.7',
4445
'pyflakes==0.8.1',

src/onelogin/saml2/response.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,9 @@ def is_valid(self, request_data, request_id=None):
114114
if len(encrypted_nameid_nodes) == 0:
115115
raise Exception('The NameID of the Response is not encrypted and the SP require it')
116116

117-
# Checks that there is at least one AttributeStatement
117+
# Checks that there is at least one AttributeStatement if required
118118
attribute_statement_nodes = self.__query_assertion('/saml:AttributeStatement')
119-
if not attribute_statement_nodes:
119+
if security.get('wantAttributeStatement', True) and not attribute_statement_nodes:
120120
raise Exception('There is no AttributeStatement on the Response')
121121

122122
# Validates Asserion timestamps

src/onelogin/saml2/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,10 @@ def __add_default_values(self):
300300
if 'signatureAlgorithm' not in self.__security.keys():
301301
self.__security['signatureAlgorithm'] = OneLogin_Saml2_Constants.RSA_SHA1
302302

303+
# AttributeStatement required by default
304+
if 'wantAttributeStatement' not in self.__security.keys():
305+
self.__security['wantAttributeStatement'] = True
306+
303307
if 'x509cert' not in self.__idp:
304308
self.__idp['x509cert'] = ''
305309
if 'certFingerprint' not in self.__idp:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIHhtbG5zOnNhbWxwPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6cHJvdG9jb2wiIERlc3RpbmF0aW9uPSJodHRwczovL3BpdGJ1bGsubm8taXAub3JnL25ld29uZWxvZ2luL2RlbW8xL2luZGV4LnBocD9hY3MiIElEPSJwZnhkYjRkOWVmZS1kMGFkLTAwODYtY2U4OC1jMjg4Njg3Y2FjNjEiIEluUmVzcG9uc2VUbz0iT05FTE9HSU5fNjEyYmJmOWIxNjQ1Mjk0YWEwYjQ2MzdiMWJjNWYzOWRlOGI3OWNlYiIgSXNzdWVJbnN0YW50PSIyMDE0LTAzLTMxVDAwOjM3OjE2WiIgVmVyc2lvbj0iMi4wIj48c2FtbDpJc3N1ZXI+aHR0cHM6Ly9waXRidWxrLm5vLWlwLm9yZy9zaW1wbGVzYW1sL3NhbWwyL2lkcC9tZXRhZGF0YS5waHA8L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+CiAgICA8ZHM6U2lnbmF0dXJlTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI3JzYS1zaGExIi8+CiAgPGRzOlJlZmVyZW5jZSBVUkk9IiNwZnhkYjRkOWVmZS1kMGFkLTAwODYtY2U4OC1jMjg4Njg3Y2FjNjEiPjxkczpUcmFuc2Zvcm1zPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjZW52ZWxvcGVkLXNpZ25hdHVyZSIvPjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz48L2RzOlRyYW5zZm9ybXM+PGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+PGRzOkRpZ2VzdFZhbHVlPmpjMklRWFNoc3dzTG85TkdJSHp2cGtBaXY4ND08L2RzOkRpZ2VzdFZhbHVlPjwvZHM6UmVmZXJlbmNlPjwvZHM6U2lnbmVkSW5mbz48ZHM6U2lnbmF0dXJlVmFsdWU+aUVqR2QrdWFqSVArYU9ucGo4MjYxUzRBaWdMeXJqc0pheTJzdVFKakhhVHlETlh4TFhWQ3AxZG1PR0JhZGhmRUtnWVJsaTFBZDA1QktBejlpd3NBME14OGZ6SmFhSlBUbHM2NS93ODZTSEN4NTdrNXhteDBSUjhuR09MOU1vb2lidnZWeTVRODl2Z2lnVWN5cWJUY0dxaU5uSVNCWGZuYVR2dnpQYS9QbWJ3PTwvZHM6U2lnbmF0dXJlVmFsdWU+CjxkczpLZXlJbmZvPjxkczpYNTA5RGF0YT48ZHM6WDUwOUNlcnRpZmljYXRlPk1JSUNWekNDQWNBQ0NRRElWSGFOU0JZTDZUQU5CZ2txaGtpRzl3MEJBUXNGQURCd01Rc3dDUVlEVlFRR0V3SkdVakVPTUF3R0ExVUVDQXdGVUdGeWFYTXhEakFNQmdOVkJBY01CVkJoY21sek1SWXdGQVlEVlFRS0RBMU9iM1poY0c5emRDQlVSVk5VTVNrd0p3WUpLb1pJaHZjTkFRa0JGaHBtYkc5eVpXNTBMbkJwWjI5MWRFQnViM1poY0c5emRDNW1jakFlRncweE5EQXlNVE14TXpVek5EQmFGdzB4TlRBeU1UTXhNelV6TkRCYU1IQXhDekFKQmdOVkJBWVRBa1pTTVE0d0RBWURWUVFJREFWUVlYSnBjekVPTUF3R0ExVUVCd3dGVUdGeWFYTXhGakFVQmdOVkJBb01EVTV2ZG1Gd2IzTjBJRlJGVTFReEtUQW5CZ2txaGtpRzl3MEJDUUVXR21ac2IzSmxiblF1Y0dsbmIzVjBRRzV2ZG1Gd2IzTjBMbVp5TUlHZk1BMEdDU3FHU0liM0RRRUJBUVVBQTRHTkFEQ0JpUUtCZ1FDaExGSG4zTG5ONEpRLzdXQ2RZdXB4a1VnY05PUW5QRit5bGwrL0RQcHV4OW5wZlkwNTlQSVVhdEI4WDdrQ241aTh0UndJeS9pa0hKUjZNcjgrTVB2YzZWT1pEeFBOZFp2TW8vOGxoeHJiTjNKZHJ3M3doWm1VL0tQUjlGM0JkRmR1K1NMenJNbDFURFVabFB0WTlYelVGWGNxTjhJWGN5OFRKekNCZU5leTNRSURBUUFCTUEwR0NTcUdTSWIzRFFFQkN3VUFBNEdCQUN0SjhmZUd6ZTFOSEI1VncxOGpNVVB2SG83SDNHd21qNlpEQVhRbGFpQVhNdU5CeE5YVldWd2lmbDZWK25XM3c5UWE3RmVvL25aL080VFVPSDFueithZGtsY0NENFFwWmFFSWJtQWJyaVBXSktnYjRMV0docVFydXdZUjdJdFRSMU1OWDlnTGJQMHowenZERVFubnQvVlVXRkVCTFNKcTRaNE5yZThMRm1TMjwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1scDpTdGF0dXM+PHNhbWxwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbHA6U3RhdHVzPjxzYW1sOkFzc2VydGlvbiB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIElEPSJwZng3ZTNmMWYxMS0zZDM4LTdkYTUtNTVlZC05YjRkNmMwYTQ0ZWIiIElzc3VlSW5zdGFudD0iMjAxNC0wMy0zMVQwMDozNzoxNloiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyPmh0dHBzOi8vcGl0YnVsay5uby1pcC5vcmcvc2ltcGxlc2FtbC9zYW1sMi9pZHAvbWV0YWRhdGEucGhwPC9zYW1sOklzc3Vlcj48ZHM6U2lnbmF0dXJlIHhtbG5zOmRzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIj4KICA8ZHM6U2lnbmVkSW5mbz48ZHM6Q2Fub25pY2FsaXphdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPgogICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgogIDxkczpSZWZlcmVuY2UgVVJJPSIjcGZ4N2UzZjFmMTEtM2QzOC03ZGE1LTU1ZWQtOWI0ZDZjMGE0NGViIj48ZHM6VHJhbnNmb3Jtcz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI2VudmVsb3BlZC1zaWduYXR1cmUiLz48ZHM6VHJhbnNmb3JtIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+PC9kczpUcmFuc2Zvcm1zPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIvPjxkczpEaWdlc3RWYWx1ZT42d1dzemxmRllidGRzNnR5K24rT3RESnZLRUE9PC9kczpEaWdlc3RWYWx1ZT48L2RzOlJlZmVyZW5jZT48L2RzOlNpZ25lZEluZm8+PGRzOlNpZ25hdHVyZVZhbHVlPmVVRTkxaFA2bTZ3VlVtd0liVkpTZnhWdkppOVFwd3QwZGpIUDRpcW5yMk42Y2ZWVmV3eERVM0dXQTlsOVpWanltV292RkltL1k0dGR3VTM0R2RiaS8yaWhvMmd0OGVWR3c4ajNSdVFoTVVIc1ZmK2hIaDJlSDhuMHhqZEFqdGRoTkhIT3pMMnREV3hYazg2T2VZbmw4Slp1VTdCRUVTZUtlQzlieDBPUW5ZTT08L2RzOlNpZ25hdHVyZVZhbHVlPgo8ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlDVnpDQ0FjQUNDUURJVkhhTlNCWUw2VEFOQmdrcWhraUc5dzBCQVFzRkFEQndNUXN3Q1FZRFZRUUdFd0pHVWpFT01Bd0dBMVVFQ0F3RlVHRnlhWE14RGpBTUJnTlZCQWNNQlZCaGNtbHpNUll3RkFZRFZRUUtEQTFPYjNaaGNHOXpkQ0JVUlZOVU1Ta3dKd1lKS29aSWh2Y05BUWtCRmhwbWJHOXlaVzUwTG5CcFoyOTFkRUJ1YjNaaGNHOXpkQzVtY2pBZUZ3MHhOREF5TVRNeE16VXpOREJhRncweE5UQXlNVE14TXpVek5EQmFNSEF4Q3pBSkJnTlZCQVlUQWtaU01RNHdEQVlEVlFRSURBVlFZWEpwY3pFT01Bd0dBMVVFQnd3RlVHRnlhWE14RmpBVUJnTlZCQW9NRFU1dmRtRndiM04wSUZSRlUxUXhLVEFuQmdrcWhraUc5dzBCQ1FFV0dtWnNiM0psYm5RdWNHbG5iM1YwUUc1dmRtRndiM04wTG1aeU1JR2ZNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0R05BRENCaVFLQmdRQ2hMRkhuM0xuTjRKUS83V0NkWXVweGtVZ2NOT1FuUEYreWxsKy9EUHB1eDlucGZZMDU5UElVYXRCOFg3a0NuNWk4dFJ3SXkvaWtISlI2TXI4K01QdmM2Vk9aRHhQTmRadk1vLzhsaHhyYk4zSmRydzN3aFptVS9LUFI5RjNCZEZkdStTTHpyTWwxVERVWmxQdFk5WHpVRlhjcU44SVhjeThUSnpDQmVOZXkzUUlEQVFBQk1BMEdDU3FHU0liM0RRRUJDd1VBQTRHQkFDdEo4ZmVHemUxTkhCNVZ3MThqTVVQdkhvN0gzR3dtajZaREFYUWxhaUFYTXVOQnhOWFZXVndpZmw2VituVzN3OVFhN0Zlby9uWi9PNFRVT0gxbnorYWRrbGNDRDRRcFphRUlibUFicmlQV0pLZ2I0TFdHaHFRcnV3WVI3SXRUUjFNTlg5Z0xiUDB6MHp2REVRbm50L1ZVV0ZFQkxTSnE0WjROcmU4TEZtUzI8L2RzOlg1MDlDZXJ0aWZpY2F0ZT48L2RzOlg1MDlEYXRhPjwvZHM6S2V5SW5mbz48L2RzOlNpZ25hdHVyZT48c2FtbDpTdWJqZWN0PjxzYW1sOk5hbWVJRCBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OnRyYW5zaWVudCIgU1BOYW1lUXVhbGlmaWVyPSJodHRwczovL3BpdGJ1bGsubm8taXAub3JnL25ld29uZWxvZ2luL2RlbW8xL21ldGFkYXRhLnBocCI+XzNhZjYyZjFkMDM1MTNiZGQ2MWRkNWJmMDRkM2RlYjdhYTYxNzQ4MGUyMjwvc2FtbDpOYW1lSUQ+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbiBNZXRob2Q9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpjbTpiZWFyZXIiPjxzYW1sOlN1YmplY3RDb25maXJtYXRpb25EYXRhIEluUmVzcG9uc2VUbz0iT05FTE9HSU5fNjEyYmJmOWIxNjQ1Mjk0YWEwYjQ2MzdiMWJjNWYzOWRlOGI3OWNlYiIgTm90T25PckFmdGVyPSIyMDIzLTEwLTAyVDA1OjU3OjE2WiIgUmVjaXBpZW50PSJodHRwczovL3BpdGJ1bGsubm8taXAub3JnL25ld29uZWxvZ2luL2RlbW8xL2luZGV4LnBocD9hY3MiLz48L3NhbWw6U3ViamVjdENvbmZpcm1hdGlvbj48L3NhbWw6U3ViamVjdD48c2FtbDpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAxNC0wMy0zMVQwMDozNjo0NloiIE5vdE9uT3JBZnRlcj0iMjAyMy0xMC0wMlQwNTo1NzoxNloiPjxzYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PHNhbWw6QXVkaWVuY2U+aHR0cHM6Ly9waXRidWxrLm5vLWlwLm9yZy9uZXdvbmVsb2dpbi9kZW1vMS9tZXRhZGF0YS5waHA8L3NhbWw6QXVkaWVuY2U+PC9zYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PC9zYW1sOkNvbmRpdGlvbnM+PHNhbWw6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDE0LTAzLTMxVDAwOjM3OjE2WiIgU2Vzc2lvbkluZGV4PSJfODVlN2NmZTE2ZDZlN2U2MDBiZDk4YmJjMmI0MzcxZTFjNjk1ODhhNGRhIiBTZXNzaW9uTm90T25PckFmdGVyPSIyMDE0LTAzLTMxVDA4OjM3OjE2WiI+PHNhbWw6QXV0aG5Db250ZXh0PjxzYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPnVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphYzpjbGFzc2VzOlBhc3N3b3JkPC9zYW1sOkF1dGhuQ29udGV4dENsYXNzUmVmPjwvc2FtbDpBdXRobkNvbnRleHQ+PC9zYW1sOkF1dGhuU3RhdGVtZW50Pjwvc2FtbDpBc3NlcnRpb24+PC9zYW1scDpSZXNwb25zZT4=

tests/src/OneLogin/saml2_tests/response_test.py

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
# All rights reserved.
55

66
from base64 import b64decode, b64encode
7+
from datetime import datetime
8+
from datetime import timedelta
9+
from freezegun import freeze_time
710
import json
811
from os.path import dirname, join, exists
912
import unittest
@@ -434,11 +437,77 @@ def testIsInValidNoStatement(self):
434437

435438
settings.set_strict(True)
436439
response_2 = OneLogin_Saml2_Response(settings, xml)
437-
try:
438-
valid = response_2.is_valid(self.get_request_data())
439-
self.assertFalse(valid)
440-
except Exception as e:
441-
self.assertEqual('There is no AttributeStatement on the Response', e.message)
440+
self.assertFalse(response_2.is_valid(self.get_request_data()))
441+
self.assertEqual('There is no AttributeStatement on the Response', response_2.get_error())
442+
443+
def testIsValidOptionalStatement(self):
444+
"""
445+
Tests the is_valid method of the OneLogin_Saml2_Response
446+
Case AttributeStatement is optional
447+
"""
448+
# shortcut
449+
json_settings = self.loadSettingsJSON()
450+
# ensure valid entityid
451+
json_settings['sp']['entityId'] = 'https://pitbulk.no-ip.org/newonelogin/demo1/metadata.php'
452+
json_settings['idp']['entityId'] = 'https://pitbulk.no-ip.org/simplesaml/saml2/idp/metadata.php'
453+
json_settings['idp']['x509cert'] = """
454+
MIICVzCCAcACCQDIVHaNSBYL6TANBgkqhkiG9w0BAQsFADBwMQswCQYDVQQGEwJG
455+
UjEOMAwGA1UECAwFUGFyaXMxDjAMBgNVBAcMBVBhcmlzMRYwFAYDVQQKDA1Ob3Zh
456+
cG9zdCBURVNUMSkwJwYJKoZIhvcNAQkBFhpmbG9yZW50LnBpZ291dEBub3ZhcG9z
457+
dC5mcjAeFw0xNDAyMTMxMzUzNDBaFw0xNTAyMTMxMzUzNDBaMHAxCzAJBgNVBAYT
458+
AkZSMQ4wDAYDVQQIDAVQYXJpczEOMAwGA1UEBwwFUGFyaXMxFjAUBgNVBAoMDU5v
459+
dmFwb3N0IFRFU1QxKTAnBgkqhkiG9w0BCQEWGmZsb3JlbnQucGlnb3V0QG5vdmFw
460+
b3N0LmZyMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQChLFHn3LnN4JQ/7WCd
461+
YupxkUgcNOQnPF+yll+/DPpux9npfY059PIUatB8X7kCn5i8tRwIy/ikHJR6Mr8+
462+
MPvc6VOZDxPNdZvMo/8lhxrbN3Jdrw3whZmU/KPR9F3BdFdu+SLzrMl1TDUZlPtY
463+
9XzUFXcqN8IXcy8TJzCBeNey3QIDAQABMA0GCSqGSIb3DQEBCwUAA4GBACtJ8feG
464+
ze1NHB5Vw18jMUPvHo7H3Gwmj6ZDAXQlaiAXMuNBxNXVWVwifl6V+nW3w9Qa7Feo
465+
/nZ/O4TUOH1nz+adklcCD4QpZaEIbmAbriPWJKgb4LWGhqQruwYR7ItTR1MNX9gL
466+
bP0z0zvDEQnnt/VUWFEBLSJq4Z4Nre8LFmS2
467+
""".strip()
468+
469+
settings = OneLogin_Saml2_Settings(json_settings)
470+
settings.set_strict(True)
471+
472+
# want AttributeStatement True by default
473+
self.assertTrue(settings.get_security_data()['wantAttributeStatement'])
474+
475+
xml = self.file_contents(join(self.data_path, 'responses', 'invalids', 'signed_assertion_response.xml.base64'))
476+
477+
not_on_or_after = datetime.strptime('2014-03-31T08:37:16Z', '%Y-%m-%dT%H:%M:%SZ')
478+
not_on_or_after -= timedelta(seconds=150)
479+
480+
response = OneLogin_Saml2_Response(settings, xml)
481+
with freeze_time(not_on_or_after):
482+
self.assertFalse(response.is_valid({
483+
'https': 'on',
484+
'http_host': 'pitbulk.no-ip.org',
485+
'script_name': 'newonelogin/demo1/index.php?acs'
486+
}))
487+
self.assertEqual('There is no AttributeStatement on the Response', response.get_error())
488+
489+
security = settings.get_security_data()
490+
self.assertTrue(security['wantAttributeStatement'])
491+
492+
# change wantAttributeStatement to optional
493+
json_settings['security']['wantAttributeStatement'] = False
494+
settings = OneLogin_Saml2_Settings(json_settings)
495+
settings.set_strict(True)
496+
497+
# check settings
498+
self.assertFalse(settings.get_security_data()['wantAttributeStatement'])
499+
500+
response = OneLogin_Saml2_Response(settings, xml)
501+
response.is_valid(self.get_request_data())
502+
503+
# check response
504+
with freeze_time(not_on_or_after):
505+
self.assertTrue(response.is_valid({
506+
'https': 'on',
507+
'http_host': 'pitbulk.no-ip.org',
508+
'script_name': 'newonelogin/demo1/index.php?acs'
509+
}))
510+
self.assertIsNone(response.get_error())
442511

443512
def testIsInValidNoKey(self):
444513
"""

0 commit comments

Comments
 (0)