diff --git a/package.json b/package.json
index e167285a30..51b8ce3a32 100644
--- a/package.json
+++ b/package.json
@@ -162,6 +162,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 b28f66f839..18f5f79289 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 a35a095371..76e68a2510 100644
--- a/src/components/Layout/Header/index.tsx
+++ b/src/components/Layout/Header/index.tsx
@@ -186,6 +186,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 homoglyphs,
+ // and only allow using __at most__ one of them.
+ // Legitimate domains in the wild 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 707aad7fb0..8ed85a49bc 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/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index b90f2ecd64..9151acca28 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -702,7 +702,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 cd11611a86..11e8d114a7 100644
--- a/src/view/com/profile/ProfileSubpageHeader.tsx
+++ b/src/view/com/profile/ProfileSubpageHeader.tsx
@@ -154,7 +154,7 @@ export function ProfileSubpageHeader({
by{' '}
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index a927526ad3..eb6b6f62e8 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -889,7 +889,11 @@ const AboutSection = React.forwardRef(
User list by{' '}
@@ -901,7 +905,7 @@ const AboutSection = React.forwardRef(
Moderation list by{' '}
diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx
index 2780944f11..956ff15d65 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 beea8f136b..b1e600faf7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3245,7 +3245,7 @@
"@babel/parser" "^7.25.9"
"@babel/types" "^7.25.9"
-"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3":
+"@babel/traverse--for-generate-function-map@npm:@babel/traverse@^7.25.3", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
version "7.25.9"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
@@ -3306,19 +3306,6 @@
debug "^4.3.1"
globals "^11.1.0"
-"@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9":
- version "7.25.9"
- resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84"
- integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==
- dependencies:
- "@babel/code-frame" "^7.25.9"
- "@babel/generator" "^7.25.9"
- "@babel/parser" "^7.25.9"
- "@babel/template" "^7.25.9"
- "@babel/types" "^7.25.9"
- debug "^4.3.1"
- globals "^11.1.0"
-
"@babel/types@^7.0.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.22.10", "@babel/types@^7.22.5", "@babel/types@^7.3.3", "@babel/types@^7.4.4":
version "7.22.10"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.10.tgz#4a9e76446048f2c66982d1a989dd12b8a2d2dc03"
@@ -15761,6 +15748,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"
@@ -17498,16 +17490,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17607,7 +17590,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17621,13 +17604,6 @@ strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -18910,7 +18886,7 @@ wordwrap@^1.0.0:
resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -18928,15 +18904,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"