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
This commit is contained in:
Claude
2026-01-27 09:46:39 +00:00
parent 15542d393e
commit 4dc55295f6
6 changed files with 58 additions and 14 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,10 +1,17 @@
import { normalizeURL as applesauceNormalizeURL } from "applesauce-core/helpers";
import { isSafeRelayURL } from "applesauce-core/helpers/relays";
// Re-export applesauce's fast relay URL check for use in hot paths
export { isSafeRelayURL };
/**
* 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
* - 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
*/
@@ -21,8 +28,14 @@ export function isValidRelayURL(url: unknown): url is string {
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 {
// Must be a valid URL structure
const parsed = new URL(trimmed);
// Protocol must be ws: or wss:

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,
@@ -16,7 +20,6 @@ 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
@@ -37,7 +40,9 @@ function extractRelayContext(event: NostrEvent): {
const rTags = event.tags
.filter((t) => t[0] === "r")
.map((t) => t[1])
.filter(isValidRelayURL);
.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
@@ -48,7 +53,9 @@ function extractRelayContext(event: NostrEvent): {
// v5: returns null for invalid tags instead of throwing
return pointer?.relays?.[0]; // First relay hint from the pointer
})
.filter(isValidRelayURL);
.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
@@ -59,7 +66,9 @@ function extractRelayContext(event: NostrEvent): {
const pointer = getAddressPointerFromATag(tag);
return pointer?.relays?.[0]; // First relay hint from the pointer
})
.filter(isValidRelayURL);
.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");