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' @@ -19,6 +19,7 @@ import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import { useViewerInboxRelayUrls } from '@/hooks/useViewerInboxRelayUrls'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
@ -535,7 +536,8 @@ function buildEmbedWideRelayUrlsStatic( @@ -535,7 +536,8 @@ function buildEmbedWideRelayUrlsStatic(
relayHintsFromParent: string[],
viewerInboxRelayUrls: string[]
): string[] {
return feedRelayPolicyUrls(
return filterReadOnlyRelaysUnlessPersonal(
feedRelayPolicyUrls(
[
{
source: 'fallback',
@ -558,6 +560,7 @@ function buildEmbedWideRelayUrlsStatic( @@ -558,6 +560,7 @@ function buildEmbedWideRelayUrlsStatic(
allowThirdPartyLocalRelays: true
}
)
)
}
/** 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 = [ @@ -429,6 +429,12 @@ export const READ_ONLY_RELAY_URLS = [
'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
* {@link READ_ONLY_RELAY_URLS} (longer connect timeout + proactive `automaticallyAuth`), but **not**

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

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
SEARCHABLE_RELAY_URLS
} from '@/constants'
import { mergeRelayUrlLayers } from '@/lib/favorites-feed-relays'
import { filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
@ -63,5 +64,5 @@ export async function buildCitationPickerSearchRelayUrls(): Promise<string[]> { @@ -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 @@ @@ -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 @@ @@ -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' @@ -14,6 +14,7 @@ import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize'
import { isHttpRelayUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { buildPersonalRelayKeySet, filterReadOnlyRelaysUnlessPersonal } from '@/lib/read-only-relay-personal'
import { getCacheRelayUrls } from './private-relays'
import { defaultFavoriteRelaysForViewer, viewerUsesGlobalRelayDefaults } from '@/lib/viewer-relay-defaults'
import client from '@/services/client.service'
@ -152,6 +153,11 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -152,6 +153,11 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const relayUrls = new Set<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(
(blockedRelays || []).map(url => {
const normalized = normalizeUrl(url) || url
@ -261,20 +267,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -261,20 +267,32 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
const userRelayList = viewerRelayListForShare ?? (await client.peekRelayListFromStorage(userPubkey))
const userRead = userReadRelaysWithHttp(userRelayList).slice(0, 10)
const userWrite = [...(userRelayList.write || []).slice(0, 10)]
userRead.forEach(addRelay)
userWrite.forEach(addRelay)
userRead.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
userWrite.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
// Include local relays from kind 10432
if (includeLocalRelays) {
const localRelays = await getCacheRelayUrls(userPubkey)
localRelays.forEach(addRelay)
localRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
}
// Include favorite relays (kind 10012) if requested
if (includeFavoriteRelays) {
try {
const favoriteRelays = await client.fetchFavoriteRelays(userPubkey)
favoriteRelays.forEach(addRelay)
favoriteRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} catch (error) {
logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error })
}
@ -286,18 +304,27 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -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
try {
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
if (includeLocalRelays) {
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.
if (includeFavoriteRelays) {
try {
const favoriteRelays = await client.fetchFavoriteRelays(userPubkey)
favoriteRelays.forEach(addRelay)
favoriteRelays.forEach((u) => {
trackPersonal(u)
addRelay(u)
})
} catch (error) {
logger.warn('[RelayListBuilder] Failed to fetch user favorite relays', { error })
}
@ -339,12 +366,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio @@ -339,12 +366,16 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio
}
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',
blockedRelays,
applySocialKindBlockedFilter: false,
allowThirdPartyLocalRelays: true
})
}),
personalKeys
)
if (httpRelayUrls.length === 0) return ws
const seen = new Set(ws.map(relayKey))
const out = [...ws]

2
src/providers/NostrProvider/index.tsx

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

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

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

57
src/services/client.service.ts

@ -37,6 +37,13 @@ import { @@ -37,6 +37,13 @@ import {
} from '@/constants'
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'
/** NIP-01 filter keys only; NIP-50 adds `search` which non-searchable relays reject. */
@ -569,6 +576,8 @@ class ClientService extends EventTarget { @@ -569,6 +576,8 @@ class ClientService extends EventTarget {
this.pool.automaticallyAuth = (relayURL: string) => {
const n = normalizeUrl(relayURL) || relayURL
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) => {
const evt = await queueRelayAuthSign(() => signer.signEvent(event))
return evt as VerifiedEvent
@ -579,6 +588,41 @@ class ClientService extends EventTarget { @@ -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. */
private async fetchNip66RelayDiscovery(): Promise<void> {
try {
@ -2476,7 +2520,9 @@ class ClientService extends EventTarget { @@ -2476,7 +2520,9 @@ class ClientService extends EventTarget {
relayReqLog?: { groupId?: string; onBatchEnd?: (rows: RelayOpTerminalRow[]) => void }
) {
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) {
relays = stripLocalNetworkRelaysForWssReq(relays)
} else {
@ -3349,7 +3395,7 @@ class ClientService extends EventTarget { @@ -3349,7 +3395,7 @@ class ClientService extends EventTarget {
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
@ -3384,7 +3430,9 @@ class ClientService extends EventTarget { @@ -3384,7 +3430,9 @@ class ClientService extends EventTarget {
.filter(Boolean)
)
)
const wsOriginal = originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
const wsOriginal = filterReadOnlyRelaysUnlessPersonal(
originalDedupedRelays.filter((url) => !isHttpRelayUrl(url))
)
let relays = [...wsOriginal]
if (relays.length === 0 && httpRelayBases.length === 0) {
relays = [...FAST_READ_RELAY_URLS]
@ -4208,6 +4256,9 @@ class ClientService extends EventTarget { @@ -4208,6 +4256,9 @@ class ClientService extends EventTarget {
const requestPromise = (async () => {
try {
const [relayList] = await this.fetchRelayLists([pubkey])
if (this.pubkey && hexPubkeysEqual(this.pubkey, pubkey)) {
void this.syncViewerPersonalRelayKeys(pubkey)
}
return relayList
} catch (error) {
logger.warn('[FetchRelayList] Fetch failed; using IndexedDB / defaults', {

Loading…
Cancel
Save