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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-27 10:59:26 +01:00
committed by GitHub
parent 3f3ebcf5f6
commit a28ffc1ec3
7 changed files with 195 additions and 13 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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