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:
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user