Skip to content

Commit c514bee

Browse files
committed
expand homoglyph protection to UserProfile.display_name and Collection.name
1 parent 11303ae commit c514bee

File tree

6 files changed

+150
-65
lines changed

6 files changed

+150
-65
lines changed

src/olympia/accounts/serializers.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django import forms
12
from django.conf import settings
23
from django.utils.translation import gettext
34

@@ -14,6 +15,7 @@
1415
subscribe_newsletter,
1516
unsubscribe_newsletter,
1617
urlparams,
18+
validate_name,
1719
)
1820
from olympia.api.fields import HttpHttpsOnlyURLField
1921
from olympia.api.serializers import AMOModelSerializer, SiteStatusSerializer
@@ -153,11 +155,16 @@ def validate_biography(self, value):
153155
return value
154156

155157
def validate_display_name(self, value):
156-
if DeniedName.blocked(value):
157-
raise serializers.ValidationError(
158-
gettext('This display name cannot be used.')
159-
)
160-
return value
158+
error_msg = gettext('This display name cannot be used.')
159+
160+
def check_function(normalized_name, variant):
161+
if DeniedName.blocked(variant):
162+
raise serializers.ValidationError(error_msg)
163+
164+
try:
165+
return validate_name(value, check_function, error_msg)
166+
except forms.ValidationError as exc:
167+
raise serializers.ValidationError(exc.messages)
161168

162169
def validate_picture_upload(self, value):
163170
image_check = ImageCheck(value)

src/olympia/accounts/tests/test_views.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
)
4646
from olympia.amo.tests.test_helpers import get_uploaded_file
4747
from olympia.api.tests.utils import APIKeyAuthTestMixin
48-
from olympia.users.models import UserNotification, UserProfile
48+
from olympia.users.models import DeniedName, UserNotification, UserProfile
4949
from olympia.users.notifications import (
5050
NOTIFICATIONS_BY_ID,
5151
NOTIFICATIONS_COMBINED,
@@ -1507,6 +1507,30 @@ def test_display_name_validation(self):
15071507
response = self.patch(data={'display_name': 'a' * 50})
15081508
assert response.status_code == 200
15091509

1510+
def test_display_name_denied_name(self):
1511+
DeniedName.objects.create(name='foo')
1512+
self.client.login_api(self.user)
1513+
response = self.patch(data={'display_name': 'foo_thing'})
1514+
assert response.status_code == 400
1515+
assert json.loads(response.content) == {
1516+
'display_name': ['This display name cannot be used.']
1517+
}
1518+
1519+
# homoglyphs don't bypass the validation
1520+
response = self.patch(data={'display_name': 'fѻѺ_thing'})
1521+
assert response.status_code == 400
1522+
assert json.loads(response.content) == {
1523+
'display_name': ['This display name cannot be used.']
1524+
}
1525+
1526+
def test_display_name_too_many_homoglyphs(self):
1527+
self.client.login_api(self.user)
1528+
response = self.patch(data={'display_name': 'l' * 17})
1529+
assert response.status_code == 400
1530+
assert json.loads(response.content) == {
1531+
'display_name': ['This display name cannot be used.']
1532+
}
1533+
15101534
@override_settings(EXTERNAL_SITE_URL='https://example.org')
15111535
def test_validate_homepage(self):
15121536
self.client.login_api(self.user)

src/olympia/addons/utils.py

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@
88

99
from olympia import amo, core
1010
from olympia.access.acl import action_allowed_for
11-
from olympia.amo.utils import (
12-
generate_lowercase_homoglyphs_variants_for_string,
13-
normalize_string_for_name_checks,
14-
verify_condition_with_locales,
15-
)
11+
from olympia.amo.utils import validate_name
1612
from olympia.translations.models import Translation
1713

1814

@@ -39,42 +35,25 @@ def validate_addon_name(name, user, *, form=None):
3935
and action_allowed_for(user, amo.permissions.TRADEMARK_BYPASS)
4036
)
4137

