Browse Source

guard against read-only relays

imwald
Silberengel 4 weeks ago
parent
commit
19d0552945
  1. 5
      src/components/Embedded/EmbeddedNote.tsx
  2. 6
      src/constants.ts
  3. 3
      src/lib/citation-picker-relays.ts
  4. 41
      src/lib/read-only-relay-personal.test.ts
  5. 67
      src/lib/read-only-relay-personal.ts
  6. 49
      src/lib/relay-list-builder.ts
  7. 2
      src/providers/NostrProvider/index.tsx
  8. 4
      src/services/client-query.service.ts
  9. 57
      src/services/client.service.ts

5
src/components/Embedded/EmbeddedNote.tsx

@ -19,6 +19,7 @@ import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store' import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls' import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context' import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider' import { useReply } from '@/providers/ReplyProvider'
@ -535,7 +536,8 @@ function buildEmbedWideRelayUrlsStatic(
relayHintsFromParent: string[], relayHintsFromParent: string[],
viewerInboxRelayUrls: string[] viewerInboxRelayUrls: string[]
): string[] { ): string[] {
return feedRelayPolicyUrls( return filterReadOnlyRelaysUnlessPersonal(
feedRelayPolicyUrls(
[ [
{ {
source: 'fallback', source: 'fallback',
@ -558,6 +560,7 @@ function buildEmbedWideRelayUrlsStatic(
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
} }
) )
)
} }
/** NIP-65 / nevent relays / seen-on — merged into a second wide REQ if the first pass missed. */ /** NIP-65 / nevent relays / seen-on — merged into a second wide REQ if the first pass missed. */

6
src/constants.ts

@ -429,6 +429,12 @@ export const READ_ONLY_RELAY_URLS = [
'wss://primus.nostr1.com' 'wss://primus.nostr1.com'
] ]
/**
* Subset of {@link READ_ONLY_RELAY_URLS} that must also appear on the viewer's NIP-65 / favorites / 10432
* before read or NIP-42 AUTH (unauthorized otherwise). Does not include aggr.nostr.land or search indexers.
*/
export const READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS = ['wss://filter.nostr.wine'] as const
/** /**
* Relays that need NIP-42 signed before the first REQ returns useful data. Same pool treatment as * Relays that need NIP-42 signed before the first REQ returns useful data. Same pool treatment as
* {@link READ_ONLY_RELAY_URLS} (longer connect timeout + proactive `automaticallyAuth`), but **not** * {@link READ_ONLY_RELAY_URLS} (longer connect timeout + proactive `automaticallyAuth`), but **not**

3
src/lib/citation-picker-relays.ts

@ -6,6 +6,7 @@ import {
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays' import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -63,5 +64,5 @@ export async function buildCitationPickerSearchRelayUrls(): Promise<string[]> {
[] []
) )
return merged.slice(0, CITATION_SEARCH_MAX_RELAYS) return filterReadOnlyRelaysUnlessPersonal(merged).slice(0, CITATION_SEARCH_MAX_RELAYS)
} }

41
src/lib/read-only-relay-personal.test.ts

@ -0,0 +1,41 @@
import { describe, expect, it, beforeEach } from 'vitest'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import {
buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal,
isPersonalListRequiredReadOnlyRelay,
setViewerPersonalRelayKeys
} from './read-only-relay-personal'
describe('read-only-relay-personal', () => {
beforeEach(() => {
setViewerPersonalRelayKeys(new Set())
})
it('requires personal list only for filter.nostr.wine', () => {
expect(isPersonalListRequiredReadOnlyRelay('wss://filter.nostr.wine/')).toBe(true)
expect(isPersonalListRequiredReadOnlyRelay('wss://relay.damus.io/')).toBe(false)
expect(isPersonalListRequiredReadOnlyRelay(AGGR_NOSTR_LAND_WSS)).toBe(false)
expect(isPersonalListRequiredReadOnlyRelay('wss://search.nos.today/')).toBe(false)
})
it('strips unlisted filter.nostr.wine but keeps aggr and search indexers', () => {
const urls = [
'wss://relay.damus.io/',
'wss://filter.nostr.wine/',
AGGR_NOSTR_LAND_WSS,
'wss://search.nos.today/'
]
expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual([
'wss://relay.damus.io/',
AGGR_NOSTR_LAND_WSS,
'wss://search.nos.today/'
])
})
it('keeps filter.nostr.wine when on the viewer personal list', () => {
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(['wss://filter.nostr.wine/']))
const urls = ['wss://relay.damus.io/', 'wss://filter.nostr.wine/']
expect(filterReadOnlyRelaysUnlessPersonal(urls)).toEqual(urls)
})
})

67
src/lib/read-only-relay-personal.ts

@ -0,0 +1,67 @@
import { READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS } from '@/constants'
import { normalizeAnyRelayUrl } from '@/lib/url'
const personalListRequiredKeySet = new Set(
READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS.map((u) =>
(normalizeAnyRelayUrl(u) || u).toLowerCase()
).filter(Boolean)
)
let viewerPersonalRelayKeys = new Set<string>()
export function relayUrlKey(url: string): string {
return (normalizeAnyRelayUrl(url) || url.trim()).toLowerCase()
}
/** True when the relay must be on the viewer's personal lists before connect / AUTH. */
export function isPersonalListRequiredReadOnlyRelay(url: string): boolean {
const key = relayUrlKey(url)
return key.length > 0 && personalListRequiredKeySet.has(key)
}
/** @deprecated Use {@link isPersonalListRequiredReadOnlyRelay}; kept for NIP-42 call sites. */
export function isReadOnlyIndexerRelay(url: string): boolean {
return isPersonalListRequiredReadOnlyRelay(url)
}
export function buildPersonalRelayKeySet(urls: readonly string[]): Set<string> {
const out = new Set<string>()
for (const u of urls) {
const key = relayUrlKey(u)
if (key) out.add(key)
}
return out
}
/** Updated when the logged-in viewer's NIP-65 / favorites / cache relays hydrate. */
export function setViewerPersonalRelayKeys(keys: ReadonlySet<string>): void {
viewerPersonalRelayKeys = new Set(keys)
}
export function getViewerPersonalRelayKeys(): ReadonlySet<string> {
return viewerPersonalRelayKeys
}
export function isReadOnlyRelayAllowedForViewer(url: string): boolean {
if (!isPersonalListRequiredReadOnlyRelay(url)) return true
const key = relayUrlKey(url)
return key.length > 0 && viewerPersonalRelayKeys.has(key)
}
function isAllowedForKeys(url: string, personalKeys: ReadonlySet<string>): boolean {
if (!isPersonalListRequiredReadOnlyRelay(url)) return true
const key = relayUrlKey(url)
return key.length > 0 && personalKeys.has(key)
}
/**
* Drop {@link READ_ONLY_PERSONAL_LIST_REQUIRED_RELAY_URLS} unless the viewer listed them on NIP-65 / favorites / 10432.
* Other read-only index relays (aggr.nostr.land, search.nos.today, ) are unchanged.
*/
export function filterReadOnlyRelaysUnlessPersonal(
urls: readonly string[],
personalKeys?: ReadonlySet<string>
): string[] {
const keys = personalKeys ?? viewerPersonalRelayKeys
return urls.filter((u) => isAllowedForKeys(u, keys))
}

49
src/lib/relay-list-builder.ts

@ -14,6 +14,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrls } from './private-relays' import { getCacheRelayUrls } from './private-relays'
import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service' import client from '@/services/client.service'
@ -152,6 +153,11 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const relayUrls = new Set<string>() const relayUrls = new Set<string>()
const httpRelayUrls: string[] = [] const httpRelayUrls: string[] = []
/** NIP-65 / favorites / 10432 — read-only index relays are only kept when listed here. */
const personalRelayUrls: string[] = []
const trackPersonal = (url: string) => {
personalRelayUrls.push(url)
}
const normalizedBlocked = new Set( const normalizedBlocked = new Set(
(blockedRelays || []).map(url => { (blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url const normalized = normalizeUrl(url) || url
@ -261,20 +267,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey))
const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10) const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10)
const userWrite = [...(userRelayList.write || []).slice(0, 10)] const userWrite = [...(userRelayList.write || []).slice(0, 10)]
userRead.forEach(addRelay) userRead.forEach((u) => {
userWrite.forEach(addRelay) trackPersonal(u)
addRelay(u)
})
userWrite.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
// Include local relays from kind 10432 // Include local relays from kind 10432
if (includeLocalRelays) { if (includeLocalRelays) {
const localRelays = await getCacheRelayUrls(userPubkey) const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay) localRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} }
// Include favorite relays (kind 10012) if requested // Include favorite relays (kind 10012) if requested
if (includeFavoriteRelays) { if (includeFavoriteRelays) {
try { try {
const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) const favoriteRelays = await client.fetchFavoriteRelays(userPubkey)
favoriteRelays.forEach(addRelay) favoriteRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} catch (error) { } catch (error) {
logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error })
} }
@ -286,18 +304,27 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
// Even if not including user's own relays, still include user's inboxes for reading // Even if not including user's own relays, still include user's inboxes for reading
try { try {
const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey)) const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey))
;(userRelayList.read ?? []).slice(0, 10).forEach(addRelay) ;(userRelayList.read ?? []).slice(0, 10).forEach((u) => {
trackPersonal(u)
addRelay(u)
})
// Include local relays from kind 10432 if enabled // Include local relays from kind 10432 if enabled
if (includeLocalRelays) { if (includeLocalRelays) {
const localRelays = await getCacheRelayUrls(userPubkey) const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay) localRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} }
// Menu / feed “favorite relays” (kind 10012) — same list as the sidebar; not part of NIP-65 alone. // Menu / feed “favorite relays” (kind 10012) — same list as the sidebar; not part of NIP-65 alone.
if (includeFavoriteRelays) { if (includeFavoriteRelays) {
try { try {
const favoriteRelays = await client.fetchFavoriteRelays(userPubkey) const favoriteRelays = await client.fetchFavoriteRelays(userPubkey)
favoriteRelays.forEach(addRelay) favoriteRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} catch (error) { } catch (error) {
logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error }) logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error })
} }
@ -339,12 +366,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
} }
const merged = Array.from(relayUrls) const merged = Array.from(relayUrls)
const ws = feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], { const personalKeys = userPubkey ? buildPersonalRelayKeySet(personalRelayUrls) : undefined
const ws = filterReadOnlyRelaysUnlessPersonal(
feedRelayPolicyUrls([{ source: 'fallback', urls: merged }], {
operation: 'read', operation: 'read',
blockedRelays, blockedRelays,
applySocialKindBlockedFilter: false, applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true allowThirdPartyLocalRelays: true
}) }),
personalKeys
)
if (httpRelayUrls.length === 0) return ws if (httpRelayUrls.length === 0) return ws
const seen = new Set(ws.map(relayKey)) const seen = new Set(ws.map(relayKey))
const out = [...ws] const out = [...ws]

