Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions web/default/src/components/html-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { cn } from '@/lib/utils'

interface HtmlContentProps {
content: string
className?: string
}

export function HtmlContent(props: HtmlContentProps) {
return (
<div
className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)}
dangerouslySetInnerHTML={{ __html: props.content }}
/>
Comment on lines +26 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Sanitize props.content before injecting it into the DOM.

This branch now renders backend-provided page content verbatim on public surfaces. A saved <script> tag or inline event handler here becomes stored XSS for every visitor who hits the HTML path.

Suggested change
+import DOMPurify from 'dompurify'
 import { cn } from '`@/lib/utils`'
@@
 export function HtmlContent(props: HtmlContentProps) {
+  const sanitizedContent = DOMPurify.sanitize(props.content)
+
   return (
     <div
       className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)}
-      dangerouslySetInnerHTML={{ __html: props.content }}
+      dangerouslySetInnerHTML={{ __html: sanitizedContent }}
     />
   )
 }

As per coding guidelines, "Do not rely on client-side checks alone for auth, permissions, validation, or security-sensitive data; avoid hardcoded secrets and minimize dangerouslySetInnerHTML usage."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function HtmlContent(props: HtmlContentProps) {
return (
<div
className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)}
dangerouslySetInnerHTML={{ __html: props.content }}
/>
import DOMPurify from 'dompurify'
import { cn } from '`@/lib/utils`'
export function HtmlContent(props: HtmlContentProps) {
const sanitizedContent = DOMPurify.sanitize(props.content)
return (
<div
className={cn('prose prose-neutral dark:prose-invert max-w-none', props.className)}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
)
}
🧰 Tools
🪛 ast-grep (0.44.0)

[warning] 29-29: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation

(react-unsafe-html-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/components/html-content.tsx` around lines 26 - 31, The
HtmlContent component currently injects props.content directly via
dangerouslySetInnerHTML, which can expose public pages to stored XSS. Update
HtmlContent to sanitize or safely transform the incoming HTML before rendering,
keeping the change localized to HtmlContent and its props.content flow so
untrusted backend content is not inserted verbatim into the DOM.

Sources: Coding guidelines, Linters/SAST tools

)
}
43 changes: 32 additions & 11 deletions web/default/src/components/notification-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import type { TFunction } from 'i18next'
import { Bell, Megaphone } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { RichContent } from '@/components/rich-content'
import { getAnnouncementColorClass } from '@/lib/colors'
import { formatDateTimeObject } from '@/lib/time'
import { cn } from '@/lib/utils'
Expand All @@ -31,7 +32,6 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Markdown } from '@/components/ui/markdown'
import {
Popover,
PopoverContent,
Expand All @@ -44,6 +44,7 @@ import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'

interface AnnouncementItem {
id?: number | string
type?: string
content?: string
extra?: string
Expand Down Expand Up @@ -72,8 +73,9 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string {
const pubDate = new Date(publishDate)

// If invalid date, return original string
if (isNaN(pubDate.getTime()))
if (Number.isNaN(pubDate.getTime())) {
return typeof publishDate === 'string' ? publishDate : ''
}

const diffMs = now.getTime() - pubDate.getTime()
const diffSeconds = Math.floor(diffMs / 1000)
Expand All @@ -89,26 +91,31 @@ function getRelativeTime(publishDate: string | Date, t: TFunction): string {

// Return relative time based on difference
if (diffSeconds < 60) return t('Just now')
if (diffMinutes < 60)
if (diffMinutes < 60) {
return diffMinutes === 1
? t('1 minute ago')
: t('{{count}} minutes ago', { count: diffMinutes })
if (diffHours < 24)
}
if (diffHours < 24) {
return diffHours === 1
? t('1 hour ago')
: t('{{count}} hours ago', { count: diffHours })
if (diffDays < 7)
}
if (diffDays < 7) {
return diffDays === 1
? t('1 day ago')
: t('{{count}} days ago', { count: diffDays })
if (diffWeeks < 4)
}
if (diffWeeks < 4) {
return diffWeeks === 1
? t('1 week ago')
: t('{{count}} weeks ago', { count: diffWeeks })
if (diffMonths < 12)
}
if (diffMonths < 12) {
return diffMonths === 1
? t('1 month ago')
: t('{{count}} months ago', { count: diffMonths })
Comment on lines +109 to 117

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

28–29 day timestamps now render as 0 months ago.

When diffDays is 28 or 29, diffWeeks < 4 is false, but diffMonths is still 0, so the month branch produces a zero-count string. Keep the week branch active until 30 days.

🐛 Proposed fix
-  if (diffWeeks < 4) {
+  if (diffDays < 30) {
     return diffWeeks === 1
       ? t('1 week ago')
       : t('{{count}} weeks ago', { count: diffWeeks })
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (diffWeeks < 4) {
return diffWeeks === 1
? t('1 week ago')
: t('{{count}} weeks ago', { count: diffWeeks })
if (diffMonths < 12)
}
if (diffMonths < 12) {
return diffMonths === 1
? t('1 month ago')
: t('{{count}} months ago', { count: diffMonths })
if (diffDays < 30) {
return diffWeeks === 1
? t('1 week ago')
: t('{{count}} weeks ago', { count: diffWeeks })
}
if (diffMonths < 12) {
return diffMonths === 1
? t('1 month ago')
: t('{{count}} months ago', { count: diffMonths })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/components/notification-popover.tsx` around lines 109 - 117,
The relative-time logic in notification-popover should not fall through to the
month branch for 28–29 day values, because the current condition around the
week/month split lets diffMonths be 0 and renders “0 months ago.” Update the
branching in the timestamp formatting helper in notification-popover.tsx so the
week-based path stays active until 30 days (for example by using a day threshold
instead of only diffWeeks), and ensure the month branch only handles 30+ day
values.

}
if (diffYears < 2) return t('1 year ago')

