Skip to content

Commit 036279c

Browse files
committed
Add filtering to app and org display names
1 parent a8cb303 commit 036279c

File tree

5 files changed

+170
-0
lines changed

5 files changed

+170
-0
lines changed

src/sentry/api/serializers/models/organization.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,13 @@ def validate_name(self, value: str) -> str:
175175
raise serializers.ValidationError(
176176
"Organization name cannot contain URL schemes (e.g. http:// or https://)."
177177
)
178+
179+
from sentry.utils.display_name_filter import check_spam_display_name
180+
181+
spam_error = check_spam_display_name(value)
182+
if spam_error:
183+
raise serializers.ValidationError(spam_error)
184+
178185
return value
179186

180187
def validate_slug(self, value: str) -> str:

src/sentry/sentry_apps/api/parsers/sentry_app.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ def validate_name(self, value):
164164
max_length = 64 - UUID_CHARS_IN_SLUG - 1 # -1 comes from the - before the UUID bit
165165
if len(value) > max_length:
166166
raise ValidationError("Cannot exceed %d characters" % max_length)
167+
168+
from sentry.utils.display_name_filter import check_spam_display_name
169+
170+
spam_error = check_spam_display_name(value)
171+
if spam_error:
172+
raise ValidationError(spam_error)
173+
167174
return value
168175

169176
def validate_allowedOrigins(self, value):
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Callable
4+
5+
CURRENCY_SIGNALS: list[str] = [
6+
"$",
7+
"\U0001f4b2", # 💲 Heavy Dollar Sign
8+
"\U0001f4b0", # 💰 Money Bag
9+
"\U0001f4b5", # 💵 Dollar Banknote
10+
"\U0001f48e", # 💎 Gem Stone
11+
"\U0001fa99", # 🪙 Coin
12+
"btc",
13+
"eth",
14+
"usdt",
15+
"crypto",
16+
"compensation",
17+
"bitcoin",
18+
"ethereum",
19+
"litecoin",
20+
"ltc",
21+
"xrp",
22+
"doge",
23+
"dogecoin",
24+
"bnb",
25+
"solana",
26+
"sol",
27+
"airdrop",
28+
]
29+
30+
CTA_VERBS: list[str] = ["click", "claim", "collect", "withdraw", "act", "pay"]
31+
CTA_URGENCY: list[str] = ["now", "here", "your", "link"]
32+
33+
SHORT_URL_SIGNALS: list[str] = [
34+
"2g.tel",
35+
"bit.ly",
36+
"t.co",
37+
"tinyurl",
38+
"rb.gy",
39+
"cutt.ly",
40+
"shorturl",
41+
"is.gd",
42+
"v.gd",
43+
"ow.ly",
44+
"bl.ink",
45+
]
46+
47+
48+
def _has_any(lowered: str, signals: list[str]) -> bool:
49+
return any(s in lowered for s in signals)
50+
51+
52+
def _has_cta(lowered: str) -> bool:
53+
return _has_any(lowered, CTA_VERBS) and _has_any(lowered, CTA_URGENCY)
54+
55+
56+
_CATEGORIES: list[tuple[str, Callable[[str], bool]]] = [
57+
("cryptocurrency terminology", lambda val: _has_any(val, CURRENCY_SIGNALS)),
58+
("call-to-action phrases", _has_cta),
59+
("URL shortener domains", lambda val: _has_any(val, SHORT_URL_SIGNALS)),
60+
]
61+
62+
63+
def check_spam_display_name(name: str) -> str | None:
64+
"""Check if a user-controlled display name matches known spam patterns.
65+
66+
Returns a user-facing error string listing the matched categories
67+
if 2+ distinct signal categories fire, or None if the name is clean.
68+
"""
69+
lowered = name.lower()
70+
matched_labels: list[str] = [label for label, check in _CATEGORIES if check(lowered)]
71+
if len(matched_labels) >= 2:
72+
joined = " and ".join(matched_labels)
73+
return f"This name contains disallowed content ({joined}). Please choose a different name."
74+
return None