2
src/providers/NostrProvider/index.tsx

@ -1050,8 +1050,10 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { useEffect(() => {
if (account) { if (account) {
client.pubkey = account.pubkey client.pubkey = account.pubkey
void client.syncViewerPersonalRelayKeys(account.pubkey)
} else { } else {
client.pubkey = undefined client.pubkey = undefined
void client.syncViewerPersonalRelayKeys()
} }
}, [account]) }, [account])

4
src/services/client-query.service.ts

@ -34,6 +34,7 @@ import { patchRelayNoticeForFetchFailures } from '@/services/relay-notice-fetch-
import type { Filter, Event as NEvent } from 'nostr-tools' import type { Filter, Event as NEvent } from 'nostr-tools'
import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools' import { SimplePool, EventTemplate, VerifiedEvent, nip19 } from 'nostr-tools'
import type { AbstractRelay } from 'nostr-tools/abstract-relay' import type { AbstractRelay } from 'nostr-tools/abstract-relay'
import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import nip66Service from './nip66.service' import nip66Service from './nip66.service'
import type { ISigner, TSignerType } from '@/types' import type { ISigner, TSignerType } from '@/types'
@ -405,6 +406,7 @@ export class QueryService {
onevent?: (evt: NEvent) => void, onevent?: (evt: NEvent) => void,
options?: QueryOptions options?: QueryOptions
): Promise<NEvent[]> { ): Promise<NEvent[]> {
urls = filterReadOnlyRelaysUnlessPersonal(urls)
const sanitizedFilters = sanitizeFiltersBeforeReq(filter) const sanitizedFilters = sanitizeFiltersBeforeReq(filter)
if (sanitizedFilters.length === 0) return [] if (sanitizedFilters.length === 0) return []
if (options?.signal?.aborted) return [] if (options?.signal?.aborted) return []
@ -781,7 +783,7 @@ export class QueryService {
return { close: () => {} } return { close: () => {} }
} }
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays let relays = filterReadOnlyRelaysUnlessPersonal(originalDedupedRelays)
const stripSocialBlockedRelays = const stripSocialBlockedRelays =
SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 && SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&

