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"