tests/sentry/core/endpoints/test_organization_index.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,19 @@ def test_name_with_url_scheme_rejected(self) -> None:
272272
)
273273
self.get_error_response(name="http://evil.com", slug="legit-slug-2", status_code=400)
274274

275+
def test_name_with_spam_signals_rejected(self) -> None:
276+
response = self.get_error_response(
277+
name="Win $50 ETH bit.ly/offer Claim Now",
278+
slug="spam-org",
279+
status_code=400,
280+
)
281+
assert "disallowed content" in str(response.data)
282+
283+
def test_name_with_single_signal_allowed(self) -> None:
284+
response = self.get_success_response(name="BTC Analytics", slug="btc-analytics")
285+
org = Organization.objects.get(id=response.data["id"])
286+
assert org.name == "BTC Analytics"
287+
275288
def test_name_with_periods_allowed(self) -> None:
276289
response = self.get_success_response(name="Acme Inc.", slug="acme-inc")
277290
org = Organization.objects.get(id=response.data["id"])
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from sentry.utils.display_name_filter import check_spam_display_name
2+
3+
4+
class TestCheckSpamDisplayName:
5+
def test_clean_name_passes(self) -> None:
6+
assert check_spam_display_name("My Company Inc.") is None
7+
8+
def test_single_currency_signal_passes(self) -> None:
9+
assert check_spam_display_name("BTC Analytics") is None
10+
11+
def test_single_cta_verb_passes(self) -> None:
12+
assert check_spam_display_name("Click Studios") is None
13+
14+
def test_single_cta_urgency_passes(self) -> None:
15+
assert check_spam_display_name("Do It Now Labs") is None
16+
17+
def test_cta_verb_plus_urgency_alone_passes(self) -> None:
18+
assert check_spam_display_name("Click Here Studios") is None
19+
20+
def test_single_shorturl_signal_passes(self) -> None:
21+
assert check_spam_display_name("bit.ly team") is None
22+
23+
def test_currency_plus_cta_rejected(self) -> None:
24+
result = check_spam_display_name("Free BTC - Click Here")
25+
assert result is not None
26+
assert "cryptocurrency terminology" in result
27+
assert "call-to-action phrases" in result
28+
29+
def test_currency_plus_shorturl_rejected(self) -> None:
30+
result = check_spam_display_name("Earn $100 via 2g.tel/promo")
31+
assert result is not None
32+
assert "cryptocurrency terminology" in result
33+
assert "URL shortener domains" in result
34+
35+
def test_cta_plus_shorturl_rejected(self) -> None:
36+
result = check_spam_display_name("Click Here: bit.ly/free")
37+
assert result is not None
38+
assert "call-to-action phrases" in result
39+
assert "URL shortener domains" in result
40+
41+
def test_all_three_categories_rejected(self) -> None:
42+
result = check_spam_display_name("Win $50 ETH bit.ly/offer Claim Now")
43+
assert result is not None
44+
45+
def test_case_insensitive(self) -> None:
46+
result = check_spam_display_name("FREE BTC - CLICK HERE")
47+
assert result is not None
48+
49+
def test_currency_emoji_detected(self) -> None:
50+
result = check_spam_display_name(
51+
"\U0001f4b2Compensation Btc: 2g.tel/x Click Your Pay Link."
52+
)
53+
assert result is not None
54+
55+
def test_single_currency_emoji_passes(self) -> None:
56+
assert check_spam_display_name("My \U0001f4b0 Company") is None
57+
58+
def test_cta_novel_combo_rejected(self) -> None:
59+
result = check_spam_display_name("Withdraw Now - Free BTC")
60+
assert result is not None
61+
62+
def test_empty_string_passes(self) -> None:
63+
assert check_spam_display_name("") is None
64+
65+
def test_error_message_format(self) -> None:
66+
result = check_spam_display_name("Free BTC - Click Here")
67+
assert result is not None
68+
assert "disallowed content" in result
69+
assert "Please choose a different name" in result

0 commit comments

Comments
 (0)