personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

security(apps): route marked.parse through DOMPurify across apps tree

Closes the nine raw `marked.parse(...) → innerHTML` sites in the apps
tree by routing all markdown rendering through a canonical
`renderMarkdown(raw)` helper that wraps marked in DOMPurify.sanitize.
Promotes DOMPurify to a shell-level script include in
`convey/templates/app.html` so every app inherits it, and retires two
home-grown sanitizers whose threat models were narrower than
DOMPurify's.

Closed sites:
- apps/home/workspace.html (4 sites: narrative init, briefing
sections, skill detail, narrative refresh)
- apps/activities/_day.html (activity markdown output)
- apps/import/workspace.html (guided-flow step content)
- apps/import/_detail.html (imported content preview)
- apps/sol/workspace.html (2 sites: run output pane, finish-event
result)

Retired:
- apps/sol/workspace.html::sanitizeHtml (DOMParser allowlist)
- apps/import/_detail.html::sanitizeMarkdown (regex pre-filter)

Normalized apps/transcripts/workspace.html::renderMarkdown to the
canonical shape (which adds { breaks: true, gfm: true }, matching the
options every other call site already passed). Removed now-redundant
per-app DOMPurify includes in transcripts and reflections, and
cleaned adjacent dead code in apps/import/_detail.html (local marked
include, stray marked.setOptions, unused markedRenderer).

