From 15542d393e6780d434c59e7a09e2dc9422adf260 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Jan 2026 09:36:42 +0000 Subject: [PATCH] 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 --- src/lib/relay-url.test.ts | 97 ++++++++++++++++++++++++++++++++++++++- src/lib/relay-url.ts | 42 +++++++++++++++++ src/services/loaders.ts | 12 +++-- 3 files changed, 146 insertions(+), 5 deletions(-) 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..413cad1 100644 --- a/src/lib/relay-url.ts +++ b/src/lib/relay-url.ts @@ -1,5 +1,47 @@ import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers"; +/** + * Check if a string is a valid relay URL + * - Must have ws:// or wss:// protocol + * - Must be a valid URL structure + * - Must not contain invalid characters + * + * @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; + } + + try { + // Must be a valid URL structure + 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 * - Validates input is a non-empty string diff --git a/src/services/loaders.ts b/src/services/loaders.ts index b725fb8..75db474 100644 --- a/src/services/loaders.ts +++ b/src/services/loaders.ts @@ -16,6 +16,7 @@ import pool from "./relay-pool"; import eventStore from "./event-store"; import { relayListCache } from "./relay-list-cache"; import type { NostrEvent } from "@/types/nostr"; +import { isValidRelayURL } from "@/lib/relay-url"; /** * Extract relay context from a Nostr event for comprehensive relay selection @@ -31,13 +32,15 @@ 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(isValidRelayURL); // 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 +48,18 @@ 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(isValidRelayURL); // 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(isValidRelayURL); // Extract first "p" tag as author hint using applesauce helper const authorHint = getTagValue(event, "p");