Skip to content
Open
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
25 changes: 25 additions & 0 deletions src/components/SafeHtml.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<template>
<component :is="tag" v-if="sanitizedHtml" v-html="sanitizedHtml" />
<slot v-else />
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { sanitizeHtml } from '../utils/sanitizeHtml'

const props = withDefaults(
defineProps<{
html?: string
tag?: string
}>(),
{
html: '',
tag: 'div',
},
)

const sanitizedHtml = computed(() => {
if (!props.html) return ''
return sanitizeHtml(props.html)
})
</script>
6 changes: 5 additions & 1 deletion src/components/content/DirectoryHub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
getDirectoryComposioStatus,
Expand Down Expand Up @@ -876,6 +876,10 @@ const expandedMcpNames = ref<Set<string>>(new Set())
const toast = ref<{ text: string; type: 'success' | 'error' } | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
let composioSearchTimer: ReturnType<typeof setTimeout> | null = null
onBeforeUnmount(() => {
if (toastTimer) clearTimeout(toastTimer)
if (composioSearchTimer) clearTimeout(composioSearchTimer)
})
Comment on lines +879 to +882

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Cleanup is incomplete without a disposed guard.

This clears timers that already exist, but async actions in this component can still finish after unmount and call showToast(), which creates a brand-new timeout on a destroyed instance. Guard showToast/post-await state writes with an isUnmounted flag so teardown cannot recreate the leak you’re trying to fix.

🤖 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 `@src/components/content/DirectoryHub.vue` around lines 879 - 882, The timer
cleanup in DirectoryHub.vue is incomplete because async work can still call
showToast() or mutate state after unmount. Add a component-level disposed flag
such as isUnmounted, set it in onBeforeUnmount(), and have showToast plus any
post-await state updates in this component check that flag before creating new
timeouts or writing state. Reference the existing showToast function and the
onBeforeUnmount teardown so the guard covers all late-firing async paths.

let isComposioLoadQueued = false

const activeCopy = computed(() => tabs.find((tab) => tab.id === activeTab.value) ?? tabs[0])
Expand Down
3 changes: 2 additions & 1 deletion src/components/content/SkillDetailModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@

<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { sanitizeHtml } from '../../utils/sanitizeHtml'
import { useUiLanguage } from '../../composables/useUiLanguage'
import IconTablerX from '../icons/IconTablerX.vue'

Expand Down Expand Up @@ -144,7 +145,7 @@ const renderedReadme = computed(() => {
const raw = readmeContent.value
if (!raw) return ''
const withoutFrontmatter = raw.replace(/^---[\s\S]*?---\s*/, '')
return simpleMarkdown(withoutFrontmatter)
return sanitizeHtml(simpleMarkdown(withoutFrontmatter))
})

function simpleMarkdown(md: string): string {
Expand Down
5 changes: 4 additions & 1 deletion src/components/content/SkillsHub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
</template>

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import IconTablerChevronRight from '../icons/IconTablerChevronRight.vue'
import SkillCard from './SkillCard.vue'
import SkillDetailModal, { type HubSkill } from './SkillDetailModal.vue'
Expand Down Expand Up @@ -173,6 +173,9 @@ const actionSkillKey = ref('')
const isInstallActionInFlight = ref(false)
const isUninstallActionInFlight = ref(false)
let toastTimer: ReturnType<typeof setTimeout> | null = null
onBeforeUnmount(() => {
if (toastTimer) clearTimeout(toastTimer)
})
Comment on lines +176 to +178

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The timer can still be recreated after unmount.

onBeforeUnmount only cancels the current handle. If an in-flight install/search/toggle request resolves later, its showToast() call will schedule a new timeout after teardown. Please pair this with an isUnmounted guard in showToast (and any other post-await state updates) to actually close the leak.

🤖 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 `@src/components/content/SkillsHub.vue` around lines 176 - 178, The toast
cleanup in SkillsHub is incomplete because onBeforeUnmount only clears the
current timer, but async handlers can still call showToast() after teardown and
recreate it. Add an isUnmounted guard in showToast and any post-await flows in
the same component so no new timeout or state update is scheduled once
unmounted. Use the existing showToast and onBeforeUnmount logic in SkillsHub.vue
as the places to wire this guard and prevent late resolves from re-arming the
timer.

const { t } = useUiLanguage()
const { buildFeedbackMailto, feedbackMailtoBase, recordVisibleFailure } = useFeedbackDiagnostics()
const feedbackMailto = feedbackMailtoBase()
Expand Down
10 changes: 8 additions & 2 deletions src/components/content/ThreadConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,7 @@ import { updateThreadFileChanges } from '../../api/codexGateway'
import { useFeedbackDiagnostics } from '../../composables/useFeedbackDiagnostics'
import { useMobile } from '../../composables/useMobile'
import { copyTextToClipboard, copyTextWithSelectionFallback } from '../../utils/clipboard'
import { sanitizeHtml } from '../../utils/sanitizeHtml'

import IconTablerArrowBackUp from '../icons/IconTablerArrowBackUp.vue'
import IconTablerArrowUp from '../icons/IconTablerArrowUp.vue'
Expand Down Expand Up @@ -3626,6 +3627,10 @@ function renderHighlightedCodeAsHtmlUncached(language: string, value: string): s
}

