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