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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user