Skip to content

Commit df0a005

Browse files
authored
Merge pull request #1085 from isso-comments/migrate-pkg-resources
Migrate pkg resources to importlib
2 parents 09cddc1 + 22a92b2 commit df0a005

8 files changed

Lines changed: 104 additions & 15 deletions

File tree

.github/workflows/python-tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
matrix:
1313
os: [ubuntu-latest, macos-latest]
14-
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
14+
python-version: [3.9, "3.10", "3.11", "3.12", "3.13", "3.14"]
1515
fail-fast: false
1616

1717
steps:

docs/conf.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import sys
1616
import io
1717
import re
18-
import pkg_resources
1918

2019
from os.path import dirname, join
2120
# Make `_theme` custom sphinx theme available

isso/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
#
2626
# Isso – a lightweight Disqus alternative
2727

28-
import pkg_resources
29-
dist = pkg_resources.get_distribution("isso")
28+
from importlib.metadata import version
29+
__version__ = version("isso")
3030

3131
# check if exectuable is `isso` and gevent is available
3232
import sys
@@ -232,7 +232,7 @@ def main():
232232
subparser = parser.add_subparsers(help="commands", dest="command")
233233

234234
parser.add_argument('--version', action='version',
235-
version='%(prog)s ' + dist.version)
235+
version='%(prog)s ' + __version__)
236236
parser.add_argument("-c", dest="conf", default="/etc/isso.cfg",
237237
metavar="/etc/isso.cfg", help="set configuration file")
238238

isso/config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,15 @@
33
import datetime
44
import logging
55
import os
6-
import pkg_resources
76
import re
87

98
from email.utils import parseaddr, formataddr
109
from configparser import ConfigParser, NoOptionError, NoSectionError, DuplicateSectionError
10+
from importlib.resources import files
1111

1212
logger = logging.getLogger("isso")
1313

14-
default_config_file = pkg_resources.resource_filename('isso', 'isso.cfg')
14+
default_config_file = str(files('isso').joinpath('isso.cfg'))
1515

1616

1717
def timedelta(string):

isso/tests/test_comments.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ def testCreate(self):
6969
self.assertEqual(rv["mode"], 1)
7070
self.assertEqual(rv["text"], '<p>Lorem ipsum ...</p>')
7171

72+
def testWebsiteXSSPayloadIsEscaped(self):
73+
"""Website field with XSS payload must have quotes HTML-escaped."""
74+
payload = "http://x.com/?'onmouseover='alert(document.domain)'x='"
75+
rv = self.post('/new?uri=%2Fpath%2F',
76+
data=json.dumps({'text': 'Hello', 'website': payload}))
77+
self.assertEqual(rv.status_code, 201)
78+
rv = loads(rv.data)
79+
# Single quotes must be HTML-escaped so they cannot break out of an
80+
# HTML attribute context (e.g. href='...')
81+
self.assertNotIn("'", rv["website"])
82+
self.assertNotIn('"', rv["website"])
83+
self.assertEqual(
84+
rv["website"],
85+
"http://x.com/?&#x27;onmouseover=&#x27;alert(document.domain)&#x27;x=&#x27;",
86+
)
87+
7288
def textCreateWithNonAsciiText(self):
7389

