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
This commit is contained in:
Claude
2026-01-27 09:36:42 +00:00
parent 3f3ebcf5f6
commit 15542d393e
3 changed files with 146 additions and 5 deletions

View File

@@ -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);
});
});
});

View File

@@ -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

View File

@@ -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");