Skip to content

Commit 499a197

Browse files
fix(analytics): rename signal to whatsapp-lite for spoofed UA
Changed from 'signal' to 'whatsapp-lite' since we can't be certain which app is spoofing the WhatsApp User-Agent - could be Signal, or any other app using the same bypass technique. - whatsapp: Verified real WhatsApp (full version like 2.23.18.78) - whatsapp-lite: Simplified UA (could be Signal or other spoofers) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bc507c0 commit 499a197

File tree

3 files changed

+48
-49
lines changed

3 files changed

+48
-49
lines changed

api/analytics.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@
6060
REAL_WHATSAPP_PATTERN = re.compile(r"whatsapp/\d+\.\d+\.\d+", re.IGNORECASE)
6161

6262

63-
def _detect_whatsapp_or_signal(user_agent: str) -> str | None:
64-
"""Distinguish WhatsApp from Signal based on User-Agent format.
63+
def _detect_whatsapp_variant(user_agent: str) -> str | None:
64+
"""Distinguish real WhatsApp from apps spoofing WhatsApp User-Agent.
6565
66-
Signal deliberately uses 'WhatsApp' User-Agent to bypass rate limits on sites like Twitter.
67-
But real WhatsApp includes full version: 'WhatsApp/2.23.18.78 i' (iOS) or 'WhatsApp/2.21.22.23 A' (Android).
68-
Signal sends simpler format: 'WhatsApp' or 'WhatsApp/2'.
66+
Some apps (Signal, others) use 'WhatsApp' User-Agent to bypass rate limits.
67+
Real WhatsApp includes full version: 'WhatsApp/2.23.18.78 i' (iOS) or 'WhatsApp/2.21.22.23 A' (Android).
68+
Spoofers send simpler format: 'WhatsApp' or 'WhatsApp/2'.
6969
7070
Returns:
71-
'whatsapp' for real WhatsApp, 'signal' for Signal-pretending-to-be-WhatsApp, None if neither.
71+
'whatsapp' for verified real WhatsApp, 'whatsapp-lite' for simplified/spoofed UA, None if neither.
7272
"""
7373
ua_lower = user_agent.lower()
7474
if "whatsapp" not in ua_lower:
@@ -78,8 +78,8 @@ def _detect_whatsapp_or_signal(user_agent: str) -> str | None:
7878
if REAL_WHATSAPP_PATTERN.search(user_agent):
7979
return "whatsapp"
8080

81-
# Has "whatsapp" but no full version - likely Signal
82-
return "signal"
81+
# Has "whatsapp" but no full version - could be Signal or other spoofers
82+
return "whatsapp-lite"
8383

8484

8585
def detect_platform(user_agent: str) -> str:
@@ -89,12 +89,12 @@ def detect_platform(user_agent: str) -> str:
8989
user_agent: The User-Agent header value
9090
9191
Returns:
92-
Platform name (e.g., 'twitter', 'whatsapp', 'signal') or 'unknown'
92+
Platform name (e.g., 'twitter', 'whatsapp', 'whatsapp-lite') or 'unknown'
9393
"""
94-
# Special handling for WhatsApp vs Signal (Signal uses WhatsApp User-Agent)
95-
whatsapp_or_signal = _detect_whatsapp_or_signal(user_agent)
96-
if whatsapp_or_signal:
97-
return whatsapp_or_signal
94+
# Special handling for WhatsApp variants (some apps spoof WhatsApp UA)
95+
whatsapp_variant = _detect_whatsapp_variant(user_agent)
96+
if whatsapp_variant:
97+
return whatsapp_variant
9898

9999
ua_lower = user_agent.lower()
100100
for platform, pattern in PLATFORM_PATTERNS.items():

docs/architecture/plausible.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,15 +164,14 @@ Bot requests page → nginx detects bot → SEO proxy serves HTML with og:image
164164

165165
**Fallback**: unknown
166166

167-
#### WhatsApp vs Signal Detection
167+
#### WhatsApp Variant Detection
168168

