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')}