From a28ffc1ec315c436303f8078997355af72191bde Mon Sep 17 00:00:00 2001 From: Alejandro Date: Tue, 27 Jan 2026 10:59:26 +0100 Subject: [PATCH] fix: filter invalid relay URLs from event tags (#217) * fix: filter invalid relay URLs from event tags Add validation to prevent invalid URLs from being used as relay hints. The issue occurred when "r" tags containing non-relay URLs (like https://(strangelove@basspistol.org/) were being extracted and used as relay connection targets. Changes: - Add isValidRelayURL() helper to validate relay URLs (must have ws:// or wss:// protocol and valid URL structure) - Update extractRelayContext() in loaders.ts to filter r-tags, e-tag relay hints, and a-tag relay hints using the new validator - Add comprehensive tests for isValidRelayURL() https://claude.ai/code/session_01Ca2fKD2r4wHKRD8rcRohj9 * refactor: use applesauce isSafeRelayURL for relay URL validation Refactor relay URL validation to use applesauce's isSafeRelayURL helper which provides a fast regex-based check for valid websocket URLs. Changes: - Update isValidRelayURL in relay-url.ts to use isSafeRelayURL as fast path, with URL constructor fallback for IP addresses - Re-export isSafeRelayURL from relay-url.ts for convenience - Update loaders.ts to use isSafeRelayURL directly from applesauce - Add relay URL validation to: - nostr-utils.ts: getEventPointerFromQTag (q-tag relay hints) - zapstore-helpers.ts: getAppReferences (a-tag relay hints) - nip89-helpers.ts: getHandlerReferences (a-tag relay hints) - PublicChatsRenderer.tsx: extractGroups (group relay URLs) This ensures consistent validation across all relay URL extraction points using applesauce's battle-tested validation. https://claude.ai/code/session_01Ca2fKD2r4wHKRD8rcRohj9 * chore: remove unused isSafeRelayURL re-export The re-export was added but all consumers import directly from applesauce-core/helpers/relays instead. https://claude.ai/code/session_01Ca2fKD2r4wHKRD8rcRohj9 --------- Co-authored-by: Claude --- .../nostr/kinds/PublicChatsRenderer.tsx | 11 ++- src/lib/nip89-helpers.ts | 6 +- src/lib/nostr-utils.ts | 13 ++- src/lib/relay-url.test.ts | 97 ++++++++++++++++++- src/lib/relay-url.ts | 52 ++++++++++ src/lib/zapstore-helpers.ts | 6 +- src/services/loaders.ts | 23 ++++- 7 files changed, 195 insertions(+), 13 deletions(-) diff --git a/src/components/nostr/kinds/PublicChatsRenderer.tsx b/src/components/nostr/kinds/PublicChatsRenderer.tsx index 544cb28..6f036fa 100644 --- a/src/components/nostr/kinds/PublicChatsRenderer.tsx +++ b/src/components/nostr/kinds/PublicChatsRenderer.tsx @@ -6,6 +6,7 @@ import { GroupLink } from "../GroupLink"; import eventStore from "@/services/event-store"; import pool from "@/services/relay-pool"; import type { NostrEvent } from "@/types/nostr"; +import { isSafeRelayURL } from "applesauce-core/helpers/relays"; /** * Extract group references from a kind 10009 event @@ -19,9 +20,17 @@ function extractGroups(event: { tags: string[][] }): Array<{ for (const tag of event.tags) { if (tag[0] === "group" && tag[1] && tag[2]) { + // Only include groups with valid relay URLs + const relayUrl = tag[2]; + if (!isSafeRelayURL(relayUrl)) { + console.warn( + `[PublicChatsRenderer] Skipping group with invalid relay URL: ${relayUrl}`, + ); + continue; + } groups.push({ groupId: tag[1], - relayUrl: tag[2], + relayUrl, }); } } diff --git a/src/lib/nip89-helpers.ts b/src/lib/nip89-helpers.ts index 79a0085..c1a8034 100644 --- a/src/lib/nip89-helpers.ts +++ b/src/lib/nip89-helpers.ts @@ -1,5 +1,6 @@ import { NostrEvent } from "@/types/nostr"; import { getTagValue } from "applesauce-core/helpers"; +import { isSafeRelayURL } from "applesauce-core/helpers/relays"; import { AddressPointer } from "nostr-tools/nip19"; /** @@ -229,10 +230,13 @@ export function getHandlerReferences(event: NostrEvent): HandlerReference[] { const relayHint = tag[2]; const platform = tag[3]; + // Only include relay hint if it's a valid websocket URL + const validRelayHint = + relayHint && isSafeRelayURL(relayHint) ? relayHint : undefined; references.push({ address, - relayHint: relayHint || undefined, + relayHint: validRelayHint, platform: platform || undefined, }); } diff --git a/src/lib/nostr-utils.ts b/src/lib/nostr-utils.ts index eefb49d..cc457ab 100644 --- a/src/lib/nostr-utils.ts +++ b/src/lib/nostr-utils.ts @@ -4,6 +4,7 @@ import type { NostrFilter } from "@/types/nostr"; import { getNip10References } from "applesauce-common/helpers/threading"; import { getCommentReplyPointer } from "applesauce-common/helpers/comment"; import type { EventPointer, AddressPointer } from "nostr-tools/nip19"; +import { isSafeRelayURL } from "applesauce-core/helpers/relays"; export function derivePlaceholderName(pubkey: string): string { return `${pubkey.slice(0, 4)}:${pubkey.slice(-4)}`; @@ -81,6 +82,10 @@ export function getEventPointerFromQTag( const relayHint = tag[2]; const authorHint = tag[3]; + // Validate relay hint is a valid websocket URL before using + const validRelayHint = + relayHint && isSafeRelayURL(relayHint) ? relayHint : undefined; + // Check if it's an address coordinate (contains colons: kind:pubkey:d-tag) if (value.includes(":")) { const parts = value.split(":"); @@ -91,8 +96,8 @@ export function getEventPointerFromQTag( if (!isNaN(kind) && pubkey) { const pointer: AddressPointer = { kind, pubkey, identifier }; - if (relayHint) { - pointer.relays = [relayHint]; + if (validRelayHint) { + pointer.relays = [validRelayHint]; } return pointer; } @@ -106,8 +111,8 @@ export function getEventPointerFromQTag( } const pointer: EventPointer = { id: value }; - if (relayHint) { - pointer.relays = [relayHint]; + if (validRelayHint) { + pointer.relays = [validRelayHint]; } if (authorHint && /^[0-9a-f]{64}$/i.test(authorHint)) { pointer.author = authorHint; diff --git a/src/lib/relay-url.test.ts b/src/lib/relay-url.test.ts index befa31e..e2a7093 100644 --- a/src/lib/relay-url.test.ts +++ b/src/lib/relay-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { normalizeRelayURL } from "./relay-url"; +import { normalizeRelayURL, isValidRelayURL } from "./relay-url"; describe("normalizeRelayURL", () => { it("should add trailing slash to URL without one", () => { @@ -127,3 +127,98 @@ describe("normalizeRelayURL", () => { }); }); }); + +describe("isValidRelayURL", () => { + describe("Valid relay URLs", () => { + it("should return true for wss:// URLs", () => { + expect(isValidRelayURL("wss://relay.example.com")).toBe(true); + expect(isValidRelayURL("wss://relay.example.com/")).toBe(true); + expect(isValidRelayURL("wss://nos.lol")).toBe(true); + }); + + it("should return true for ws:// URLs", () => { + expect(isValidRelayURL("ws://localhost")).toBe(true); + expect(isValidRelayURL("ws://localhost:8080")).toBe(true); + }); + + it("should return true for URLs with paths", () => { + expect(isValidRelayURL("wss://relay.example.com/inbox")).toBe(true); + expect(isValidRelayURL("wss://relay.example.com/path/to/relay")).toBe( + true, + ); + }); + + it("should return true for URLs with ports", () => { + expect(isValidRelayURL("wss://relay.example.com:8080")).toBe(true); + expect(isValidRelayURL("ws://localhost:3000/")).toBe(true); + }); + }); + + describe("Invalid relay URLs - wrong protocol", () => { + it("should return false for http:// URLs", () => { + expect(isValidRelayURL("http://example.com")).toBe(false); + expect(isValidRelayURL("http://basspistol.org/inbox")).toBe(false); + }); + + it("should return false for https:// URLs", () => { + expect(isValidRelayURL("https://example.com")).toBe(false); + expect(isValidRelayURL("https://basspistol.org/")).toBe(false); + }); + + it("should return false for URLs without protocol", () => { + expect(isValidRelayURL("relay.example.com")).toBe(false); + expect(isValidRelayURL("nos.lol")).toBe(false); + }); + + it("should return false for other protocols", () => { + expect(isValidRelayURL("ftp://example.com")).toBe(false); + expect(isValidRelayURL("file:///path/to/file")).toBe(false); + }); + }); + + describe("Invalid relay URLs - malformed", () => { + it("should return false for URLs with invalid characters", () => { + // Real-world case: NIP-05 identifier incorrectly parsed as URL + expect(isValidRelayURL("https://(strangelove@basspistol.org/")).toBe( + false, + ); + }); + + it("should return false for empty or whitespace strings", () => { + expect(isValidRelayURL("")).toBe(false); + expect(isValidRelayURL(" ")).toBe(false); + }); + + it("should return false for non-string types", () => { + expect(isValidRelayURL(null)).toBe(false); + expect(isValidRelayURL(undefined)).toBe(false); + expect(isValidRelayURL(123)).toBe(false); + expect(isValidRelayURL({})).toBe(false); + expect(isValidRelayURL([])).toBe(false); + }); + + it("should return false for incomplete URLs", () => { + expect(isValidRelayURL("wss://")).toBe(false); + expect(isValidRelayURL("ws://")).toBe(false); + }); + }); + + describe("Edge cases", () => { + it("should handle URLs with whitespace (trimmed)", () => { + expect(isValidRelayURL(" wss://relay.example.com ")).toBe(true); + }); + + it("should handle URLs with query parameters", () => { + expect(isValidRelayURL("wss://relay.example.com?token=abc")).toBe(true); + }); + + it("should handle localhost URLs", () => { + expect(isValidRelayURL("ws://127.0.0.1")).toBe(true); + expect(isValidRelayURL("ws://localhost:8080")).toBe(true); + }); + + it("should handle IP addresses", () => { + expect(isValidRelayURL("wss://192.168.1.1:8080")).toBe(true); + }); + }); +}); diff --git a/src/lib/relay-url.ts b/src/lib/relay-url.ts index 20225e0..1de044b 100644 --- a/src/lib/relay-url.ts +++ b/src/lib/relay-url.ts @@ -1,4 +1,56 @@ import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers"; +import { isSafeRelayURL } from "applesauce-core/helpers/relays"; + +/** + * Check if a string is a valid relay URL + * - Must have ws:// or wss:// protocol + * - Must be a valid URL structure + * - Must have a valid hostname + * + * Uses applesauce's isSafeRelayURL for fast validation of domain-based URLs, + * with a fallback to URL constructor for IP addresses (which isSafeRelayURL doesn't support). + * + * @returns true if the URL is a valid relay URL, false otherwise + */ +export function isValidRelayURL(url: unknown): url is string { + // Must be a non-empty string + if (typeof url !== "string" || !url.trim()) { + return false; + } + + const trimmed = url.trim(); + + // Must start with ws:// or wss:// + if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) { + return false; + } + + // Fast path: use applesauce's regex-based validation for domain URLs + if (isSafeRelayURL(trimmed)) { + return true; + } + + // Fallback: use URL constructor for IP addresses and edge cases + // isSafeRelayURL doesn't support IP addresses like 192.168.1.1 or 127.0.0.1 + try { + const parsed = new URL(trimmed); + + // Protocol must be ws: or wss: + if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") { + return false; + } + + // Must have a valid hostname + if (!parsed.hostname || parsed.hostname.length === 0) { + return false; + } + + return true; + } catch { + // URL parsing failed + return false; + } +} /** * Normalize a relay URL to ensure consistent comparison diff --git a/src/lib/zapstore-helpers.ts b/src/lib/zapstore-helpers.ts index aa3f2b1..ffb8480 100644 --- a/src/lib/zapstore-helpers.ts +++ b/src/lib/zapstore-helpers.ts @@ -1,5 +1,6 @@ import { NostrEvent } from "@/types/nostr"; import { getTagValue } from "applesauce-core/helpers"; +import { isSafeRelayURL } from "applesauce-core/helpers/relays"; import { AddressPointer } from "nostr-tools/nip19"; /** @@ -227,9 +228,12 @@ export function getAppReferences(event: NostrEvent): AppReference[] { // Kind 32267 apps are expected in curation sets if (address.kind === 32267) { const relayHint = tag[2]; + // Only include relay hint if it's a valid websocket URL + const validRelayHint = + relayHint && isSafeRelayURL(relayHint) ? relayHint : undefined; references.push({ address, - relayHint: relayHint || undefined, + relayHint: validRelayHint, }); } } diff --git a/src/services/loaders.ts b/src/services/loaders.ts index b725fb8..d3cd7f4 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -6,7 +6,11 @@ import { } from "applesauce-loaders/loaders"; import type { EventPointer } from "nostr-tools/nip19"; import { Observable } from "rxjs"; -import { getSeenRelays, mergeRelaySets } from "applesauce-core/helpers/relays"; +import { + getSeenRelays, + mergeRelaySets, + isSafeRelayURL, +} from "applesauce-core/helpers/relays"; import { getEventPointerFromETag, getAddressPointerFromATag, @@ -31,13 +35,17 @@ function extractRelayContext(event: NostrEvent): { // Get relays where this event was seen (tracked by applesauce) const seenRelays = getSeenRelays(event); - // Extract all "r" tags (URL references per NIP-01) + // Extract relay URLs from "r" tags (URL references per NIP-01) + // Only include valid relay URLs (ws:// or wss://) - filter out http/https links const rTags = event.tags .filter((t) => t[0] === "r") .map((t) => t[1]) - .filter(Boolean); + .filter( + (url): url is string => typeof url === "string" && isSafeRelayURL(url), + ); // Extract relay hints from all "e" tags using applesauce helper + // Filter to only valid relay URLs const eTagRelays = event.tags .filter((t) => t[0] === "e") .map((tag) => { @@ -45,17 +53,22 @@ function extractRelayContext(event: NostrEvent): { // v5: returns null for invalid tags instead of throwing return pointer?.relays?.[0]; // First relay hint from the pointer }) - .filter((relay): relay is string => relay !== undefined); + .filter( + (url): url is string => typeof url === "string" && isSafeRelayURL(url), + ); // Extract relay hints from all "a" tags (addressable event references) // This includes both lowercase "a" (reply) and uppercase "A" (root) tags + // Filter to only valid relay URLs const aTagRelays = event.tags .filter((t) => t[0] === "a" || t[0] === "A") .map((tag) => { const pointer = getAddressPointerFromATag(tag); return pointer?.relays?.[0]; // First relay hint from the pointer }) - .filter((relay): relay is string => relay !== undefined); + .filter( + (url): url is string => typeof url === "string" && isSafeRelayURL(url), + ); // Extract first "p" tag as author hint using applesauce helper const authorHint = getTagValue(event, "p");