mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-14 17:36:59 +02:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user