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 {BSKY_SERVICE} from '#/lib/constants'
import {enforceLen} from '#/lib/strings/helpers'
import * as persisted from '#/state/persisted'
import {useSession} from '#/state/session'
import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf'
@@ -20,6 +21,40 @@ import {useAnalytics} from '#/analytics'
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({
control,
onSelect,
@@ -100,6 +135,7 @@ function DialogInner({
const {accounts} = useSession()
const {gtMobile} = useBreakpoints()
const [customAddress, setCustomAddress] = useState(initialCustomAddress)
const [validationError, setValidationError] = useState('')
const [pdsAddressHistory, setPdsAddressHistory] = useState<string[]>(
persisted.get('pdsAddressHistory') || [],
)
@@ -108,31 +144,24 @@ function DialogInner({
formRef,
() => ({
getFormState: () => {
let url
if (fixedOption === 'custom') {
url = customAddress.trim().toLowerCase()
if (!url) {
return null
}
} else {
url = fixedOption
if (fixedOption !== 'custom') {
return fixedOption
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
if (url === 'localhost' || url.startsWith('localhost:')) {
url = `http://${url}`
} else {
url = `https://${url}`
}
// Guard against the dialog being dismissed (backdrop, escape, drag)
// with an empty or invalid address - don't propagate garbage.
if (!customAddress.trim() || !isValidServerUrl(customAddress)) {
return null
}
if (fixedOption === 'custom') {
if (!pdsAddressHistory.includes(url)) {
const newHistory = [url, ...pdsAddressHistory.slice(0, 4)]
setPdsAddressHistory(newHistory)
persisted.write('pdsAddressHistory', newHistory)
}
const url = normalizeServerUrl(customAddress)
if (!pdsAddressHistory.includes(url)) {
const newHistory = [
url,
// Prune any legacy invalid entries while we're writing.
...pdsAddressHistory.filter(isValidServerUrl),
].slice(0, MAX_PDS_HISTORY)
setPdsAddressHistory(newHistory)
persisted.write('pdsAddressHistory', newHistory)
}
return url
},
}),
@@ -141,6 +170,12 @@ function DialogInner({
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 (
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
@@ -196,21 +231,29 @@ function DialogInner({
<TextField.LabelText nativeID="address-input-label">
<Trans>Server address</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Root isInvalid={!!validationError}>
<TextField.Icon icon={Globe} />
<Dialog.Input
testID="customServerTextInput"
value={customAddress}
onChangeText={setCustomAddress}
onChangeText={v => {
setCustomAddress(v)
if (validationError) setValidationError('')
}}
label="my-server.com"
accessibilityLabelledBy="address-input-label"
autoCapitalize="none"
keyboardType="url"
/>
</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]}>
{pdsAddressHistory.map(uri => (
{recentServers.map(uri => (
<Button
key={uri}
variant="ghost"
@@ -218,7 +261,9 @@ function DialogInner({
label={uri}
style={[a.px_sm, a.py_xs, a.rounded_sm, a.gap_sm]}
onPress={() => setCustomAddress(uri)}>
<ButtonText>{uri}</ButtonText>
<ButtonText numberOfLines={1}>
{enforceLen(uri, MAX_PDS_LABEL_LEN, true, 'middle')}
</ButtonText>
</Button>
))}
</View>
@@ -257,7 +302,21 @@ function DialogInner({
native: 'large',
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`)}>
<ButtonText>
<Trans>Done</Trans>
+72 -11
View File
@@ -8,16 +8,22 @@ import {
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {type QueryClient, useQueryClient} from '@tanstack/react-query'
import {isBlueskyHostedPds, ResolvePdsError} from '#/lib/api/resolve-pds'
import {BSKY_SERVICE, DEFAULT_SERVICE} from '#/lib/constants'
import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
import {cleanError, isNetworkError} from '#/lib/strings/errors'
import {createFullHandle} from '#/lib/strings/handles'
import {enforceLen} from '#/lib/strings/helpers'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
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 {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {atoms as a, ios, native, useTheme, web} from '#/alf'
@@ -38,6 +44,51 @@ import {FormContainer} from './FormContainer'
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 = ({
error,
serviceUrl,
@@ -80,6 +131,7 @@ export const LoginForm = ({
const passwordRef = useRef<TextInput>(null)
const hasFocusedOnce = useRef<boolean>(false)
const {_} = useLingui()
const queryClient = useQueryClient()
const {login} = useSessionApi()
const requestNotificationsPermission = useRequestNotificationsPermission()
const {setShowLoggedOut} = useLoggedOutViewControls()
@@ -198,13 +250,20 @@ export const LoginForm = ({
}
}
// Make sure the resolved-PDS state is in sync with the current input
// before deciding which service to use.
// Keep the status message in sync with the submitted handle.
if (handleForResolve !== 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
await login(
@@ -499,7 +558,7 @@ function PdsResolveStatus({
content = (
<Text
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
label={_(msg`Change server`)}
{...createStaticClick(onPressUseCustomServer)}
@@ -519,10 +578,12 @@ function PdsResolveStatus({
}
contentKey = 'loading'
content = (
<Text
style={[a.text_sm, t.atoms.text_contrast_medium, web(a.text_right)]}>
<Trans>Resolving your server</Trans>
</Text>
<View
accessibilityLabel={_(msg`Resolving your server`)}
accessibilityHint=""
style={[web(a.align_end)]}>
<Loader size="md" />
</View>
)
} else if (query.isError) {
contentKey = 'error'
@@ -557,7 +618,7 @@ function PdsResolveStatus({
content = (
<Text
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>
)
}
+30 -9
View File
@@ -6,20 +6,41 @@ import {STALE} from '#/state/queries'
const RQKEY_ROOT = 'resolve-pds'
export const RQKEY = (handle: string) => [RQKEY_ROOT, handle]
export function useResolvePdsQuery(handle: string, opts?: {enabled?: boolean}) {
const normalized = 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 =
function normalizeHandle(handle: string) {
return handle.trim().replace(/^@/, '').toLowerCase()
}
/**
* 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.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),
queryFn: () => resolvePdsForHandle(normalized),
staleTime: STALE.MINUTES.FIVE,
// 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),
})
}