57
src/services/client.service.ts

@ -37,6 +37,13 @@ import {
} from '@/constants' } from '@/constants'
import { getCacheRelayUrls } from '@/lib/private-relays' import { getCacheRelayUrls } from '@/lib/private-relays'
import {
buildPersonalRelayKeySet,
filterReadOnlyRelaysUnlessPersonal,
isReadOnlyIndexerRelay,
isReadOnlyRelayAllowedForViewer,
setViewerPersonalRelayKeys
} from '@/lib/read-only-relay-personal'
import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults' import { profileFetchRelayUrlsWithoutFastReadLayer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */ /** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
@ -569,6 +576,8 @@ class ClientService extends EventTarget {
this.pool.automaticallyAuth = (relayURL: string) => { this.pool.automaticallyAuth = (relayURL: string) => {
const n = normalizeUrl(relayURL) || relayURL const n = normalizeUrl(relayURL) || relayURL
if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null if (!READ_ONLY_RELAY_CONNECT_BOOST_URLS.has(n)) return null
// Read-only index relays (e.g. filter.nostr.wine): only AUTH when on the viewer's relay lists.
if (isReadOnlyIndexerRelay(n) && !isReadOnlyRelayAllowedForViewer(n)) return null
return async (event: EventTemplate) => { return async (event: EventTemplate) => {
const evt = await queueRelayAuthSign(() => signer.signEvent(event)) const evt = await queueRelayAuthSign(() => signer.signEvent(event))
return evt as VerifiedEvent return evt as VerifiedEvent
@ -579,6 +588,41 @@ class ClientService extends EventTarget {
} }
} }
/**
* NIP-65 read/write + favorites (10012) + cache relays (10432) for the logged-in viewer.
* Used to gate {@link READ_ONLY_RELAY_URLS} and proactive NIP-42 on those relays.
*/
async syncViewerPersonalRelayKeys(pubkey?: string): Promise<void> {
const pk = pubkey?.trim() || this.pubkey?.trim()
if (!pk) {
setViewerPersonalRelayKeys(new Set())
return
}
const urls: string[] = []
try {
const rl = await this.peekRelayListFromStorage(pk)
urls.push(
...(rl.read ?? []),
...(rl.write ?? []),
...(rl.httpRead ?? []),
...(rl.httpWrite ?? [])
)
} catch {
// ignore
}
try {
urls.push(...(await this.fetchFavoriteRelays(pk)))
} catch {
// ignore
}
try {
urls.push(...(await getCacheRelayUrls(pk)))
} catch {
// ignore
}
setViewerPersonalRelayKeys(buildPersonalRelayKeySet(urls))
}
/** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */ /** NIP-66: fetch relay discovery events (30166) in background to supplement search/NIP support. */
private async fetchNip66RelayDiscovery(): Promise<void> { private async fetchNip66RelayDiscovery(): Promise<void> {
try { try {
@ -2476,7 +2520,9 @@ class ClientService extends EventTarget {
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void } relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) { ) {
const originalDedupedRelays = Array.from(new Set(urls)) const originalDedupedRelays = Array.from(new Set(urls))
let relays = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) let relays = filterReadOnlyRelaysUnlessPersonal(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
)
if (navigator.onLine) { if (navigator.onLine) {
relays = stripLocalNetworkRelaysForWssReq(relays) relays = stripLocalNetworkRelaysForWssReq(relays)
} else { } else {
@ -3349,7 +3395,7 @@ class ClientService extends EventTarget {
firstRelayResultGraceMs?: number | false firstRelayResultGraceMs?: number | false
} }
) { ) {
return this.queryService.query(urls, filter, onevent, options) return this.queryService.query(filterReadOnlyRelaysUnlessPersonal(urls), filter, onevent, options)
} }
// Legacy query implementation removed - now delegated to QueryService // Legacy query implementation removed - now delegated to QueryService
@ -3384,7 +3430,9 @@ class ClientService extends EventTarget {
.filter(Boolean) .filter(Boolean)
) )
) )
const wsOriginal = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url)) const wsOriginal = filterReadOnlyRelaysUnlessPersonal(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
)
let relays = [...wsOriginal] let relays = [...wsOriginal]
if (relays.length === 0 && httpRelayBases.length === 0) { if (relays.length === 0 && httpRelayBases.length === 0) {
relays = [...FAST_READ_RELAY_URLS] relays = [...FAST_READ_RELAY_URLS]
@ -4208,6 +4256,9 @@ class ClientService extends EventTarget {
const requestPromise = (async () => { const requestPromise = (async () => {
try { try {
const [relayList] = await this.fetchRelayLists([pubkey]) const [relayList] = await this.fetchRelayLists([pubkey])
if (this.pubkey && hexPubkeysEqual(this.pubkey, pubkey)) {
void this.syncViewerPersonalRelayKeys(pubkey)
}
return relayList return relayList
} catch (error) { } catch (error) {
logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', { logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', {

Loading…
Cancel
Save