Sanitize all user-supplied URLs from Nostr events to prevent javascript: XSS

Add a shared sanitizeUrl() utility that validates URLs are well-formed
https: before they reach href attributes, window.open(), or openUrl().

Apply sanitization across all components that render untrusted URLs:
- CalendarEventDetailPage: r-tag links
- ZapstoreAppContent: url and repository tags
- ZapstoreReleaseContent: asset url tags passed to openUrl()
- AppHandlerContent: web handler tags and metadata.website
- NsiteCard: source tag
- GitRepoCard: web tag URLs passed to openUrl()
- FileMetadataContent: url tag used in download href
- ProfilePage: metadata.website (tighten weak startsWith check)
- useUserStatus: r-tag URL

Document sanitizeUrl usage in AGENTS.md for future agent use.
This commit is contained in:
Alex Gleason
2026-04-10 14:22:42 -05:00
parent 72268dfde6
commit 9d899cfe87
11 changed files with 79 additions and 13 deletions
+34
View File
@@ -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 `<a href={...}>` (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 `<img src={...}>` (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.
+2 -1
View File
@@ -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]);
+2 -1
View File
@@ -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]);
+2 -1
View File
@@ -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');
+2 -1
View File
@@ -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",
);
+2 -1
View File
@@ -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");
+3 -2
View File
@@ -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');
+3 -2
View File
@@ -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');
+3 -1
View File
@@ -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 };
},
+21
View File
@@ -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;
}
+5 -3
View File
@@ -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 && (
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey ?? ''} className="text-sm text-muted-foreground" />
)}
{metadata?.website && (
{metadata?.website && sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`) && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-0.5">
<Globe className="size-3.5 text-muted-foreground shrink-0" />
<a
href={metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`}
href={sanitizeUrl(metadata.website.startsWith('http') ? metadata.website : `https://${metadata.website}`)}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary hover:underline"