diff --git a/package.json b/package.json
index ff2223b489..b0cb2f0561 100644
--- a/package.json
+++ b/package.json
@@ -164,6 +164,7 @@
"patch-package": "^6.5.1",
"postinstall-postinstall": "^2.1.0",
"psl": "^1.9.0",
+ "punycode": "2.3.1",
"react": "18.2.0",
"react-compiler-runtime": "19.0.0-beta-a7bf2bd-20241110",
"react-dom": "^18.2.0",
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index de94d7e196..b37474990b 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -131,7 +131,7 @@ export function TitleAndByline({
- Feed by {sanitizeHandle(creator.handle, '@')}
+ Feed by {sanitizeHandle(creator.handle, '@', false)}
)}
@@ -174,14 +174,16 @@ export function Description({
description,
...rest
}: {description?: string} & Partial) {
- const rt = React.useMemo(() => {
+ const rtMemo = React.useMemo(() => {
if (!description) return
const rt = new RichTextApi({text: description || ''})
rt.detectFacetsWithoutResolution()
return rt
}, [description])
- if (!rt) return null
- return
+ if (!rtMemo) return null
+ return (
+
+ )
}
export function DescriptionPlaceholder() {
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
index 16b484cea9..3461d788d3 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -187,6 +187,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
const align = useContext(AlignmentContext)
return (
{
if (disabled) return
@@ -155,7 +155,7 @@ export function WizardProfileCard({
type="user"
btnType={btnType}
displayName={displayName}
- subtitle={`@${sanitizeHandle(profile.handle)}`}
+ subtitle={sanitizeHandle(profile.handle, '@', false)}
onPress={onPress}
avatar={profile.avatar}
included={included}
@@ -201,7 +201,11 @@ export function WizardFeedCard({
type="algo"
btnType={btnType}
displayName={sanitizeDisplayName(generator.displayName)}
- subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
+ subtitle={`Feed by ${sanitizeHandle(
+ generator.creator.handle,
+ '@',
+ false,
+ )}`}
onPress={onPress}
avatar={generator.avatar}
included={included}
diff --git a/src/components/moderation/LabelsOnMeDialog.tsx b/src/components/moderation/LabelsOnMeDialog.tsx
index 7d1e7d0326..ec9ee67378 100644
--- a/src/components/moderation/LabelsOnMeDialog.tsx
+++ b/src/components/moderation/LabelsOnMeDialog.tsx
@@ -120,7 +120,7 @@ function Label({
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const sourceName = labeler
- ? sanitizeHandle(labeler.creator.handle, '@')
+ ? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src
return (
{
+ if (!label.startsWith('xn--')) {
+ return label // it's not an IDN label
+ }
+ const unicodeLabel = decode(label.slice(4))
+ if (isPossibleHomographAttack(unicodeLabel)) {
+ return label
+ }
+ return unicodeLabel
+ })
+ .join('.')
+}
+
+/// Checks if the given unicode domain label may be subject to an
+/// homograph attack (https://en.wikipedia.org/wiki/IDN_homograph_attack)
+function isPossibleHomographAttack(unicodeLabel: string): boolean {
+ let hasNonRFC2181Characters = false
+ // We check for characters belonging to any script that has problematic homographs,
+ // and only allow using __at most__ one of them.
+ // Detection is based on the observation that legitimate domains in the wild do not mix those scripts.
+ // Note: you can use https://symbl.cc/en/unicode-table/ as reference
+ let hasLatin = false
+ let hasIPA = false
+ let hasGreekOrCoptic = false
+ let hasCyrillic = false
+ let hasArmenian = false
+ let hasNKo = false
+
+ const iterator = unicodeLabel[Symbol.iterator]()
+ let next = iterator.next()
+ while (!next.done) {
+ const codePoint = next.value.codePointAt(0) as number
+
+ if (codePoint <= 0x007f) {
+ // Basic Latin
+ const isLowercase = codePoint >= 0x0061 && codePoint <= 0x007a
+ if (isLowercase) {
+ hasLatin = true
+ } else {
+ const isUppercase = codePoint >= 0x0041 && codePoint <= 0x005a
+ if (isUppercase) {
+ if (codePoint === 0x0049 /* 'I' */) {
+ return true // this is confusable with 'l' in many fonts
+ }
+ hasNonRFC2181Characters = true
+ hasLatin = true
+ } else {
+ const isNumeric = codePoint >= 0x0030 && codePoint <= 0x0039
+ if (!isNumeric && codePoint !== 0x002d /* '-' */) {
+ hasNonRFC2181Characters = true
+ }
+ }
+ }
+ } else {
+ hasNonRFC2181Characters = true
+
+ if (codePoint <= 0x024f) {
+ // Latin-1 Suppl., Latin Extended-A, Latin Extended-B
+ hasLatin = true
+ } else if (codePoint <= 0x02ff) {
+ // IPA Extensions, Spacing Modifier Letters
+ hasIPA = true
+ } else if (codePoint <= 0x036f) {
+ // Combining Diacritical Marks (i.e. accents)
+ // do nothing
+ } else if (codePoint <= 0x03ff) {
+ // Greek and Coptic
+ hasGreekOrCoptic = true
+ } else if (codePoint <= 0x052f) {
+ // Cyrillic, Cyrillic Suppl.
+ hasCyrillic = true
+ } else if (codePoint <= 0x058f) {
+ // Armenian
+ hasArmenian = true
+ } else if (codePoint >= 0x070c && codePoint <= 0x07ff) {
+ // NKo
+ hasNKo = true
+ } else if (codePoint >= 0xd800 && codePoint <= 0xffff) {
+ // Surrogates, Combining and other high abuse potential codepoints.
+ // These are basically never legitimate parts of a label.
+ return true
+ }
+ }
+ next = iterator.next()
+ }
+
+ if (!hasNonRFC2181Characters) {
+ // The label contains only characters in [-a-z0-9] and may be a valid domain label,
+ // therefore it did not need to be using punycode.
+ // It should be regarded as a possible attack.
+ return true
+ }
+
+ const scripts =
+ Number(hasLatin) +
+ Number(hasIPA) +
+ Number(hasGreekOrCoptic) +
+ Number(hasCyrillic) +
+ Number(hasArmenian) +
+ Number(hasNKo)
+ return scripts > 1 // The label uses more than one confusable script
}
export interface IsValidHandle {
diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx
index a694cbb837..e4b061a305 100644
--- a/src/screens/List/ListHiddenScreen.tsx
+++ b/src/screens/List/ListHiddenScreen.tsx
@@ -135,7 +135,7 @@ export function ListHiddenScreen({
]}>
This list - created by{' '}
-
+
{isOwner
? _(msg`you`)
: sanitizeHandle(list.creator.handle, '@')}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 27b73da70c..128d7ff3b6 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -3,7 +3,7 @@ import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {isInvalidHandle} from '#/lib/strings/handles'
+import {isInvalidHandle, toSanitizedUnicodeHandle} from '#/lib/strings/handles'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {atoms as a, useTheme, web} from '#/alf'
@@ -49,7 +49,9 @@ export function ProfileHeaderHandle({
: [a.text_md, a.leading_snug, t.atoms.text_contrast_medium],
web({wordBreak: 'break-all'}),
]}>
- {invalidHandle ? _(msg`⚠Invalid Handle`) : `@${profile.handle}`}
+ {invalidHandle
+ ? _(msg`⚠Invalid Handle`)
+ : `@${toSanitizedUnicodeHandle(profile.handle)}`}
)
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index 0fda6843b4..cfd90f8431 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -81,6 +81,7 @@ export function Autocomplete({
a.text_right,
{maxWidth: '50%'},
]}
+ emoji
numberOfLines={1}>
{sanitizeHandle(item.handle, '@')}
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index f40c2ee8d9..fa3fdf07c3 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -184,7 +184,7 @@ const MentionList = forwardRef(
{displayName}
-
+
{sanitizeHandle(item.handle, '@')}
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index a591488891..dbf930aa96 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -248,7 +248,7 @@ export function FeedSourceCardLoaded({
{feed.displayName}
-
+
{feed.type === 'feed' ? (
Feed by {sanitizeHandle(feed.creatorHandle, '@')}
) : (
diff --git a/src/view/com/modals/ListAddRemoveUsers.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx
index 5285d4a157..0c14102620 100644
--- a/src/view/com/modals/ListAddRemoveUsers.tsx
+++ b/src/view/com/modals/ListAddRemoveUsers.tsx
@@ -248,13 +248,14 @@ function UserResult({
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
-
+
{sanitizeHandle(profile.handle, '@')}
{!!profile.viewer?.followedBy && }
diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx
index b0b76644f0..dbed317639 100644
--- a/src/view/com/modals/UserAddRemoveLists.tsx
+++ b/src/view/com/modals/UserAddRemoveLists.tsx
@@ -206,7 +206,7 @@ function ListItem({
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
-
+
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
User list by you
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index 1267ce0894..ed2363f767 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -703,7 +703,7 @@ function ExpandedAuthorsList({
author.profile.displayName || author.profile.handle,
)}
{' '}
-
+
{sanitizeHandle(author.profile.handle, '@')}
diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx
index b0cf4d10e4..c19233f446 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -148,7 +148,7 @@ export function ProfileSubpageHeader({
{isLoading || !creator ? (
) : (
-
+
{purpose === 'app.bsky.graph.defs#curatelist' ? (
isOwner ? (
List by you
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index de3ccad39b..322a054edc 100644
--- a/src/view/shell/desktop/Search.tsx
+++ b/src/view/shell/desktop/Search.tsx
@@ -136,7 +136,7 @@ let SearchProfileCard = ({
moderation.ui('displayName'),
)}
-
+
{sanitizeHandle(profile.handle, '@')}
diff --git a/yarn.lock b/yarn.lock
index 61ed0f66c7..10fee56012 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15720,6 +15720,11 @@ punycode.js@^2.3.1:
resolved "https://registry.yarnpkg.com/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+punycode@2.3.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+ integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+
punycode@^2.1.0, punycode@^2.1.1:
version "2.3.0"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f"