// Over 2 years, show specific date
Expand All @@ -129,6 +136,19 @@ function AnnouncementDot({ type }: { type?: string }) {
)
}

function getAnnouncementRenderKey(announcement: AnnouncementItem): string {
if (announcement.id !== undefined && announcement.id !== null) {
return `id:${announcement.id}`
}

return JSON.stringify({
content: announcement.content ?? '',
extra: announcement.extra ?? '',
publishDate: announcement.publishDate ?? '',
type: announcement.type ?? '',
})
}

/**
* Empty state component
*/
Expand Down Expand Up @@ -184,7 +204,7 @@ function NoticeContent({

return (
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
<Markdown>{notice}</Markdown>
<RichContent breaks content={notice} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Don't render notice/announcement HTML through RichContent without sanitizing it first.

These fields used to stay on the Markdown renderer. With RichContent, any tag-like payload now flows into unsanitized dangerouslySetInnerHTML, which turns notice or announcement content into a stored XSS vector inside the notification UI.

As per coding guidelines, "Do not rely on client-side checks alone for auth, permissions, validation, or security-sensitive data; avoid hardcoded secrets and minimize dangerouslySetInnerHTML usage."

Also applies to: 262-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/components/notification-popover.tsx` at line 207, The
notification popover is rendering notice/announcement content through
RichContent without sanitization, which can pass unsafe HTML into
dangerouslySetInnerHTML. Update the notification-popover rendering path that
uses RichContent for notice and announcement fields to sanitize or escape the
content before rendering, or switch back to the safer Markdown rendering path if
appropriate. Make the fix in the shared notice/announcement rendering logic so
both usages are covered, and keep the change localized around the RichContent
usage in the notification-popover component.

Source: Coding guidelines

</ScrollArea>
)
}
Expand Down Expand Up @@ -221,6 +241,7 @@ function AnnouncementsContent({
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
<div className='flex flex-col'>
{announcements.map((item, idx) => {
const announcementKey = getAnnouncementRenderKey(item)
const publishDate = item.publishDate
? new Date(item.publishDate)
: null
Expand All @@ -232,18 +253,18 @@ function AnnouncementsContent({
: ''

return (
<div key={idx}>
<div key={announcementKey}>
<div className='py-3'>
<div className='flex items-start gap-3'>
<AnnouncementDot type={item.type} />
<div className='flex min-w-0 flex-1 flex-col gap-2'>
<div className='text-sm'>
<Markdown>{item.content || ''}</Markdown>
<RichContent breaks content={item.content || ''} />
</div>

{item.extra ? (
<div className='text-muted-foreground text-xs'>
<Markdown>{item.extra}</Markdown>
<RichContent breaks content={item.extra} />
</div>
) : null}

Expand Down
39 changes: 39 additions & 0 deletions web/default/src/components/rich-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
Copyright (C) 2023-2026 QuantumNous

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact support@quantumnous.com
*/
import { isLikelyHtml } from '@/lib/content-format'
import { HtmlContent } from '@/components/html-content'
import { Markdown } from '@/components/ui/markdown'

interface RichContentProps {
content: string
breaks?: boolean
className?: string
}

export function RichContent(props: RichContentProps) {
if (isLikelyHtml(props.content)) {
return <HtmlContent content={props.content} className={props.className} />
}

return (
<Markdown breaks={props.breaks} className={props.className}>
{props.content}
</Markdown>
)
}
45 changes: 27 additions & 18 deletions web/default/src/components/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { useMemo } from 'react'
import { cn } from '@/lib/utils'

interface MarkdownProps {
breaks?: boolean
children: string
className?: string
}
Expand Down Expand Up @@ -186,13 +187,13 @@ function renderMath(source: string, displayMode: boolean): string {
}

function replaceEmojiShortcodes(value: string): string {
return value.replace(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
return value.replaceAll(/:(?:smiley|star|fa-star|fa-gear):/g, (shortcode) => {
return emojiShortcodes[shortcode] ?? shortcode
})
}

function getTextUnits(value: string): number {
return Array.from(value).reduce((total, character) => {
return [...value].reduce((total, character) => {
if (/\s/.test(character)) {
return total + 0.5
}
Expand Down Expand Up @@ -372,7 +373,7 @@ function renderFlowDiagram(source: string): string {
const nodePositions = new Map(
nodes.map((node, index) => [node.id, getFlowNodeLayout(node, index, centerX)])
)
const lastNode = nodes.length > 0 ? nodePositions.get(nodes[nodes.length - 1].id) : undefined
const lastNode = nodes.length > 0 ? nodePositions.get(nodes.at(-1)?.id ?? '') : undefined
const height = Math.max(180, (lastNode?.y ?? 64) + (lastNode?.height ?? 40) / 2 + 54)
const renderedEdges = edges
.map((edge) => {
Expand Down Expand Up @@ -694,31 +695,39 @@ function addExternalLinkAttributes(html: string): string {
return template.innerHTML
}

function renderMarkdown(markdown: string): string {
const parsedHtml = markdownParser.parse(markdown, markdownOptions)
function renderMarkdown(markdown: string, breaks = false): string {
const parsedHtml = markdownParser.parse(markdown, {
...markdownOptions,
breaks,
})
const html = DOMPurify.sanitize(parsedHtml, sanitizeOptions)

return addExternalLinkAttributes(html)
}

export function Markdown(props: MarkdownProps) {
const html = useMemo(() => renderMarkdown(props.children), [props.children])
const html = useMemo(
() => renderMarkdown(props.children, props.breaks),
[props.breaks, props.children]
)

return (
<div
className={cn(
'prose prose-sm dark:prose-invert max-w-none',
'prose-headings:font-semibold prose-headings:tracking-tight',
'prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg',
'prose-p:leading-relaxed prose-p:my-2',
'prose-a:text-primary prose-a:no-underline hover:prose-a:underline',
'prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-muted prose-pre:border',
'prose-blockquote:border-l-primary prose-blockquote:bg-muted/50 prose-blockquote:py-1',
'prose-ul:my-2 prose-ol:my-2 prose-li:my-1',
'prose-table:border prose-thead:bg-muted',
'prose-td:border prose-th:border prose-td:px-3 prose-th:px-3',
'prose-img:rounded-lg prose-img:shadow-sm',
'[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:text-2xl [&_h1]:font-semibold',
'[&_h2]:mt-5 [&_h2]:mb-3 [&_h2]:text-xl [&_h2]:font-semibold',
'[&_h3]:mt-4 [&_h3]:mb-2 [&_h3]:text-lg [&_h3]:font-semibold',
'[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:font-semibold',
'[&_p]:my-2 [&_p]:leading-relaxed [&_strong]:font-semibold [&_em]:italic',
'[&_a]:text-primary [&_a]:underline hover:[&_a]:text-primary/80',
'[&_ol]:my-2 [&_ul]:my-2 [&_ol]:list-decimal [&_ul]:list-disc [&_ol]:pl-5 [&_ul]:pl-5 [&_li]:my-1 [&_li]:pl-1',
'[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-primary [&_blockquote]:bg-muted/50 [&_blockquote]:py-1 [&_blockquote]:pl-4',
'[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono',
'[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:border [&_pre]:bg-muted [&_pre]:p-3 [&_table]:my-4 [&_table]:block [&_table]:w-full [&_table]:overflow-x-auto',
'[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-sm',
'[&_thead]:bg-muted [&_th]:border [&_td]:border [&_th]:px-3 [&_td]:px-3 [&_th]:py-2 [&_td]:py-2 [&_th]:text-left',
'[&_hr]:my-6 [&_img]:my-4 [&_img]:max-w-full [&_img]:rounded-lg',
'[&_.katex-display]:my-4 [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden',
'[&_.markdown-page-break]:my-6 [&_.markdown-page-break]:border-dashed',
'[&_.markdown-diagram]:my-4 [&_.markdown-diagram]:overflow-x-auto [&_.markdown-diagram]:rounded-md [&_.markdown-diagram]:border [&_.markdown-diagram]:bg-background [&_.markdown-diagram]:p-4',
Expand All @@ -732,7 +741,7 @@ export function Markdown(props: MarkdownProps) {
'[&_.markdown-sequence-note]:fill-warning/20 [&_.markdown-sequence-note]:stroke-warning',
'[&_.markdown-sequence-note-text]:fill-foreground [&_.markdown-sequence-note-text]:text-xs',
'[&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
'[overflow-wrap:anywhere] break-words',
'[overflow-wrap:anywhere]',
props.className
)}
dangerouslySetInnerHTML={{ __html: html }}
Expand Down
34 changes: 8 additions & 26 deletions web/default/src/features/about/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
import { useQuery } from '@tanstack/react-query'
import { Construction } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Markdown } from '@/components/ui/markdown'
import { RichContent } from '@/components/rich-content'
import { Skeleton } from '@/components/ui/skeleton'
import { PublicLayout } from '@/components/layout'
import { isHttpUrl } from '@/lib/content-format'
import { getAboutContent } from './api'

function isValidUrl(value: string) {
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}

function isLikelyHtml(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}

function EmptyAboutState() {
const { t } = useTranslation()
const currentYear = new Date().getFullYear()
Expand Down Expand Up @@ -131,8 +119,7 @@ export function About() {

const rawContent = data?.data?.trim() ?? ''
const hasContent = rawContent.length > 0
const isUrl = hasContent && isValidUrl(rawContent)
const isHtml = hasContent && !isUrl && isLikelyHtml(rawContent)
const isUrl = hasContent && isHttpUrl(rawContent)

if (isLoading) {
return (
Expand Down Expand Up @@ -162,6 +149,7 @@ export function About() {
src={rawContent}
className='h-[calc(100vh-3.5rem)] w-full border-0'
title={t('About')}
sandbox='allow-forms allow-popups allow-popups-to-escape-sandbox allow-scripts'
/>
</PublicLayout>
)
Expand All @@ -170,16 +158,10 @@ export function About() {
return (
<PublicLayout>
<div className='mx-auto max-w-6xl px-4 py-8'>
{isHtml ? (
<div
className='prose prose-neutral dark:prose-invert max-w-none'
dangerouslySetInnerHTML={{ __html: rawContent }}
/>
) : (
<Markdown className='prose-neutral dark:prose-invert max-w-none'>
{rawContent}
</Markdown>
)}
<RichContent
content={rawContent}
className='prose-neutral dark:prose-invert max-w-none'
/>
</div>
</PublicLayout>
)
Expand Down
Loading