diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 2b1f48fc..6d1c7439 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -659,9 +659,9 @@ const AppLayout: React.FC = () => {
{t('landing.footer.network')}
@@ -670,7 +670,7 @@ const AppLayout: React.FC = () => { @@ -689,7 +689,7 @@ const AppLayout: React.FC = () => {
  • {t('landing.footer.forum')}
  • {t('landing.footer.discord')}
  • {t('landing.footer.telegram')}
  • -
  • {t('landing.footer.twitter')}
  • +
  • {t('landing.footer.twitter')}
  • diff --git a/web/src/components/forum/DiscussionThread.tsx b/web/src/components/forum/DiscussionThread.tsx index e5bcbc81..c39483da 100644 --- a/web/src/components/forum/DiscussionThread.tsx +++ b/web/src/components/forum/DiscussionThread.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { useWebSocket } from '@/contexts/WebSocketContext'; import { useToast } from '@/hooks/use-toast'; +import DOMPurify from 'dompurify'; interface Comment { id: string; @@ -329,8 +330,18 @@ export function DiscussionThread({ proposalId }: { proposalId: string }) { ); + // SECURITY: user-supplied comment content. Escape all HTML FIRST so raw markup + // (e.g. ) becomes inert text, THEN apply the markdown + // transforms, THEN run the result through DOMPurify restricted to a safe tag/attr + // allow-list with http(s)-only hrefs. This blocks stored XSS (script/onerror/ + // javascript: URLs) while keeping basic markdown rendering. const parseMarkdown = (text: string): string => { - return text + const escaped = (text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const html = escaped .replace(/^### (.*$)/gim, '

    $1

    ') .replace(/^## (.*$)/gim, '

    $1

    ') .replace(/^# (.*$)/gim, '

    $1

    ') @@ -339,6 +350,11 @@ export function DiscussionThread({ proposalId }: { proposalId: string }) { .replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '$1') .replace(/^> (.*$)/gim, '
    $1
    ') .replace(/\n/gim, '
    '); + return DOMPurify.sanitize(html, { + ALLOWED_TAGS: ['h1', 'h2', 'h3', 'strong', 'em', 'a', 'blockquote', 'br', 'p'], + ALLOWED_ATTR: ['href', 'class'], + ALLOWED_URI_REGEXP: /^https?:\/\//i, + }); }; return ( diff --git a/web/src/components/landing/LandingPageDesktop.tsx b/web/src/components/landing/LandingPageDesktop.tsx index 56a4b9a0..7c7d18d9 100644 --- a/web/src/components/landing/LandingPageDesktop.tsx +++ b/web/src/components/landing/LandingPageDesktop.tsx @@ -1148,9 +1148,9 @@ const LandingPageDesktop: React.FC = () => {
    {t('landing.footer.network')}
    @@ -1159,7 +1159,7 @@ const LandingPageDesktop: React.FC = () => { @@ -1178,7 +1178,7 @@ const LandingPageDesktop: React.FC = () => {
  • {t('landing.footer.forum')}
  • {t('landing.footer.discord')}
  • {t('landing.footer.telegram')}
  • -
  • {t('landing.footer.twitter')}
  • +
  • {t('landing.footer.twitter')}
  • diff --git a/web/src/pages/Explorer.tsx b/web/src/pages/Explorer.tsx index a828ad93..6659d23f 100644 --- a/web/src/pages/Explorer.tsx +++ b/web/src/pages/Explorer.tsx @@ -860,7 +860,7 @@ const Explorer: React.FC = () => { {t('explorer.telemetry')} diff --git a/web/src/pages/Forum.tsx b/web/src/pages/Forum.tsx index 56f15992..c6465e5d 100644 --- a/web/src/pages/Forum.tsx +++ b/web/src/pages/Forum.tsx @@ -429,7 +429,7 @@ const Forum: React.FC = () => { - + → {t('forum.govDashboard')}