Harden custom-server login: validate input, resolve PDS on submit, truncate hosts

- Validate the custom-server address (reject unparseable/invalid hosts) and gate the dialog's Done button on it; truncate + cap the recent-server shortcuts
- Resolve the host on submit (best-effort, timeout-bounded, falls back to the default) so a fast submit / password-manager autofill can't beat the background lookup
- Middle-truncate displayed hosts so long/unparseable values can't blow out the layout; show a spinner while resolving
This commit is contained in:
Alex Benzer
2026-06-19 11:48:05 -07:00
parent 4aea3b5391
commit d4bb3b907a
3 changed files with 189 additions and 48 deletions
+87 -28
View File
@@ -5,6 +5,7 @@ import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {BSKY_SERVICE} from '#/lib/constants' import {BSKY_SERVICE} from '#/lib/constants'
import {enforceLen} from '#/lib/strings/helpers'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
@@ -20,6 +21,40 @@ import {useAnalytics} from '#/analytics'
type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom' type SegmentedControlOptions = typeof BSKY_SERVICE | 'custom'
// Max recent-server shortcuts to keep/show, and the max label length before a
// chip is middle-truncated so it can't overflow the dialog.
const MAX_PDS_HISTORY = 5
const MAX_PDS_LABEL_LEN = 28
/**
* Adds a scheme if the user omitted one. `localhost` defaults to http, anything
* else to https.
*/
function normalizeServerUrl(raw: string): string {
const url = raw.trim().toLowerCase()
if (!url || url.startsWith('http://') || url.startsWith('https://')) {
return url
}
if (url === 'localhost' || url.startsWith('localhost:')) {
return `http://${url}`
}
return `https://${url}`
}
/**
* A custom server address is valid if it parses as a URL with a hostname that
* is either `localhost` or a dotted domain. This rejects garbage like
* `localhost:2583asd` (invalid port) or a bare word with no TLD.
*/
function isValidServerUrl(raw: string): boolean {
try {
const {hostname} = new URL(normalizeServerUrl(raw))
return hostname === 'localhost' || hostname.includes('.')
} catch {
return false
}
}
export function ServerInputDialog({ export function ServerInputDialog({
control, control,
onSelect, onSelect,
@@ -100,6 +135,7 @@ function DialogInner({
const {accounts} = useSession() const {accounts} = useSession()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const [customAddress, setCustomAddress] = useState(initialCustomAddress) const [customAddress, setCustomAddress] = useState(initialCustomAddress)
const [validationError, setValidationError] = useState('')
const [pdsAddressHistory, setPdsAddressHistory] = useState<string[]>( const [pdsAddressHistory, setPdsAddressHistory] = useState<string[]>(
persisted.get('pdsAddressHistory') || [], persisted.get('pdsAddressHistory') || [],
) )
@@ -108,31 +144,24 @@ function DialogInner({
formRef, formRef,
() => ({ () => ({
getFormState: () => { getFormState: () => {
let url if (fixedOption !== 'custom') {
if (fixedOption === 'custom') { return fixedOption
url = customAddress.trim().toLowerCase()
if (!url) {
return null
}
} else {
url = fixedOption
} }
if (!url.startsWith('http://') && !url.startsWith('https://')) { // Guard against the dialog being dismissed (backdrop, escape, drag)
if (url === 'localhost' || url.startsWith('localhost:')) { // with an empty or invalid address - don't propagate garbage.
url = `http://${url}` if (!customAddress.trim() || !isValidServerUrl(customAddress)) {
} else { return null
url = `https://${url}`
}
} }
const url = normalizeServerUrl(customAddress)
if (fixedOption === 'custom') { if (!pdsAddressHistory.includes(url)) {
if (!pdsAddressHistory.includes(url)) { const newHistory = [
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)] url,
setPdsAddressHistory(newHistory) // Prune any legacy invalid entries while we're writing.
persisted.write('pdsAddressHistory', newHistory) ...pdsAddressHistory.filter(isValidServerUrl),
} ].slice(0, MAX_PDS_HISTORY)
setPdsAddressHistory(newHistory)
persisted.write('pdsAddressHistory', newHistory)
} }
return url return url
}, },
}), }),
@@ -141,6 +170,12 @@ function DialogInner({
const isFirstTimeUser = accounts.length === 0 const isFirstTimeUser = accounts.length === 0
// Drop legacy/invalid entries (history predates input validation) and cap the
// count so the shortcuts can't overflow the dialog.
const recentServers = pdsAddressHistory
.filter(isValidServerUrl)
.slice(0, MAX_PDS_HISTORY)
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
@@ -196,21 +231,29 @@ function DialogInner({
<TextField.LabelText nativeID="address-input-label"> <TextField.LabelText nativeID="address-input-label">
<Trans>Server address</Trans> <Trans>Server address</Trans>
</TextField.LabelText> </TextField.LabelText>
<TextField.Root> <TextField.Root isInvalid={!!validationError}>
<TextField.Icon icon={Globe} /> <TextField.Icon icon={Globe} />
<Dialog.Input <Dialog.Input
testID="customServerTextInput" testID="customServerTextInput"
value={customAddress} value={customAddress}
onChangeText={setCustomAddress} onChangeText={v => {
setCustomAddress(v)
if (validationError) setValidationError('')
}}
label="my-server.com" label="my-server.com"
accessibilityLabelledBy="address-input-label" accessibilityLabelledBy="address-input-label"
autoCapitalize="none" autoCapitalize="none"
keyboardType="url" keyboardType="url"
/> />
</TextField.Root> </TextField.Root>
{pdsAddressHistory.length > 0 && ( {validationError ? (
<View style={[a.mt_xs]}>
<Admonition type="error">{validationError}</Admonition>
</View>
) : null}
{recentServers.length > 0 && (
<View style={[a.flex_row, a.flex_wrap, a.mt_xs]}> <View style={[a.flex_row, a.flex_wrap, a.mt_xs]}>
{pdsAddressHistory.map(uri => ( {recentServers.map(uri => (
<Button <Button
key={uri} key={uri}
variant="ghost" variant="ghost"
@@ -218,7 +261,9 @@ function DialogInner({
label={uri} label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]} style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}> onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText> <ButtonText numberOfLines={1}>
{enforceLen(uri, MAX_PDS_LABEL_LEN, true, 'middle')}
</ButtonText>
</Button> </Button>
))} ))}
</View> </View>
@@ -257,7 +302,21 @@ function DialogInner({
native: 'large', native: 'large',
web: 'small', web: 'small',
})} })}
onPress={() => control.close()} onPress={() => {
// Block closing with an invalid custom address. An empty address
// is allowed - it clears the override / keeps the default.
if (
fixedOption === 'custom' &&
customAddress.trim() &&
!isValidServerUrl(customAddress)
) {
setValidationError(
_(msg`Enter a valid server address, e.g. example.com`),
)
return
}
control.close()
}}
label={_(msg`Done`)}> label={_(msg`Done`)}>
<ButtonText> <ButtonText>
<Trans>Done</Trans> <Trans>Done</Trans>
+72 -11
View File
@@ -8,16 +8,22 @@ import {
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
import {isBlueskyHostedPds, ResolvePdsError} from '#/lib/api/resolve-pds' import {isBlueskyHostedPds, ResolvePdsError} from '#/lib/api/resolve-pds'
import {BSKY_SERVICE, DEFAULT_SERVICE} from '#/lib/constants' import {BSKY_SERVICE, DEFAULT_SERVICE} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications' import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {cleanError, isNetworkError} from '#/lib/strings/errors' import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles' import {createFullHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {toNiceDomain} from '#/lib/strings/url-helpers' import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs' import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useResolvePdsQuery} from '#/state/queries/resolve-pds' import {
looksResolvable,
resolvePdsQueryOptions,
useResolvePdsQuery,
} from '#/state/queries/resolve-pds'
import {useSessionApi} from '#/state/session' import {useSessionApi} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, native, useTheme, web} from '#/alf' import {atoms as a, ios, native, useTheme, web} from '#/alf'
@@ -38,6 +44,51 @@ import {FormContainer} from './FormContainer'
type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
/**
* Truncate a host for display so an unexpectedly long (or unparseable, e.g. a
* pasted blob) server string can't blow out the layout. Middle-truncation
* keeps the recognizable start and the TLD/port.
*/
function niceHostLabel(url: string): string {
return enforceLen(toNiceDomain(url), 32, true, 'middle')
}
// Upper bound on how long a submit will wait for host resolution before
// falling back to the default service, so a slow lookup can never trap the
// Sign in button.
const RESOLVE_ON_SUBMIT_TIMEOUT_MS = 2e3
/**
* Best-effort host resolution at submit time. Reuses any in-flight or cached
* resolution (same query key as the background hook) and only fires a request
* if none exists. Returns the resolved PDS, or undefined on timeout/failure so
* the caller falls back to the default service.
*/
async function resolvePdsOnSubmit(
queryClient: QueryClient,
handle: string,
): Promise<string | undefined> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
const result = await Promise.race([
queryClient.fetchQuery(resolvePdsQueryOptions(handle)),
new Promise<undefined>(resolve => {
timer = setTimeout(
() => resolve(undefined),
RESOLVE_ON_SUBMIT_TIMEOUT_MS,
)
}),
])
return result?.pds
} catch {
// Bad handle, network error, or no PDS in the DID doc. Fall back to the
// default service - login will surface any genuine auth error.
return undefined
} finally {
if (timer) clearTimeout(timer)
}
}
export const LoginForm = ({ export const LoginForm = ({
error, error,
serviceUrl, serviceUrl,
@@ -80,6 +131,7 @@ export const LoginForm = ({
const passwordRef = useRef<TextInput>(null) const passwordRef = useRef<TextInput>(null)
const hasFocusedOnce = useRef<boolean>(false) const hasFocusedOnce = useRef<boolean>(false)
const {_} = useLingui() const {_} = useLingui()
const queryClient = useQueryClient()
const {login} = useSessionApi() const {login} = useSessionApi()
const requestNotificationsPermission = useRequestNotificationsPermission() const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls() const {setShowLoggedOut} = useLoggedOutViewControls()
@@ -198,13 +250,20 @@ export const LoginForm = ({
} }
} }
// Make sure the resolved-PDS state is in sync with the current input // Keep the status message in sync with the submitted handle.
// before deciding which service to use.
if (handleForResolve !== fullIdent) { if (handleForResolve !== fullIdent) {
setHandleForResolve(fullIdent) setHandleForResolve(fullIdent)
} }
const service =
customServerOverride ?? resolveQuery.data?.pds ?? serviceUrl // Pick the service to sign in to. A custom server override always wins.
// Otherwise ensure host resolution has finished first: a fast submit
// (e.g. password-manager autofill) can beat the background lookup and
// wrongly default to the main service.
let service = customServerOverride ?? serviceUrl
if (!customServerOverride && looksResolvable(fullIdent)) {
const resolvedPds = await resolvePdsOnSubmit(queryClient, fullIdent)
if (resolvedPds) service = resolvedPds
}
// TODO remove double login // TODO remove double login
await login( await login(
@@ -499,7 +558,7 @@ function PdsResolveStatus({
content = ( content = (
<Text <Text
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}> style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
<Trans>You're signing in to {toNiceDomain(override)}.</Trans>{' '} <Trans>You're signing in to {niceHostLabel(override)}.</Trans>{' '}
<InlineLinkText <InlineLinkText
label={_(msg`Change server`)} label={_(msg`Change server`)}
{...createStaticClick(onPressUseCustomServer)} {...createStaticClick(onPressUseCustomServer)}
@@ -519,10 +578,12 @@ function PdsResolveStatus({
} }
contentKey = 'loading' contentKey = 'loading'
content = ( content = (
<Text <View
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}> accessibilityLabel={_(msg`Resolving your server`)}
<Trans>Resolving your server</Trans> accessibilityHint=""
</Text> style={[web(a.align_end)]}>
<Loader size="md" />
</View>
) )
} else if (query.isError) { } else if (query.isError) {
contentKey = 'error' contentKey = 'error'
@@ -557,7 +618,7 @@ function PdsResolveStatus({
content = ( content = (
<Text <Text
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}> style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
<Trans>You're signing in to {toNiceDomain(query.data.pds)}</Trans> <Trans>You're signing in to {niceHostLabel(query.data.pds)}</Trans>
</Text> </Text>
) )
} }
+30 -9
View File
@@ -6,20 +6,41 @@ import {STALE} from '#/state/queries'
const RQKEY_ROOT = 'resolve-pds' const RQKEY_ROOT = 'resolve-pds'
export const RQKEY = (handle: string) => [RQKEY_ROOT, handle] export const RQKEY = (handle: string) => [RQKEY_ROOT, handle]
export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) { function normalizeHandle(handle: string) {
const normalized = handle.trim().replace(/^@/, '').toLowerCase() return handle.trim().replace(/^@/, '').toLowerCase()
// Only auto-resolve when the input looks like a full handle or a DID. }
// Skip emails (contain `@`) so legacy email-login users keep going to the
// default service. /**
const looksResolvable = * Whether the input is worth resolving. Only resolve when it looks like a full
* handle or a DID. Skip emails (contain `@`) so legacy email-login users keep
* going to the default service.
*/
export function looksResolvable(handle: string) {
const normalized = normalizeHandle(handle)
return (
!normalized.includes('@') && !normalized.includes('@') &&
(normalized.startsWith('did:') || normalized.includes('.')) (normalized.startsWith('did:') || normalized.includes('.'))
return useQuery({ )
enabled: (opts?.enabled ?? true) && looksResolvable, }
/**
* Shared query config so the background hook and any imperative `fetchQuery`
* (e.g. resolving on form submit) use the same key, cache, and options.
*/
export function resolvePdsQueryOptions(handle: string) {
const normalized = normalizeHandle(handle)
return {
queryKey: RQKEY(normalized), queryKey: RQKEY(normalized),
queryFn: () => resolvePdsForHandle(normalized), queryFn: () => resolvePdsForHandle(normalized),
staleTime: STALE.MINUTES.FIVE, staleTime: STALE.MINUTES.FIVE,
// Don't retry — failures fall back to manual server entry, no point hammering. // Don't retry — failures fall back to manual server entry, no point hammering.
retry: false, retry: false as const,
}
}
export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) {
return useQuery({
...resolvePdsQueryOptions(handle),
enabled: (opts?.enabled ?? true) && looksResolvable(handle),
}) })
} }