42-
def _check(name):
43-
name_without_punctuation = normalize_string_for_name_checks(
44-
name, categories_to_strip=('P')
45-
).lower()
46-
47-
variants = generate_lowercase_homoglyphs_variants_for_string(
48-
normalize_string_for_name_checks(name)
49-
)
50-
51-
for index, variant in enumerate(variants):
52-
if index > 65535:
53-
# index > 65535 means over 16 confusable characters with 2
54-
# variants each. That name is likely suspicious, and it's too
55-
# expensive to continue anyway. Reject it immediately.
56-
raise forms.ValidationError(gettext('This name cannot be used.'))
57-
58-
if skip_trademark_check:
59-
continue
60-
61-
for symbol in amo.MOZILLA_TRADEMARK_SYMBOLS:
62-
symbol_count = variant.count(symbol)
63-
violates_trademark = symbol_count > 1 or (
64-
symbol_count >= 1
65-
# 'XXX for Mozilla' or 'XXX for Firefox' is allowed.
66-
and not name_without_punctuation.endswith(f' for {symbol}')
38+
def check_function(normalized_name, variant):
39+
if skip_trademark_check:
40+
return
41+
42+
for symbol in amo.MOZILLA_TRADEMARK_SYMBOLS:
43+
symbol_count = variant.count(symbol)
44+
violates_trademark = symbol_count > 1 or (
45+
symbol_count >= 1
46+
# 'XXX for Mozilla' or 'XXX for Firefox' is allowed.
47+
and not normalized_name.endswith(f' for {symbol}')
48+
)
49+
50+
if violates_trademark:
51+
msg = gettext(
52+
'Add-on names cannot contain the Mozilla or Firefox trademarks.'
6753
)
54+
raise forms.ValidationError(msg)
6855

69-
if violates_trademark:
70-
msg = gettext(
71-
'Add-on names cannot contain the Mozilla or Firefox trademarks.'
72-
)
73-
raise forms.ValidationError(msg)
74-
75-
verify_condition_with_locales(
76-
value=name, check_func=_check, form=form, field_name='name'
77-
)
56+
validate_name(name, check_function, gettext('This name cannot be used.'), form=form)
7857

7958
return name
8059

src/olympia/amo/utils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,31 @@ def verify_condition_with_locales(*, value, check_func, form=None, field_name=No
11731173
raise
11741174

11751175

1176+
def validate_name(name, check_function, error_message, *, form=None):
1177+
def variant_checker(name):
1178+
normalized_name = normalize_string_for_name_checks(
1179+
name, categories_to_strip=('P')
1180+
).lower()
1181+
1182+
variants = generate_lowercase_homoglyphs_variants_for_string(
1183+
normalize_string_for_name_checks(name)
1184+
)
1185+
1186+
for index, variant in enumerate(variants):
1187+
if index > 65535:
1188+
# index > 65535 means over 16 confusable characters with 2
1189+
# variants each. That name is likely suspicious, and it's too
1190+
# expensive to continue anyway. Reject it immediately.
1191+
raise forms.ValidationError(error_message)
1192+
1193+
check_function(normalized_name, variant)
1194+
1195+
verify_condition_with_locales(
1196+
value=name, check_func=variant_checker, form=form, field_name='name'
1197+
)
1198+
return name
1199+
1200+
11761201
def verify_no_urls(value, *, form=None, field_name=None):
11771202
def _check(value):
11781203
if has_urls(value):

src/olympia/bandwagon/serializers.py

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django import forms
12
from django.utils.translation import gettext, gettext_lazy as _
23

34
from rest_framework import serializers
@@ -9,7 +10,7 @@
910
from olympia.addons.models import Addon
1011
from olympia.addons.serializers import AddonSerializer
1112
from olympia.amo.templatetags.jinja_helpers import absolutify
12-
from olympia.amo.utils import clean_nl, has_urls, slug_validator
13+
from olympia.amo.utils import clean_nl, has_urls, slug_validator, validate_name
1314
from olympia.api.fields import (
1415
SlugOrPrimaryKeyRelatedField,
1516
SplitField,
@@ -80,17 +81,18 @@ def get_url(self, obj):
8081
return absolutify(obj.get_url_path())
8182

8283
def validate_name(self, value):
83-
# if we have a localised dict of values validate them all.
84-
if isinstance(value, dict):
85-
return {
86-
locale: self.validate_name(sub_value)
87-
for locale, sub_value in value.items()
88-
}
89-
if DeniedName.blocked(value) and not can_use_denied_names(
90-
self.context.get('request')
91-
):
92-
raise serializers.ValidationError(gettext('This name cannot be used.'))
93-
return value
84+
error_msg = gettext('This name cannot be used.')
85+
86+
def check_function(normalized_name, variant):
87+
if not can_use_denied_names(
88+
self.context.get('request')
89+
) and DeniedName.blocked(variant):
90+
raise serializers.ValidationError(error_msg)
91+
92+
try:
93+
return validate_name(value, check_function, error_msg)
94+
except forms.ValidationError as exc:
95+
raise serializers.ValidationError(exc.messages)
9496

9597
def validate_description(self, value):
9698
if has_urls(clean_nl(str(value))):
@@ -106,14 +108,18 @@ def validate_slug(self, value):
106108
'numbers, underscores or hyphens.'
107109
),
108110
)
109-
if DeniedName.blocked(value) and not can_use_denied_names(
110-
self.context.get('request')
111-
):
112-
raise serializers.ValidationError(
113-
gettext('This custom URL cannot be used.')
114-
)
115-
116-
return value
111+
error_msg = gettext('This custom URL cannot be used.')
112+
113+
def check_function(normalized_name, variant):
114+
if not can_use_denied_names(
115+
self.context.get('request')
116+
) and DeniedName.blocked(variant):
117+
raise serializers.ValidationError(error_msg)
118+
119+
try:
120+
return validate_name(value, check_function, error_msg)
121+
except forms.ValidationError as exc:
122+
raise serializers.ValidationError(exc.messages)
117123

118124
def create(self, validated_data):
119125
validated_data['author'] = self.context['request'].user

src/olympia/bandwagon/tests/test_views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,12 +465,32 @@ def test_name_denied_name(self):
465465
response = self.send(data=data)
466466
assert response.status_code == 400
467467
assert json.loads(response.content) == {'name': ['This name cannot be used.']}
468+
469+
# homoglyphs don't bypass the validation
470+
data.update(name={'en-US': 'fѻѺ thing'})
471+
response = self.send(data=data)
472+
assert response.status_code == 400
473+
assert json.loads(response.content) == {'name': ['This name cannot be used.']}
474+
468475
# But you can if you have the correct permission
469476
self.grant_permission(self.user, 'Collections:Edit')
470477
self.client.login_api(self.user)
471478
response = self.send(data=data)
472479
assert response.status_code in (200, 201)
473480

481+
def test_name_too_many_homoglyphs(self):
482+
self.client.login_api(self.user)
483+
data = dict(self.data)
484+
data.update(name={'en-US': 'l' * 17})
485+
response = self.send(data=data)
486+
assert response.status_code == 400
487+
assert json.loads(response.content) == {'name': ['This name cannot be used.']}
488+
# even with permission
489+
self.grant_permission(self.user, 'Collections:Edit')
490+
self.client.login_api(self.user)
491+
response = self.send(data=data)
492+
assert response.status_code == 400
493+
474494
def test_slug_denied_name(self):
475495
DeniedName.objects.create(name='foo')
476496
self.client.login_api(self.user)
@@ -481,12 +501,36 @@ def test_slug_denied_name(self):
481501
assert json.loads(response.content) == {
482502
'slug': ['This custom URL cannot be used.']
483503
}
504+
505+
# homoglyphs don't bypass the validation
506+
data.update(slug='fѻѺ_thing')
507+
response = self.send(data=data)
508+
assert response.status_code == 400
509+
assert json.loads(response.content) == {
510+
'slug': ['This custom URL cannot be used.']
511+
}
512+
484513
# But you can if you have the correct permission
485514
self.grant_permission(self.user, 'Collections:Edit')
486515
self.client.login_api(self.user)
487516
response = self.send(data=data)
488517
assert response.status_code in (200, 201)
489518

519+
def test_slug_too_many_homoglyphs(self):
520+
self.client.login_api(self.user)
521+
data = dict(self.data)
522+
data.update(slug='l' * 17)
523+
response = self.send(data=data)
524+
assert response.status_code == 400
525+
assert json.loads(response.content) == {
526+
'slug': ['This custom URL cannot be used.']
527+
}
528+
# even with permission
529+
self.grant_permission(self.user, 'Collections:Edit')
530+
self.client.login_api(self.user)
531+
response = self.send(data=data)
532+
assert response.status_code == 400
533+
490534

491535
class TestCollectionViewSetCreate(CollectionViewSetDataMixin, TestCase):
492536
def send(self, url=None, data=None):

0 commit comments

Comments
 (0)