Skip to content

Commit

Permalink
Add support for punycode/IDN handles + sanitisation for homograph att…
Browse files Browse the repository at this point in the history
…acks
  • Loading branch information
drash-course committed Dec 10, 2024
1 parent f34e8d8 commit d287c7a
Show file tree
Hide file tree
Showing 19 changed files with 170 additions and 69 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions src/components/FeedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function TitleAndByline({
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
<Trans>Feed by {sanitizeHandle(creator.handle, '@')}</Trans>
<Trans>Feed by {sanitizeHandle(creator.handle, '@', false)}</Trans>
</Text>
)}
</View>
Expand Down Expand Up @@ -174,14 +174,16 @@ export function Description({
description,
...rest
}: {description?: string} & Partial<RichTextProps>) {
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 <RichText value={rt} style={[a.leading_snug]} disableLinks {...rest} />
if (!rtMemo) return null
return (
<RichText value={rtMemo} style={[a.leading_snug]} disableLinks {...rest} />
)
}

export function DescriptionPlaceholder() {
Expand Down
1 change: 1 addition & 0 deletions src/components/Layout/Header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function SubtitleText({children}: {children: React.ReactNode}) {
const align = useContext(AlignmentContext)
return (
<Text
emoji
style={[
a.text_sm,
a.leading_snug,
Expand Down
10 changes: 7 additions & 3 deletions src/components/StarterPack/Wizard/WizardListCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function WizardProfileCard({
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
: `@${sanitizeHandle(profile.handle)}`
: sanitizeHandle(profile.handle, '@', false)

const onPress = () => {
if (disabled) return
Expand All @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
8 changes: 5 additions & 3 deletions src/components/moderation/LabelsOnMeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View
Expand Down Expand Up @@ -199,7 +199,7 @@ function AppealForm({
const isAccountReport = 'did' in subject
const agent = useAgent()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src

const {mutate, isPending} = useMutation({
Expand Down Expand Up @@ -263,7 +263,9 @@ function AppealForm({
label={_(msg`Text input field`)}
placeholder={_(
msg`Please explain why you think this label was incorrectly applied by ${
labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src
labeler
? sanitizeHandle(labeler.creator.handle, '@', false)
: label.src
}`,
)}
value={details}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/generate-starterpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export function useGenerateStarterPackMutation({
const displayName = enforceLen(
profile.displayName
? sanitizeDisplayName(profile.displayName)
: `@${sanitizeHandle(profile.handle)}`,
: sanitizeHandle(profile.handle, '@'),
25,
true,
)
Expand Down
124 changes: 120 additions & 4 deletions src/lib/strings/handles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Regex from the go implementation
// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
import {decode} from 'punycode'

import {forceLTR} from '#/lib/strings/bidi'

const VALIDATE_REGEX =
Expand All @@ -23,10 +25,124 @@ export function isInvalidHandle(handle: string): boolean {
return handle === 'handle.invalid'
}

export function sanitizeHandle(handle: string, prefix = ''): string {
return isInvalidHandle(handle)
? '⚠Invalid Handle'
: forceLTR(`${prefix}${handle}`)
export function sanitizeHandle(
asciiHandle: string,
prefix = '',
allowUnicode = true,
): string {
if (isInvalidHandle(asciiHandle)) {
return '⚠Invalid Handle'
}
const handle = allowUnicode
? toSanitizedUnicodeHandle(asciiHandle)
: asciiHandle
return forceLTR(`${prefix}${handle}`)
}

export function toSanitizedUnicodeHandle(asciiHandle: string): string {
return asciiHandle
.split('.')
.map((label: string) => {
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 {
Expand Down
2 changes: 1 addition & 1 deletion src/screens/List/ListHiddenScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function ListHiddenScreen({
]}>
<Trans>
This list - created by{' '}
<Text style={[a.text_md, !isOwner && a.font_bold]}>
<Text emoji style={[a.text_md, !isOwner && a.font_bold]}>
{isOwner
? _(msg`you`)
: sanitizeHandle(list.creator.handle, '@')}
Expand Down
6 changes: 4 additions & 2 deletions src/screens/Profile/Header/Handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)}`}
</Text>
</View>
)
Expand Down
1 change: 1 addition & 0 deletions src/view/com/composer/text-input/mobile/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function Autocomplete({
a.text_right,
{maxWidth: '50%'},
]}
emoji
numberOfLines={1}>
{sanitizeHandle(item.handle, '@')}
</Text>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/composer/text-input/web/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
{displayName}
</Text>
</View>
<Text type="xs" style={pal.textLight} numberOfLines={1}>
<Text type="xs" style={pal.textLight} emoji numberOfLines={1}>
{sanitizeHandle(item.handle, '@')}
</Text>
</Pressable>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/feeds/FeedSourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ export function FeedSourceCardLoaded({
<Text emoji style={[pal.text, s.bold]} numberOfLines={1}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={1}>
<Text emoji style={[pal.textLight]} numberOfLines={1}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
Expand Down
3 changes: 2 additions & 1 deletion src/view/com/modals/ListAddRemoveUsers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,13 +248,14 @@ function UserResult({
<Text
type="lg"
style={[s.bold, pal.text]}
emoji
numberOfLines={1}
lineHeight={1.2}>
{sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" emoji style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
{!!profile.viewer?.followedBy && <View style={s.flexRow} />}
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/modals/UserAddRemoveLists.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function ListItem({
lineHeight={1.2}>
{sanitizeDisplayName(list.name)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text type="md" emoji style={[pal.textLight]} numberOfLines={1}>
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
(list.creator.did === currentAccount?.did ? (
<Trans>User list by you</Trans>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/notifications/FeedItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ function ExpandedAuthorsList({
author.profile.displayName || author.profile.handle,
)}
</Text>{' '}
<Text style={[pal.textLight]} lineHeight={1.2}>
<Text emoji style={[pal.textLight]} lineHeight={1.2}>
{sanitizeHandle(author.profile.handle, '@')}
</Text>
</Text>
Expand Down
2 changes: 1 addition & 1 deletion src/view/com/profile/ProfileSubpageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ export function ProfileSubpageHeader({
<Trans>
by{' '}
<TextLink
text={sanitizeHandle(creator.handle, '@')}
text={sanitizeHandle(creator.handle, '@', false)}
href={makeProfileLink(creator)}
style={pal.textLight}
/>
Expand Down
8 changes: 6 additions & 2 deletions src/view/screens/ProfileList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,11 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
<Trans>
User list by{' '}
<TextLink
text={sanitizeHandle(list.creator.handle || '', '@')}
text={sanitizeHandle(
list.creator.handle || '',
'@',
false,
)}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
Expand All @@ -901,7 +905,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
<Trans>
Moderation list by{' '}
<TextLink
text={sanitizeHandle(list.creator.handle || '', '@')}
text={sanitizeHandle(list.creator.handle || '', '@', false)}
href={makeProfileLink(list.creator)}
style={pal.textLight}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/view/shell/desktop/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ let SearchProfileCard = ({
moderation.ui('displayName'),
)}
</Text>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
<Text emoji type="md" style={[pal.textLight]} numberOfLines={1}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
Expand Down
Loading

0 comments on commit d287c7a

Please sign in to comment.