Harden against malformed untrusted data that crashes pages

- ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:,
  iso3166:) threw TypeError crashing the page; now URL is only created
  for url-type content and #-prefixed strings are used for other types
- ExternalContentPage & RelayPage: decodeURIComponent on malformed
  percent-encoded URL params threw URIError; now wrapped in try/catch
- useMastodonPost: new URL() on invalid URLs inside queryFn now returns
  null instead of letting the error propagate
- colorUtils/themeEvent: malformed hex color values from theme events
  produced NaN CSS variables; added isValidHex guard in parseColorTags
This commit is contained in:
Chad Curtis
2026-04-18 04:50:34 -05:00
parent 36373400f8
commit 363e39d72c
5 changed files with 31 additions and 14 deletions
+2 -1
View File
@@ -55,7 +55,8 @@ export function useMastodonPost(url: string) {
return useQuery({
queryKey: ['mastodon-post', url],
queryFn: async ({ signal }): Promise<ExternalPostData | null> => {
const parsed = new URL(url);
let parsed: URL;
try { parsed = new URL(url); } catch { return null; }
const origin = parsed.origin;
// Extract status ID from the last path segment: /@user/123456
+5
View File
@@ -39,6 +39,11 @@ export function rgbToHsl(r: number, g: number, b: number): { h: number; s: numbe
return { h: h * 360, s: s * 100, l: l * 100 };
}
/** Check whether a string looks like a valid hex color (#RGB, #RRGGBB, or without #). */
export function isValidHex(hex: string): boolean {
return /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex);
}
/** Convert hex color (#RRGGBB or #RGB) to RGB. */
export function hexToRgb(hex: string): [number, number, number] {
hex = hex.replace('#', '');
+2 -1
View File
@@ -1,6 +1,6 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { CoreThemeColors, ThemeConfig, ThemeFont, ThemeBackground } from '@/themes';
import { hslStringToHex, hexToHslString } from '@/lib/colorUtils';
import { hslStringToHex, hexToHslString, isValidHex } from '@/lib/colorUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
// ─── Kind Constants ───────────────────────────────────────────────────
@@ -39,6 +39,7 @@ function parseColorTags(tags: string[][]): CoreThemeColors | null {
const primaryHex = colorMap.get('primary');
if (!bgHex || !textHex || !primaryHex) return null;
if (!isValidHex(bgHex) || !isValidHex(textHex) || !isValidHex(primaryHex)) return null;
return {
background: hexToHslString(bgHex),
+19 -11
View File
@@ -154,7 +154,7 @@ export function ExternalContentPage() {
if (!rawUri) return '';
// If the wildcard param looks already encoded (no "://" present), decode it.
if (!rawUri.includes('://')) {
return decodeURIComponent(rawUri);
try { return decodeURIComponent(rawUri); } catch { return rawUri; }
}
// Otherwise it's a bare URL — reattach any query string the browser separated out.
return rawUri + location.search;
@@ -179,12 +179,20 @@ export function ExternalContentPage() {
useSeoMeta({ title: content ? (resolvedTitle ? `${resolvedTitle} | ${config.appName}` : seoTitle(content, config.appName)) : `External Content | ${config.appName}` });
// Build the NIP-73 identifier for comments.
// For URLs, the raw URL is used. For others, the full prefixed identifier.
const commentRoot = useMemo(() => {
if (!content) return undefined;
return new URL(content.value);
// For URLs, a URL object is used. For others (isbn:, iso3166:, etc.) a #-prefixed string
// is passed to useComments for querying but cannot be used with ComposeBox/ReplyComposeModal.
const commentRootUrl = useMemo((): URL | undefined => {
if (!content || content.type !== 'url') return undefined;
try { return new URL(content.value); } catch { return undefined; }
}, [content]);
const commentRootId = useMemo((): `#${string}` | undefined => {
if (!content || content.type === 'url') return undefined;
return `#${content.value}` as `#${string}`;
}, [content]);
const commentRoot: URL | `#${string}` | undefined = commentRootUrl ?? commentRootId;
const { muteItems } = useMuteList();
const { data: commentsData, isLoading: commentsLoading } = useComments(commentRoot, 500);
@@ -229,7 +237,7 @@ export function ExternalContentPage() {
onFabClick: openCompose,
});
if (!content || !uri || !commentRoot) {
if (!content || !uri) {
return <NotFound />;
}
@@ -271,20 +279,20 @@ export function ExternalContentPage() {
<ExternalActionBar content={content} />
{/* Comment compose dialog (opened via FAB) */}
<ReplyComposeModal event={commentRoot} open={composeOpen} onOpenChange={setComposeOpen} />
{commentRootUrl && <ReplyComposeModal event={commentRootUrl} open={composeOpen} onOpenChange={setComposeOpen} />}
{/* ISBN pages get a tabbed interface with Comments + Reviews */}
{content.type === 'isbn' ? (
<BookContentTabs
isbn={content.value.replace('isbn:', '')}
commentRoot={commentRoot}
commentRoot={commentRootUrl}
orderedReplies={orderedReplies}
commentsLoading={commentsLoading}
/>
) : (
<>
{/* Inline compose box */}
<ComposeBox compact replyTo={commentRoot} />
{commentRootUrl && <ComposeBox compact replyTo={commentRootUrl} />}
{/* Threaded comments list */}
<div>
@@ -346,7 +354,7 @@ function CommentsEmptyState() {
interface BookContentTabsProps {
isbn: string;
commentRoot: URL;
commentRoot: URL | undefined;
orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>;
commentsLoading: boolean;
}
@@ -376,7 +384,7 @@ function BookContentTabs({ isbn, commentRoot, orderedReplies, commentsLoading }:
<TabsContent value="comments" className="mt-0">
{/* Inline compose box */}
<ComposeBox compact replyTo={commentRoot} />
{commentRoot && <ComposeBox compact replyTo={commentRoot} />}
{/* Threaded comments list */}
<div>
+3 -1
View File
@@ -53,7 +53,9 @@ export function RelayPage() {
const relayUrl = useMemo(() => {
if (!rawParam) return undefined;
// If the wildcard param has no "://", it's encoded — decode it.
const url = rawParam.includes('://') ? rawParam : decodeURIComponent(rawParam);
let decoded: string;
try { decoded = decodeURIComponent(rawParam); } catch { decoded = rawParam; }
const url = rawParam.includes('://') ? rawParam : decoded;
if (url.startsWith('wss://') || url.startsWith('ws://')) {
return url;
}