diff --git a/AGENTS.md b/AGENTS.md index 081425f5..2866cd1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -409,6 +409,40 @@ Without filtering approvals by the moderator list, anyone could publish kind 455 Author filtering is not needed for public user-generated content where anyone should be able to post (kind 1 notes, reactions, discovery queries, public feeds, etc.). +#### Sanitizing URLs from Event Data + +**CRITICAL**: Any URL extracted from Nostr event tags or metadata fields (e.g. `r`, `url`, `repository`, `web`, `source` tags, or `metadata.website`) is **untrusted user input**. A malicious actor can publish an event containing a `javascript:` URI, which would execute arbitrary code if placed directly into an `href` attribute, `window.open()`, or `openUrl()` call. + +**Always use `sanitizeUrl()`** from `@/lib/sanitizeUrl` before passing any event-sourced URL to a navigable context: + +```typescript +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +// Single URL — returns the normalised href, or undefined if not valid https +const url = sanitizeUrl(getTag(event.tags, 'url')); +if (url) { + // safe to use in href, openUrl(), etc. +} + +// Array of URLs — filter out invalid entries +const links = getAllTags(event.tags, 'r') + .map(([, v]) => sanitizeUrl(v)) + .filter((v): v is string => !!v); +``` + +`sanitizeUrl` accepts `string | undefined | null` and returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, etc.) return `undefined`. + +**When sanitization is required:** +- Any tag value used in `` (e.g. `r`, `url`, `repository`, `web`, `source` tags) +- Any tag value passed to `openUrl()` or `window.open()` +- `metadata.website` or other `NostrMetadata` string fields used as navigation targets + +**When sanitization is NOT required:** +- URLs extracted by regex that already constrains the protocol (e.g. `NoteContent` tokeniser matches only `https?://`) +- URLs used only as `` (browsers do not execute `javascript:` in image src) +- Hardcoded or application-generated URLs (relay configs, internal routes, etc.) +- URLs displayed as plain text without being placed in a navigable attribute + ### The `useNostr` Hook The `useNostr` hook returns an object containing a `nostr` property, with `.query()` and `.event()` methods for querying and publishing Nostr events respectively. diff --git a/src/components/AppHandlerContent.tsx b/src/components/AppHandlerContent.tsx index 2eac3a91..cbd531d8 100644 --- a/src/components/AppHandlerContent.tsx +++ b/src/components/AppHandlerContent.tsx @@ -9,6 +9,7 @@ import { NsitePreviewDialog } from '@/components/NsitePreviewDialog'; import { Skeleton } from '@/components/ui/skeleton'; import { useAddrEvent } from '@/hooks/useEvent'; import { NostrURI } from '@/lib/NostrURI'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; /** Get a tag value by name. */ @@ -106,7 +107,7 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { const about = metadata.about; const picture = metadata.picture; const banner = metadata.banner; - const websiteUrl = getWebsiteUrl(event.tags, metadata); + const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata)); const hashtags = getAllTags(event.tags, 't'); const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]); diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 7026e0bf..506055d8 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -34,6 +34,7 @@ import { usePublishRSVP } from '@/hooks/usePublishRSVP'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; // --- Helpers --- @@ -159,7 +160,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const location = locationRaw ? parseLocation(locationRaw) : undefined; const summary = getTag(event.tags, 'summary'); const hashtags = getAllTags(event.tags, 't').map(([, v]) => v).filter(Boolean); - const links = getAllTags(event.tags, 'r').map(([, v]) => v).filter(Boolean); + const links = getAllTags(event.tags, 'r').map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v); const eventCoord = useMemo(() => getEventCoord(event), [event]); const dateStr = useMemo(() => formatDetailDate(event), [event]); diff --git a/src/components/FileMetadataContent.tsx b/src/components/FileMetadataContent.tsx index a5966dab..1f1344be 100644 --- a/src/components/FileMetadataContent.tsx +++ b/src/components/FileMetadataContent.tsx @@ -10,6 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { getDisplayName } from '@/lib/getDisplayName'; import { genUserName } from '@/lib/genUserName'; import { getAvatarShape } from '@/lib/avatarShape'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; /** Extract the first value of a tag by name. */ function getTag(tags: string[][], name: string): string | undefined { @@ -75,7 +76,7 @@ interface FileMetadataContentProps { * rounded card below it (similar to YouTube's description box). */ export function FileMetadataContent({ event, compact }: FileMetadataContentProps) { - const url = getTag(event.tags, 'url'); + const url = sanitizeUrl(getTag(event.tags, 'url')); const mime = getTag(event.tags, 'm') ?? ''; const alt = getTag(event.tags, 'alt'); const webxdcId = getTag(event.tags, 'webxdc'); diff --git a/src/components/GitRepoCard.tsx b/src/components/GitRepoCard.tsx index 90f60440..d8599987 100644 --- a/src/components/GitRepoCard.tsx +++ b/src/components/GitRepoCard.tsx @@ -3,6 +3,7 @@ import { BookMarked, Copy, Check, ExternalLink, Globe, Wand2 } from "lucide-reac import { useState } from "react"; import { Button } from "@/components/ui/button"; import { openUrl } from "@/lib/downloadFile"; +import { sanitizeUrl } from "@/lib/sanitizeUrl"; import { NostrURI } from "@/lib/NostrURI"; interface GitRepoCardProps { @@ -23,7 +24,7 @@ function getFaviconUrl(webUrl: string): string | undefined { export function GitRepoCard({ event }: GitRepoCardProps) { const name = event.tags.find(([n]) => n === "name")?.[1]; const description = event.tags.find(([n]) => n === "description")?.[1]; - const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => v); + const webUrls = event.tags.filter(([n]) => n === "web").map(([, v]) => sanitizeUrl(v)).filter((v): v is string => !!v); const isPersonalFork = event.tags.some( ([n, v]) => n === "t" && v === "personal-fork", ); diff --git a/src/components/NsiteCard.tsx b/src/components/NsiteCard.tsx index 39d9ec05..bb6f9e12 100644 --- a/src/components/NsiteCard.tsx +++ b/src/components/NsiteCard.tsx @@ -8,6 +8,7 @@ import { NsitePreviewDialog } from "@/components/NsitePreviewDialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useLinkPreview } from "@/hooks/useLinkPreview"; import { getNsiteSubdomain } from "@/lib/nsiteSubdomain"; +import { sanitizeUrl } from "@/lib/sanitizeUrl"; import { cn } from "@/lib/utils"; interface NsiteCardProps { @@ -24,7 +25,7 @@ export function NsiteCard({ event }: NsiteCardProps) { const title = event.tags.find(([n]) => n === "title")?.[1]; const description = event.tags.find(([n]) => n === "description")?.[1]; const dTag = event.tags.find(([n]) => n === "d")?.[1]; - const sourceUrl = event.tags.find(([n]) => n === "source")?.[1]; + const sourceUrl = sanitizeUrl(event.tags.find(([n]) => n === "source")?.[1]); const pathTags = event.tags.filter(([n]) => n === "path"); const serverTags = event.tags.filter(([n]) => n === "server"); diff --git a/src/components/ZapstoreAppContent.tsx b/src/components/ZapstoreAppContent.tsx index 4be4da4f..8fd6917f 100644 --- a/src/components/ZapstoreAppContent.tsx +++ b/src/components/ZapstoreAppContent.tsx @@ -5,6 +5,7 @@ import { ChevronLeft, ChevronRight, ExternalLink, GitFork, Globe, Package, Shiel import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog'; @@ -243,8 +244,8 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps) const platforms = getAllTags(event.tags, 'f'); const uniquePlatforms = useMemo(() => getUniquePlatforms(platforms), [platforms]); const hashtags = getAllTags(event.tags, 't'); - const websiteUrl = getTag(event.tags, 'url'); - const repoUrl = getTag(event.tags, 'repository'); + const websiteUrl = sanitizeUrl(getTag(event.tags, 'url')); + const repoUrl = sanitizeUrl(getTag(event.tags, 'repository')); const license = getTag(event.tags, 'license'); const appId = getTag(event.tags, 'd'); diff --git a/src/components/ZapstoreReleaseContent.tsx b/src/components/ZapstoreReleaseContent.tsx index 7de072d0..ae5d7439 100644 --- a/src/components/ZapstoreReleaseContent.tsx +++ b/src/components/ZapstoreReleaseContent.tsx @@ -25,6 +25,7 @@ import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { ZAPSTORE_RELAY } from '@/lib/appRelays'; import { openUrl } from '@/lib/downloadFile'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; /** Sanitize schema allowing only the subset needed for a CHANGELOG. */ const CHANGELOG_SANITIZE_SCHEMA = { @@ -203,7 +204,7 @@ function useReleaseApp(appIdentifier: string | undefined, releasePubkey: string) /** Single asset download row. */ function AssetRow({ event }: { event: NostrEvent }) { const mime = getTag(event.tags, 'm') ?? ''; - const url = getTag(event.tags, 'url'); + const url = sanitizeUrl(getTag(event.tags, 'url')); const version = getTag(event.tags, 'version'); const size = formatSize(getTag(event.tags, 'size')); const platforms = getAllTags(event.tags, 'f'); @@ -561,7 +562,7 @@ interface ZapstoreAssetContentProps { /** Renders a kind 3063 Zapstore software asset event. */ export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentProps) { const mime = getTag(event.tags, 'm') ?? ''; - const url = getTag(event.tags, 'url'); + const url = sanitizeUrl(getTag(event.tags, 'url')); const version = getTag(event.tags, 'version'); const size = formatSize(getTag(event.tags, 'size')); const appIdentifier = getTag(event.tags, 'i'); diff --git a/src/hooks/useUserStatus.ts b/src/hooks/useUserStatus.ts index 4beb2e45..b1b2b6fd 100644 --- a/src/hooks/useUserStatus.ts +++ b/src/hooks/useUserStatus.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + export interface UserStatus { /** The status text, or null if no status / expired / cleared. */ status: string | null; @@ -44,7 +46,7 @@ export function useUserStatus(pubkey: string | undefined): UserStatus & { isLoad const content = event.content.trim(); if (!content) return { status: null, url: null }; - const url = event.tags.find(([n]) => n === 'r')?.[1] ?? null; + const url = sanitizeUrl(event.tags.find(([n]) => n === 'r')?.[1]) ?? null; return { status: content, url }; }, diff --git a/src/lib/sanitizeUrl.ts b/src/lib/sanitizeUrl.ts new file mode 100644 index 00000000..a892a9f4 --- /dev/null +++ b/src/lib/sanitizeUrl.ts @@ -0,0 +1,21 @@ +/** + * Validate that a string is a well-formed HTTPS URL. + * + * Returns the normalised `href` when valid, or `undefined` otherwise. + * This **must** be used whenever a URL originates from untrusted Nostr + * event data (tags, metadata fields, etc.) and will be placed into an + * `href`, `window.open()`, or `openUrl()` call. Without this check a + * malicious `javascript:` URI could execute arbitrary code. + */ +export function sanitizeUrl(raw: string | undefined | null): string | undefined { + if (!raw) return undefined; + try { + const parsed = new URL(raw); + if (parsed.protocol === 'https:') { + return parsed.href; + } + } catch { + // not a valid URL + } + return undefined; +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 5045cab3..94363200 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -102,8 +102,10 @@ import { SubHeaderBar } from '@/components/SubHeaderBar'; import { useActiveTabIndicator } from '@/components/SubHeaderBarContext'; import { TabButton } from '@/components/TabButton'; import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; -import { cn } from '@/lib/utils'; import type { AddrCoords } from '@/hooks/useEvent'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; +import { cn } from '@/lib/utils'; + import type { FeedItem } from '@/lib/feedUtils'; import type { NostrEvent } from '@nostrify/nostrify'; import QRCode from 'qrcode'; @@ -2187,11 +2189,11 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; {metadata?.nip05 && ( )} - {metadata?.website && ( + {metadata?.website && sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`) && (