Extends the pattern shipped in 5382b346 (transcripts) and the
reflections hardening to the rest of the apps tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+38 -70
+5 -1
apps/activities/_day.html
··· 583 583 return d.innerHTML; 584 584 } 585 585 586 + function renderMarkdown(raw) { 587 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 588 + } 589 + 586 590 function fmtTime(ts) { 587 591 const d = new Date(ts); 588 592 let h = d.getHours(); ··· 857 861 858 862 let rendered; 859 863 if (data.format === 'md') { 860 - rendered = `<div class="rendered-markdown">${marked.parse(data.content, { breaks: true, gfm: true })}</div>`; 864 + rendered = `<div class="rendered-markdown">${renderMarkdown(data.content)}</div>`; 861 865 } else if (data.format === 'json') { 862 866 let pretty; 863 867 try { pretty = JSON.stringify(JSON.parse(data.content), null, 2); } catch (e) { pretty = data.content; }
+16 -11
apps/home/workspace.html
··· 1341 1341 var briefingSections = {{ briefing_sections|tojson|safe }}; 1342 1342 var skillContent = {{ skills_content|default({})|tojson|safe }}; 1343 1343 var sectionOrder = ['your_day', 'yesterday', 'forward_look', 'reading']; 1344 + 1345 + function renderMarkdown(raw) { 1346 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 1347 + } 1348 + 1344 1349 if (narrativeRaw) { 1345 1350 const el = document.getElementById('pulse-narrative-content'); 1346 - if (el) el.innerHTML = marked.parse(narrativeRaw, {breaks: true, gfm: true}); 1351 + if (el) el.innerHTML = renderMarkdown(narrativeRaw); 1347 1352 } 1348 1353 1349 1354 function renderBriefingSections(sections) { ··· 1355 1360 el.innerHTML = ''; 1356 1361 return; 1357 1362 } 1358 - el.innerHTML = marked.parse(raw, {breaks: true, gfm: true}); 1363 + el.innerHTML = renderMarkdown(raw); 1359 1364 if (key === 'reading') { 1360 1365 el.querySelectorAll('li').forEach(function(li) { 1361 1366 var strong = li.querySelector('strong'); ··· 1408 1413 renderBriefingSections(briefingSections); 1409 1414 1410 1415 function toggleSkillDetail(skillId) { 1411 - var detail = document.getElementById('skill-detail-' + skillId); 1412 - if (!detail) return; 1413 - if (detail.style.display === 'none') { 1414 - if (skillContent[skillId]) { 1415 - detail.innerHTML = marked.parse(skillContent[skillId], {breaks: true, gfm: true}); 1416 - } 1417 - detail.style.display = 'block'; 1418 - } else { 1416 + var detail = document.getElementById('skill-detail-' + skillId); 1417 + if (!detail) return; 1418 + if (detail.style.display === 'none') { 1419 + if (skillContent[skillId]) { 1420 + detail.innerHTML = renderMarkdown(skillContent[skillId]); 1421 + } 1422 + detail.style.display = 'block'; 1423 + } else { 1419 1424 detail.style.display = 'none'; 1420 1425 } 1421 1426 } ··· 1613 1618 .then(data => { 1614 1619 const el = document.getElementById('pulse-narrative-content'); 1615 1620 if (el && data.narrative_content) { 1616 - el.innerHTML = marked.parse(data.narrative_content, {breaks: true, gfm: true}); 1621 + el.innerHTML = renderMarkdown(data.narrative_content); 1617 1622 const hdr = document.querySelector('.pulse-narrative .pulse-section-header'); 1618 1623 if (hdr && data.narrative_header) hdr.textContent = data.narrative_header; 1619 1624 const meta = document.querySelector('.pulse-narrative .pulse-narrative-meta');
+5 -13
apps/import/_detail.html
··· 166 166 167 167 </div> 168 168 169 - <script src="{{ vendor_lib('marked') }}"></script> 170 169 <script> 171 - // Configure marked for safe rendering of imported content 172 - marked.setOptions({ breaks: true, gfm: true }); 173 - const markedRenderer = new marked.Renderer(); 174 - // Sanitize: strip raw HTML tags from markdown input to prevent XSS from user-imported content 175 - function sanitizeMarkdown(md) { 176 - return md.replace(/<(script|iframe|object|embed|form|input|textarea|button|select|style|link|meta)[^>]*>[\s\S]*?<\/\1>/gi, '') 177 - .replace(/<(script|iframe|object|embed|form|input|textarea|button|select|style|link|meta)[^>]*\/?>/gi, '') 178 - .replace(/\bon\w+\s*=\s*["'][^"']*["']/gi, '') 179 - .replace(/javascript\s*:/gi, 'blocked:'); 180 - } 181 - 182 170 // Tab switching 183 171 const tabs = document.querySelectorAll('.tab'); 184 172 const contents = document.querySelectorAll('.tab-content'); ··· 187 175 let contentSearch = ''; 188 176 let contentMonth = ''; 189 177 let searchDebounce = null; 178 + 179 + function renderMarkdown(raw) { 180 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 181 + } 190 182 191 183 tabs.forEach(tab => { 192 184 tab.addEventListener('click', () => { ··· 341 333 342 334 return data.content.map(part => { 343 335 if (part.type === 'markdown') { 344 - return `<div class="import-content-markdown">${marked.parse(sanitizeMarkdown(part.content))}</div>`; 336 + return `<div class="import-content-markdown">${renderMarkdown(part.content)}</div>`; 345 337 } 346 338 const speaker = part.speaker || 'Unknown'; 347 339 const cls = speaker === 'Human' ? 'human' : 'assistant';
+5 -1
apps/import/workspace.html
··· 683 683 .replace(/"/g, '&quot;'); 684 684 } 685 685 686 + function renderMarkdown(raw) { 687 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 688 + } 689 + 686 690 function navigateTo(view, options = {}) { 687 691 const { updateHash = true } = options; 688 692 const grid = document.getElementById('importGrid'); ··· 1233 1237 const response = await fetch(`/app/import/api/guide/${sourceName}`); 1234 1238 if (response.ok) { 1235 1239 const markdown = await response.text(); 1236 - guideHtml = `<li><div class="import-step"><div class="import-step-number">Step 1</div><div class="import-guide-content">${marked.parse(markdown, { breaks: true, gfm: true })}</div></div></li>`; 1240 + guideHtml = `<li><div class="import-step"><div class="import-step-number">Step 1</div><div class="import-guide-content">${renderMarkdown(markdown)}</div></div></li>`; 1237 1241 } 1238 1242 } 1239 1243
-2
apps/reflections/workspace.html
··· 153 153 } 154 154 </style> 155 155 156 - <script src="{{ vendor_lib('dompurify') }}"></script> 157 - 158 156 <div class="reflection-shell"> 159 157 {% if view_mode == "index" %} 160 158 <header class="reflection-header">
+4 -38
apps/sol/workspace.html
··· 2088 2088 if (data.format === 'md') { 2089 2089 const markdown = document.createElement('div'); 2090 2090 markdown.className = 'rendered-markdown'; 2091 - markdown.innerHTML = sanitizeHtml(marked.parse(data.content, { breaks: true, gfm: true })); 2091 + markdown.innerHTML = renderMarkdown(data.content); 2092 2092 container.appendChild(markdown); 2093 2093 } else { 2094 2094 const pre = document.createElement('pre'); ··· 2128 2128 return div.innerHTML; 2129 2129 } 2130 2130 2131 - // Sanitize HTML to allow only safe tags and attributes 2132 - function sanitizeHtml(html) { 2133 - const doc = new DOMParser().parseFromString(html, 'text/html'); 2134 - const allowedTags = new Set([ 2135 - 'p', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li', 'a', 2136 - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'br', 'hr', 2137 - 'table', 'thead', 'tbody', 'tr', 'th', 'td' 2138 - ]); 2139 - const allowedAttributes = { 2140 - a: new Set(['href']) 2141 - }; 2142 - 2143 - Array.from(doc.body.querySelectorAll('*')).forEach(function(el) { 2144 - const tagName = el.tagName.toLowerCase(); 2145 - if (!allowedTags.has(tagName)) { 2146 - el.replaceWith(doc.createTextNode(el.textContent || '')); 2147 - return; 2148 - } 2149 - 2150 - Array.from(el.attributes).forEach(function(attr) { 2151 - if (!allowedAttributes[tagName] || !allowedAttributes[tagName].has(attr.name)) { 2152 - el.removeAttribute(attr.name); 2153 - } 2154 - }); 2155 - 2156 - if (tagName === 'a') { 2157 - const href = el.getAttribute('href'); 2158 - if (!href || !/^(https?:\/\/|mailto:)/.test(href)) { 2159 - el.removeAttribute('href'); 2160 - } 2161 - el.setAttribute('target', '_blank'); 2162 - el.setAttribute('rel', 'noopener noreferrer'); 2163 - } 2164 - }); 2165 - 2166 - return doc.body.innerHTML; 2131 + function renderMarkdown(raw) { 2132 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 2167 2133 } 2168 2134 2169 2135 // Render agent events as a rich interactive timeline ··· 2348 2314 if (event.result && event.result.trim()) { 2349 2315 const resultDiv = document.createElement('div'); 2350 2316 resultDiv.className = 'finish-result'; 2351 - resultDiv.innerHTML = sanitizeHtml(marked.parse(event.result, { breaks: true, gfm: true })); 2317 + resultDiv.innerHTML = renderMarkdown(event.result); 2352 2318 block.appendChild(resultDiv); 2353 2319 } else { 2354 2320 const empty = document.createElement('div');
+1 -3
apps/transcripts/workspace.html
··· 1291 1291 </div> 1292 1292 1293 1293 <script src="{{ vendor_lib('marked') }}"></script> 1294 - <script src="{{ vendor_lib('dompurify') }}"></script> 1295 1294 <script> 1296 1295 (() => { 1297 1296 // Timeline bounds - computed dynamically from content ··· 2749 2748 } 2750 2749 2751 2750 function renderMarkdown(raw) { 2752 - if (!raw) return ''; 2753 - return DOMPurify.sanitize(marked.parse(raw)); 2751 + return DOMPurify.sanitize(marked.parse(raw || '', { breaks: true, gfm: true })); 2754 2752 } 2755 2753 2756 2754 function escapeHtml(str) {
+1
convey/templates/app.html
··· 98 98 <!-- App JavaScript (includes AppServices framework) --> 99 99 <script src="{{ url_for('root.static', filename='app.js') }}"></script> 100 100 <script src="{{ vendor_lib('marked') }}"></script> 101 + <script src="{{ vendor_lib('dompurify') }}"></script> 101 102 102 103 <script> 103 104 (function() {
+1 -1
docs/VENDOR.md
··· 76 76 ``` 77 77 78 78 **Currently Used By**: 79 - - `apps/transcripts/workspace.html` 79 + - All apps (shell-level include via `convey/templates/app.html`) 80 80 81 81 ## Adding New Libraries 82 82