7490
rv = self.post('/new?uri=%2Fpath%2F',
@@ -391,6 +407,27 @@ def testUpdate(self):
391407
self.assertEqual(rv['website'], 'http://example.com/')
392408
self.assertIn('modified', rv)
393409

410+
def testUpdateWebsiteXSSPayloadIsEscaped(self):
411+
"""Website and author XSS payloads via edit endpoint must be HTML-escaped."""
412+
self.post('/new?uri=%2Fpath%2F', data=json.dumps({'text': 'Lorem ipsum ...'}))
413+
414+
website_payload = "http://x.com/?'onmouseover='alert(document.domain)'x='"
415+
author_payload = "<script>alert(1)</script>"
416+
self.put('/id/1', data=json.dumps({
417+
'text': 'Hello World',
418+
'author': author_payload,
419+
'website': website_payload,
420+
}))
421+
422+
rv = loads(self.get('/id/1?plain=1').data)
423+
self.assertNotIn("'", rv["website"])
424+
self.assertEqual(
425+
rv["website"],
426+
"http://x.com/?&#x27;onmouseover=&#x27;alert(document.domain)&#x27;x=&#x27;",
427+
)
428+
self.assertNotIn("<script>", rv["author"])
429+
self.assertEqual(rv["author"], "&lt;script&gt;alert(1)&lt;/script&gt;")
430+
394431
def testUpdateForbidden(self):
395432

396433
self.post('/new?uri=test', data=json.dumps({'text': 'Hello world!'}))
@@ -860,6 +897,34 @@ def testModerateComment(self):
860897
# Comment should no longer exist
861898
self.assertEqual(self.app.db.comments.get(id_), None)
862899

900+
def testModerateEditXSSPayloadIsEscaped(self):
901+
"""XSS payloads in author/website via moderate edit endpoint must be HTML-escaped."""
902+
id_ = 1
903+
signed = self.app.sign(id_)
904+
905+
self.client.post('/new?uri=/moderated', data=json.dumps({"text": "..."}))
906+
907+
website_payload = "http://x.com/?'onmouseover='alert(document.domain)'x='"
908+
author_payload = "<script>alert(1)</script>"
909+
rv = self.client.post(
910+
'/id/%d/edit/%s' % (id_, signed),
911+
data=json.dumps({
912+
"text": "new text",
913+
"author": author_payload,
914+
"website": website_payload,
915+
}),
916+
)
917+
self.assertEqual(rv.status_code, 200)
918+
919+
stored = self.app.db.comments.get(id_)
920+
self.assertNotIn("'", stored["website"])
921+
self.assertEqual(
922+
stored["website"],
923+
"http://x.com/?&#x27;onmouseover=&#x27;alert(document.domain)&#x27;x=&#x27;",
924+
)
925+
self.assertNotIn("<script>", stored["author"])
926+
self.assertEqual(stored["author"], "&lt;script&gt;alert(1)&lt;/script&gt;")
927+
863928

864929
class TestUnsubscribe(unittest.TestCase):
865930

isso/utils/http.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from urllib.parse import urlparse
88

9-
from isso import dist
9+
from isso import __version__
1010
from isso.wsgi import urlsplit
1111

1212
MAX_RETRY_COUNT = 3
@@ -24,7 +24,7 @@ class curl(object):
2424
"""
2525

2626
headers = {
27-
"User-Agent": "Isso/{0} (+https://isso-comments.de)".format(dist.version)
27+
"User-Agent": "Isso/{0} (+https://isso-comments.de)".format(__version__)
2828
}
2929

3030
def __init__(self, method, host, path, timeout=3):

isso/views/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- encoding: utf-8 -*-
22

3-
import pkg_resources
4-
dist = pkg_resources.get_distribution("isso")
3+
from importlib.metadata import version
4+
__version__ = version("isso")
55

66
import json
77

@@ -81,7 +81,7 @@ def __init__(self, isso):
8181
def show(self, environ, request):
8282

8383
rv = {
84-
"version": dist.version,
84+
"version": __version__,
8585
"host": str(local("host")),
8686
"origin": str(local("origin")),
8787
"moderation": self.moderation,

isso/views/comments.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
import time
66
import functools
77
import json # json.dumps to put URL in <script>
8-
import pkg_resources
98

109
from configparser import NoOptionError
10+
from importlib.resources import files
1111
from datetime import datetime, timedelta
1212
from html import escape
1313
from io import BytesIO as StringIO
@@ -335,10 +335,13 @@ def new(self, environ, request, uri):
335335
if not valid:
336336
return BadRequest(reason)
337337

338-
for field in ("author", "email", "website"):
338+
for field in ("author", "email"):
339339
if data.get(field) is not None:
340340
data[field] = escape(data[field], quote=False)
341341

342+
if data.get("website") is not None:
343+
data["website"] = escape(data["website"], quote=True)
344+
342345
if data.get("website"):
343346
data["website"] = normalize(data["website"])
344347

@@ -548,6 +551,13 @@ def edit(self, environ, request, id):
548551
if not valid:
549552
return BadRequest(reason)
550553

554+
for field in ("author",):
555+
if data.get(field) is not None:
556+
data[field] = escape(data[field], quote=False)
557+
558+
if data.get("website") is not None:
559+
data["website"] = escape(data["website"], quote=True)
560+
551561
data['modified'] = time.time()
552562

553563
with self.isso.lock:
@@ -794,6 +804,21 @@ def moderate(self, environ, request, id, action, key):
794804
return Response("Comment has been activated", 200)
795805
elif action == "edit":
796806
data = request.json
807+
808+
for key in set(data.keys()) - set(["text", "author", "website"]):
809+
data.pop(key)
810+
811+
valid, reason = API.verify(data)
812+
if not valid:
813+
return BadRequest(reason)
814+
815+
for field in ("author",):
816+
if data.get(field) is not None:
817+
data[field] = escape(data[field], quote=False)
818+
819+
if data.get("website") is not None:
820+
data["website"] = escape(data["website"], quote=True)
821+
797822
with self.isso.lock:
798823
rv = self.comments.update(id, data)
799824
for key in set(rv.keys()) - API.FIELDS:
@@ -1391,7 +1416,7 @@ def config(self, environment, request):
13911416
</body>
13921417
"""
13931418
def demo(self, env, req):
1394-
index = pkg_resources.resource_filename('isso', 'demo/index.html')
1419+
index = str(files('isso').joinpath('demo/index.html'))
13951420
return send_from_directory(os_path.dirname(index), 'index.html', env)
13961421

13971422
"""

0 commit comments

Comments
 (0)