function renderCachedHighlightedCodeAsHtml(language: string, value: string): string {
return sanitizeHtml(_renderCachedHighlightedCodeAsHtml(language, value))
}

function _renderCachedHighlightedCodeAsHtml(language: string, value: string): string {
const cacheKey = `${highlightCacheVersion.value}\u0000${normalizeCodeLanguage(language)}\u0000${language}\u0000${value}`
const cached = highlightHtmlCache.get(cacheKey)
if (cached !== undefined) {
Expand Down Expand Up @@ -3676,7 +3681,7 @@ function renderListItemParagraphsAsHtml(item: ListItem): string {
function renderListItemContentAsHtml(item: ListItem): string {
const paragraphsHtml = renderListItemParagraphsAsHtml(item)
const childrenHtml = item.children?.map((block) => renderMessageBlockAsHtml(block)).join('') ?? ''
return paragraphsHtml + childrenHtml
return sanitizeHtml(paragraphsHtml + childrenHtml)
}

function tableCellAlignmentStyle(alignment: TableAlignment): string {
Expand Down Expand Up @@ -3754,9 +3759,10 @@ function renderMarkdownBlocksAsHtml(text: string): string {
markdownHtmlCache.set(cacheKey, cached)
return cached.html
}
const html = parseMessageBlocks(text)
const rawHtml = parseMessageBlocks(text)
.map((block) => renderMessageBlockAsHtml(block))
.join('')
const html = sanitizeHtml(rawHtml)
return setBoundedCacheEntry(
Comment on lines 3759 to 3766

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. sanitizehtml missing manual tests 📘 Rule violation ⚙ Maintainability

This PR changes user-visible behavior by sanitizing multiple v-html markdown/HTML rendering entry
points and updates lifecycle behavior by clearing toastTimer/composioSearchTimer on unmount to
prevent timer leaks, but it does not include corresponding updates to manual test documentation
under tests/<domain>/. Without updated verification steps and expectations, these changes are
harder to validate and navigation/unmount regressions may go unnoticed.
Agent Prompt
## Issue description
The PR introduces behavior changes (sanitizing HTML/markdown rendering via `v-html` entry points and clearing timers on component unmount to prevent leaks), but it does not add or update the relevant manual test documentation under `tests/<domain>/`, making these changes harder to verify and more likely to regress unnoticed.

## Issue Context
PR Compliance ID 1 requires that behavior changes remain verifiable and discoverable via updates to domain manual test docs. The manual test docs should include prerequisites, exact actions/steps, expected results, and rollback/cleanup notes when applicable—especially for user-visible rendering changes and navigation/unmount scenarios.

## Fix Focus Areas
- tests/chat-composer-rendering/index.md[7-43]
- tests/skills-plugins-integrations/index.md[7-37]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

markdownHtmlCache,
cacheKey,
Expand Down
71 changes: 71 additions & 0 deletions src/utils/sanitizeHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Lightweight HTML sanitizer using native browser DOMParser.
* Removes dangerous elements (script, iframe, object, embed, style) and
* event-handler attributes (onclick, onerror, etc.) from HTML strings.
*/

const ALLOWED_TAGS = new Set([
'a', 'b', 'br', 'code', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'hr', 'i', 'img', 'li', 'mark', 'ol', 'p', 'pre', 's', 'small', 'span',
'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead',
'tr', 'ul', 'dl', 'dt', 'dd', 'blockquote', 'details', 'summary',
])

const DENIED_TAGS = new Set([
'script', 'style', 'iframe', 'frame', 'object', 'embed', 'param',
'applet', 'link', 'base', 'meta', 'noscript',
])

/**
* Sanitize an HTML string in the browser.
* Parses the HTML, strips forbidden tags and attributes, and returns clean HTML.
*/
export function sanitizeHtml(raw: string): string {
if (typeof DOMParser === 'undefined') return raw
Comment on lines +23 to +24

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

Make the sanitizer fail closed and normalize URL schemes.

Line 24 returns unsanitized HTML when DOMParser is unavailable, and Lines 55-64 miss payloads such as href="\njavascript:...". Escape on fallback and strip whitespace/control characters before checking dangerous schemes.

Proposed fix
 const DENIED_TAGS = new Set([
   'script', 'style', 'iframe', 'frame', 'object', 'embed', 'param',
   'applet', 'link', 'base', 'meta', 'noscript',
 ])
 
+const URL_ATTRIBUTES = new Set(['href', 'src', 'action', 'formaction', 'xlink:href'])
+
+function escapeHtml(raw: string): string {
+  return raw
+    .replace(/&/gu, '&amp;')
+    .replace(/</gu, '&lt;')
+    .replace(/>/gu, '&gt;')
+    .replace(/"/gu, '&quot;')
+    .replace(/'/gu, '&`#39`;')
+}
+
+function isUnsafeUrl(value: string): boolean {
+  const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/gu, '').toLowerCase()
+  return /^(?:javascript|vbscript|data):/u.test(normalized)
+}
+
 /**
  * Sanitize an HTML string in the browser.
  * Parses the HTML, strips forbidden tags and attributes, and returns clean HTML.
  */
 export function sanitizeHtml(raw: string): string {
-  if (typeof DOMParser === 'undefined') return raw
+  if (typeof DOMParser === 'undefined') return escapeHtml(raw)
   if (!raw) return ''
@@
-      const attrValue = attrs[i].value.toLowerCase()
-
       if (attrName.startsWith('on')) {
         el.removeAttribute(attrs[i].name)
         continue
       }
 
-      if (attrName === 'href' || attrName === 'src' || attrName === 'action') {
-        if (attrValue.startsWith('javascript:') || attrValue.startsWith('vbscript:') || attrValue.startsWith('data:')) {
+      if (URL_ATTRIBUTES.has(attrName)) {
+        if (isUnsafeUrl(attrs[i].value)) {
           el.removeAttribute(attrs[i].name)
         }
       }

Also applies to: 51-65

🤖 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 `@src/utils/sanitizeHtml.ts` around lines 23 - 24, The fallback in sanitizeHtml
currently returns the original HTML when DOMParser is unavailable, so make it
fail closed by escaping or neutralizing the input instead of passing it through
unchanged. In sanitizeHtml and the URL attribute checks around the scheme
validation logic, normalize candidate URLs by stripping whitespace and control
characters before testing for dangerous schemes, so payloads like href with
embedded newlines or tabs are caught consistently. Use the existing sanitizeHtml
flow and its attribute filtering helpers to apply the fix in one place,
including the href/src scheme handling path.

if (!raw) return ''

const doc = new DOMParser().parseFromString(raw, 'text/html')

// Walk all elements and remove dangerous ones
const allElements = doc.body.querySelectorAll('*')
for (const el of allElements) {
const tag = el.tagName.toLowerCase()

if (DENIED_TAGS.has(tag)) {
el.remove()
continue
}

if (!ALLOWED_TAGS.has(tag)) {
// Remove unknown tags but keep their text content
const parent = el.parentNode
if (parent) {
while (el.firstChild) {
parent.insertBefore(el.firstChild, el)
}
el.remove()
}
continue
}

// Remove dangerous attributes (event handlers, javascript: URLs)
const attrs = el.attributes
for (let i = attrs.length - 1; i >= 0; i--) {
const attrName = attrs[i].name.toLowerCase()
const attrValue = attrs[i].value.toLowerCase()

if (attrName.startsWith('on')) {
el.removeAttribute(attrs[i].name)
continue
}

if (attrName === 'href' || attrName === 'src' || attrName === 'action') {
if (attrValue.startsWith('javascript:') || attrValue.startsWith('vbscript:') || attrValue.startsWith('data:')) {
el.removeAttribute(attrs[i].name)
}
}
Comment on lines +51 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Url scheme bypass 🐞 Bug ⛨ Security

sanitizeHtml() checks href/src/action with startsWith() on the raw attribute value without
trimming/normalizing, so values like " \njavascript:..." bypass the filter and remain in the output.
Because the sanitized result is injected via v-html (e.g., SkillDetailModal/ThreadConversation),
this can re-enable XSS via crafted links/images.
Agent Prompt
## Issue description
`sanitizeHtml()` blocks `javascript:`, `vbscript:`, and `data:` URLs only when the attribute value *starts with* those strings. Because the current implementation does not trim/strip leading whitespace/control characters (and doesn’t otherwise normalize URLs), dangerous schemes can bypass the check.

## Issue Context
The sanitizer output is used in `v-html` sinks (e.g., `SkillDetailModal.vue` and `ThreadConversation.vue`), so bypasses directly affect DOM insertion.

## Fix Focus Areas
- src/utils/sanitizeHtml.ts[51-66]

## Suggested fix
- Normalize attribute values before checking schemes (at minimum: `const normalized = attrs[i].value.trim().toLowerCase()`), and consider stripping ASCII control chars (`\u0000`-`\u001F`, `\u007F`) before evaluation.
- Prefer a protocol-based check using `new URL(value, base)` where applicable and reject any non-allowlisted protocols (e.g., allow `http:`, `https:`, `mailto:`, `tel:` for `href`; allow `http:`/`https:` for `src`).
- If you keep string checks, ensure they handle leading whitespace and mixed whitespace like `"\t\njavascript:"`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}
}

return doc.body.innerHTML
}