Skip to content

Commit 0afbfe0

Browse files
committed
Fix stored XSS in website and author fields
Single quotes were not HTML-escaped in the website field due to escape() being called with quote=False. This allowed an attacker to break out of a single-quoted HTML attribute (e.g. href='...') and inject event handlers such as onmouseover. The same escaping was missing entirely from the user edit endpoint and the moderation edit endpoint. Fix by using escape(..., quote=True) for the website field and escape(..., quote=False) for author across all three write paths: POST /new, PUT /id/<id>, and POST /id/<id>/edit/<key>. Reported-by: ByamB4 <byamba4life@gmail.com>
1 parent 09cddc1 commit 0afbfe0

2 files changed

Lines changed: 91 additions & 1 deletion

File tree

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/views/comments.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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:

0 commit comments

Comments
 (0)