Skip to content

Commit be3dcc7

Browse files
authored
Sanitize comment content on edit (mozilla#4090)
Also: - Add tests - Drop useless error representation complexity
1 parent b0ebb14 commit be3dcc7

3 files changed

Lines changed: 99 additions & 25 deletions

File tree

pontoon/base/forms.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,15 @@ class AddCommentForm(forms.Form):
400400
translation = forms.IntegerField(required=False)
401401

402402

403+
class EditCommentForm(forms.Form):
404+
"""
405+
Form for parameters to the `edit_comment` view.
406+
"""
407+
408+
comment_id = forms.IntegerField()
409+
content = HtmlField()
410+
411+
403412
class CreateTokenForm(forms.ModelForm):
404413
"""
405414
Form for creating Personal Access Tokens.

pontoon/base/tests/views/test_comment.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,51 @@
77
from pontoon.test.factories import TranslationCommentFactory
88

99

10+
@pytest.mark.django_db
11+
def test_add_comment(member, translation_a):
12+
url = reverse("pontoon.add_comment")
13+
14+
response = member.client.post(
15+
url,
16+
{
17+
"translation": translation_a.pk,
18+
"entity": translation_a.entity.pk,
19+
"locale": translation_a.locale.code,
20+
"comment": "new comment",
21+
},
22+
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
23+
)
24+
25+
assert response.status_code == 200
26+
27+
comment = Comment.objects.get(translation=translation_a, author=member.user)
28+
assert comment.content == "new comment"
29+
30+
31+
@pytest.mark.django_db
32+
def test_add_comment_sanitizes_html(member, entity_a, locale_a, project_locale_a):
33+
url = reverse("pontoon.add_comment")
34+
35+
payload = "<svg><script>alert(1)</script>safe"
36+
37+
response = member.client.post(
38+
url,
39+
{
40+
"entity": entity_a.pk,
41+
"locale": locale_a.code,
42+
"comment": payload,
43+
},
44+
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
45+
)
46+
47+
assert response.status_code == 200
48+
49+
comment = Comment.objects.latest("id")
50+
51+
assert "<script>" not in comment.content
52+
assert "safe" in comment.content
53+
54+
1055
@pytest.mark.django_db
1156
def test_pin_comment(member, client, comment_a):
1257
url = reverse("pontoon.pin_comment")
@@ -66,10 +111,10 @@ def test_unpin_comment(member, client, team_comment_a):
66111

67112

68113
@pytest.mark.django_db
69-
def test_edit_comment(member, client, comment_a):
114+
def test_edit_comment(member, comment_a):
70115
url = reverse("pontoon.edit_comment")
71116

72-
# a user cannot edit someone else's comment
117+
# A user cannot edit someone else's comment
73118
response = member.client.post(
74119
url,
75120
{"comment_id": comment_a.pk, "content": "edited"},
@@ -93,6 +138,34 @@ def test_edit_comment(member, client, comment_a):
93138
assert comment_a.content == "edited content"
94139

95140

141+
@pytest.mark.django_db
142+
def test_edit_comment_sanitizes_html(member, comment_a):
143+
url = reverse("pontoon.edit_comment")
144+
145+
# Make member the author
146+
comment_a.author = member.user
147+
comment_a.save()
148+
149+
payload = "<img src=x onerror=alert(1)>safe"
150+
151+
response = member.client.post(
152+
url,
153+
{"comment_id": comment_a.pk, "content": payload},
154+
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
155+
)
156+
157+
assert response.status_code == 200
158+
159+
comment_a.refresh_from_db()
160+
161+
# Dangerous part should be removed
162+
assert "onerror" not in comment_a.content
163+
assert "<img" not in comment_a.content
164+
165+
# Safe content should remain
166+
assert "safe" in comment_a.content
167+
168+
96169
@pytest.mark.django_db
97170
def test_delete_comment(member, client, comment_a):
98171
url = reverse("pontoon.delete_comment")

pontoon/base/views.py

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -245,9 +245,7 @@ def entities(request):
245245
return JsonResponse(
246246
{
247247
"status": False,
248-
"message": "{error}".format(
249-
error=form.errors.as_json(escape_html=True)
250-
),
248+
"message": form.errors,
251249
},
252250
status=400,
253251
)
@@ -666,9 +664,7 @@ def add_comment(request):
666664
return JsonResponse(
667665
{
668666
"status": False,
669-
"message": "{error}".format(
670-
error=form.errors.as_json(escape_html=True)
671-
),
667+
"message": form.errors,
672668
},
673669
status=400,
674670
)
@@ -753,33 +749,29 @@ def unpin_comment(request):
753749

754750
@login_required(redirect_field_name="", login_url="/403")
755751
@require_POST
752+
@utils.require_AJAX
756753
@transaction.atomic
757754
def edit_comment(request):
758-
"""Edit a comment"""
759-
comment_id = request.POST.get("comment_id", None)
760-
761-
if not comment_id:
762-
return JsonResponse({"status": False, "message": "Bad Request"}, status=400)
755+
"""Edit a comment."""
756+
form = forms.EditCommentForm(request.POST)
757+
if not form.is_valid():
758+
return JsonResponse(
759+
{
760+
"status": False,
761+
"message": form.errors,
762+
},
763+
status=400,
764+
)
763765

764-
comment = get_object_or_404(Comment, id=comment_id)
766+
comment = get_object_or_404(Comment, id=form.cleaned_data["comment_id"])
765767

766768
if request.user != comment.author:
767769
return JsonResponse(
768770
{"status": False, "message": "Forbidden: You can't edit this comment"},
769771
status=403,
770772
)
771773

772-
content = request.POST.get("content", None)
773-
774-
if not content:
775-
return JsonResponse(
776-
{
777-
"status": False,
778-
"message": "Bad Request",
779-
},
780-
status=400,
781-
)
782-
comment.content = content
774+
comment.content = form.cleaned_data["content"]
783775
comment.save()
784776

785777
return JsonResponse({"status": True})

0 commit comments

Comments
 (0)