169-
Signal deliberately uses a WhatsApp User-Agent to bypass rate limits on sites like Twitter ([Issue #10060](https://github.com/signalapp/Signal-Android/issues/10060)). We distinguish them by version format:
169+
Some apps (Signal, others) use a WhatsApp User-Agent to bypass rate limits ([Issue #10060](https://github.com/signalapp/Signal-Android/issues/10060)). We distinguish real WhatsApp from spoofed requests by version format:
170170

171171
| Platform | User-Agent Example | Detection |
172172
|----------|-------------------|-----------|
173-
| WhatsApp (iOS) | `WhatsApp/2.23.18.78 i` | 3+ part version → whatsapp |
174-
| WhatsApp (Android) | `WhatsApp/2.21.22.23 A` | 3+ part version → whatsapp |
175-
| Signal | `WhatsApp` or `WhatsApp/2` | No full version → signal |
173+
| `whatsapp` | `WhatsApp/2.23.18.78 i` | 3+ part version = verified WhatsApp |
174+
| `whatsapp-lite` | `WhatsApp` or `WhatsApp/2` | Simplified UA = Signal or other spoofers |
176175

177176
### API Endpoints
178177

tests/unit/api/test_analytics.py

Lines changed: 31 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import pytest
66

7-
from api.analytics import PLATFORM_PATTERNS, _detect_whatsapp_or_signal, detect_platform, track_og_image
7+
from api.analytics import PLATFORM_PATTERNS, _detect_whatsapp_variant, detect_platform, track_og_image
88

99

1010
class TestDetectPlatform:
@@ -26,17 +26,17 @@ def test_detects_whatsapp_desktop(self) -> None:
2626
"""Should detect real WhatsApp Desktop."""
2727
assert detect_platform("WhatsApp/2.2336.9 N") == "whatsapp"
2828

29-
def test_detects_signal_as_fake_whatsapp(self) -> None:
30-
"""Should detect Signal (which uses fake WhatsApp User-Agent).
29+
def test_detects_whatsapp_lite_for_spoofed_ua(self) -> None:
30+
"""Should detect spoofed WhatsApp User-Agent as whatsapp-lite.
3131
32-
Signal deliberately uses 'WhatsApp' User-Agent to bypass rate limits,
33-
but without full version number like real WhatsApp.
32+
Some apps (Signal, others) use simplified 'WhatsApp' User-Agent to bypass rate limits.
33+
We can't know for sure which app, so we label it 'whatsapp-lite'.
3434
See: https://github.com/signalapp/Signal-Android/issues/10060
3535
"""
36-
# Signal sends simple "WhatsApp" or "WhatsApp/2" without full version
37-
assert detect_platform("WhatsApp") == "signal"
38-
assert detect_platform("WhatsApp/2") == "signal"
39-
assert detect_platform("WhatsApp/2.1") == "signal" # Only 2-part version
36+
# Simplified UA without full version -> whatsapp-lite
37+
assert detect_platform("WhatsApp") == "whatsapp-lite"
38+
assert detect_platform("WhatsApp/2") == "whatsapp-lite"
39+
assert detect_platform("WhatsApp/2.1") == "whatsapp-lite" # Only 2-part version
4040

4141
def test_detects_facebook(self) -> None:
4242
"""Should detect Facebook."""
@@ -80,49 +80,49 @@ def test_case_insensitive(self) -> None:
8080
assert detect_platform("twitterbot/1.0") == "twitter"
8181

8282
def test_all_platforms_have_patterns(self) -> None:
83-
"""Should have 25 platform patterns in dict (whatsapp/signal handled separately)."""
84-
# 27 total platforms: 25 in PLATFORM_PATTERNS + whatsapp + signal (special handling)
83+
"""Should have 25 platform patterns in dict (whatsapp variants handled separately)."""
84+
# 27 total platforms: 25 in PLATFORM_PATTERNS + whatsapp + whatsapp-lite (special handling)
8585
assert len(PLATFORM_PATTERNS) == 25
8686

8787

88-
class TestWhatsAppSignalDetection:
89-
"""Tests for WhatsApp vs Signal detection logic."""
88+
class TestWhatsAppVariantDetection:
89+
"""Tests for WhatsApp variant detection (real vs spoofed)."""
9090

9191
def test_real_whatsapp_ios(self) -> None:
9292
"""Real WhatsApp iOS should return 'whatsapp'."""
93-
assert _detect_whatsapp_or_signal("WhatsApp/2.23.18.78 i") == "whatsapp"
93+
assert _detect_whatsapp_variant("WhatsApp/2.23.18.78 i") == "whatsapp"
9494

9595
def test_real_whatsapp_android(self) -> None:
9696
"""Real WhatsApp Android should return 'whatsapp'."""
97-
assert _detect_whatsapp_or_signal("WhatsApp/2.21.22.23 A") == "whatsapp"
97+
assert _detect_whatsapp_variant("WhatsApp/2.21.22.23 A") == "whatsapp"
9898

9999
def test_real_whatsapp_cfnetwork(self) -> None:
100100
"""Real WhatsApp with CFNetwork should return 'whatsapp'."""
101-
assert _detect_whatsapp_or_signal("WhatsApp/2.18.31.32 CFNetwork/894 Darwin/17.4.0") == "whatsapp"
101+
assert _detect_whatsapp_variant("WhatsApp/2.18.31.32 CFNetwork/894 Darwin/17.4.0") == "whatsapp"
102102

103-
def test_signal_simple(self) -> None:
104-
"""Signal's simple WhatsApp UA should return 'signal'."""
105-
assert _detect_whatsapp_or_signal("WhatsApp") == "signal"
103+
def test_spoofed_simple(self) -> None:
104+
"""Simplified WhatsApp UA should return 'whatsapp-lite'."""
105+
assert _detect_whatsapp_variant("WhatsApp") == "whatsapp-lite"
106106

107-
def test_signal_with_major_version(self) -> None:
108-
"""Signal's WhatsApp/2 should return 'signal'."""
109-
assert _detect_whatsapp_or_signal("WhatsApp/2") == "signal"
107+
def test_spoofed_with_major_version(self) -> None:
108+
"""WhatsApp/2 (no full version) should return 'whatsapp-lite'."""
109+
assert _detect_whatsapp_variant("WhatsApp/2") == "whatsapp-lite"
110110

111-
def test_signal_with_two_part_version(self) -> None:
112-
"""Signal's WhatsApp/2.1 (only 2 parts) should return 'signal'."""
113-
assert _detect_whatsapp_or_signal("WhatsApp/2.1") == "signal"
111+
def test_spoofed_with_two_part_version(self) -> None:
112+
"""WhatsApp/2.1 (only 2 parts) should return 'whatsapp-lite'."""
113+
assert _detect_whatsapp_variant("WhatsApp/2.1") == "whatsapp-lite"
114114

115115
def test_non_whatsapp_returns_none(self) -> None:
116116
"""Non-WhatsApp User-Agent should return None."""
117-
assert _detect_whatsapp_or_signal("Twitterbot/1.0") is None
118-
assert _detect_whatsapp_or_signal("Mozilla/5.0") is None
119-
assert _detect_whatsapp_or_signal("") is None
117+
assert _detect_whatsapp_variant("Twitterbot/1.0") is None
118+
assert _detect_whatsapp_variant("Mozilla/5.0") is None
119+
assert _detect_whatsapp_variant("") is None
120120

121121
def test_case_insensitive(self) -> None:
122122
"""Should handle case-insensitive matching."""
123-
assert _detect_whatsapp_or_signal("WHATSAPP/2.23.18.78") == "whatsapp"
124-
assert _detect_whatsapp_or_signal("whatsapp/2.23.18.78") == "whatsapp"
125-
assert _detect_whatsapp_or_signal("WHATSAPP") == "signal"
123+
assert _detect_whatsapp_variant("WHATSAPP/2.23.18.78") == "whatsapp"
124+
assert _detect_whatsapp_variant("whatsapp/2.23.18.78") == "whatsapp"
125+
assert _detect_whatsapp_variant("WHATSAPP") == "whatsapp-lite"
126126

127127

128128
class TestTrackOgImage:

0 commit comments

Comments
 (0)