-
-
Notifications
You must be signed in to change notification settings - Fork 147
fix: prevent timer leaks and add HTML sanitization layer #206
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
354ff26
582cdf6
f505a64
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🤖 Prompt for AI Agents |
||
| const { t } = useUiLanguage() | ||
| const { buildFeedbackMailto, feedbackMailtoBase, recordVisibleFailure } = useFeedbackDiagnostics() | ||
| const feedbackMailto = feedbackMailtoBase() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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' | ||
|
|
@@ -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) { | ||
|
|
@@ -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 { | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. sanitizehtml missing manual tests 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
|
||
| markdownHtmlCache, | ||
| cacheKey, | ||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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, '&')
+ .replace(/</gu, '<')
+ .replace(/>/gu, '>')
+ .replace(/"/gu, '"')
+ .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 |
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Url scheme bypass 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
|
||
| } | ||
| } | ||
|
|
||
| return doc.body.innerHTML | ||
| } | ||
There was a problem hiding this comment.
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. GuardshowToast/post-awaitstate writes with anisUnmountedflag so teardown cannot recreate the leak you’re trying to fix.🤖 Prompt for AI Agents