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"