Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…position sync - Add data-source-line attributes to rendered markdown HTML via custom marked renderer using lexer/parser pipeline with token annotation - Bidirectional click-to-scroll: clicking in preview moves CM5 cursor to corresponding source line (centred); clicking in CM5 scrolls preview to matching element — only when target is off-screen - Route undo/redo from iframe through CM5's undo stack via capture-phase keyboard interception (Cmd/Ctrl+Z, Shift+Z, Y) - Use diff-based cm.replaceRange() instead of doc.setText() to preserve CM5 undo history when syncing WYSIWYG edits back - Escape key in iframe refocuses CM5 editor - Read mode clicks without selection refocus CM5; edit mode clicks sync cursor without stealing focus - Exclude src/mdViewer build output from ESLint Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tton styles Reduce toolbar height from 44px to 30px, shrink buttons/icons proportionally, and switch edit-toggle-btn to btn-alt-quiet style (transparent bg, border on hover).
…tip delay - Add underline formatting to embedded toolbar, floating format bar, and editor - Responsive toolbar: collapses into dropdown groups (text, lists, blocks) on narrow widths - Fix min-width issue preventing toolbar from shrinking below content width - Format bar buttons now single-line (no wrapping) - Increase tooltip delay from 300ms to 800ms
Collapse groups into dropdowns progressively as width shrinks: - Level 1 (<480px): block elements (quote, hr, table, codeblock) - Level 2 (<390px): + lists (bullet, ordered, task) - Level 3 (<300px): + text formatting (bold, italic, etc.) Most-used tools stay visible longest.
…ewer CM selections highlight corresponding blocks in the md viewer, and md viewer selections set the matching text selection in CM. Clicking without selecting clears both sides. Uses data-source-line mapping with text-matching refinement, debounced at 200ms.
Enable checkboxes in edit mode (marked renders them disabled), handle click events to trigger content change, sync checked property to HTML attribute before cloning for Turndown conversion, and relax Turndown filter to match checkboxes inside any <li> (not just .task-list-item).
… switching Implement a two-tier document cache inside the mdviewer iframe: - Working set files: always cached (unlimited, mirrors Phoenix) - Non-working-set files: LRU cache (max 20 entries) Each cached file gets its own DOM element. File switching is now a hide/show operation with zero re-rendering or layout reflow. The md iframe persists across MD-to-HTML preview switches (hidden, not destroyed). Key changes: - New doc-cache.js module manages DOM pool with id="viewer-content" swap trick - bridge.js handles SWITCH_FILE, CLEAR_CACHE, WORKING_SET_CHANGED messages - MarkdownSync.js sends SWITCH_FILE on iframe reuse, includes filePath - main.js keeps $mdviewrIframe persistent, syncs working set changes - viewer.js and editor.js use dynamic element lookup
Phoenix sends full locale like "en-US" but locale files are named "en.json". Strip to base language to avoid "Failed to load locale" warnings.
- Reload button clears file cache and re-renders, restoring scroll position and edit mode - Scroll save/restore now uses data-source-line elements instead of pixel positions, making it immune to image loading layout shifts
Parameterize translateStrings.js to support both Phoenix NLS (define-wrapped JS) and mdviewer (flat JSON) formats. translateMdviewer() flattens en.json to root/strings.json, translates all locales, then unflattens back to locales/. i18n.js updated to handle region-code locales with fallback.
Stage md-nls-autogenerated and locales folders in translation PR, check both error files, and gitignore mdviewer errors.txt.
…ortcut tooltip
Edit mode is now preserved globally when switching files instead of
per-document. Added ({mod}+U) shortcut to underline tooltip string.
…lapse thresholds - Rename Done button to Reader with book-open icon and title - Add Switch to edit mode title on Edit button - Remove unused toolbar.done string - Add toolbar.reader, toolbar.switch_to_reader, toolbar.switch_to_edit strings - Raise collapse thresholds by +100px (640/520/420) to prevent clipping
When blanking the iframe on panel hide, deactivate MarkdownSync and reset _isMdviewrActive so the next panel show properly reuses the persistent md iframe instead of activating on the blank iframe.
…mode on file switch Replaced inline markdown in MDVIEWR_SET_EDIT_MODE with a dedicated MDVIEWR_RERENDER_CONTENT message sent only when user switches to reader. Prevents edit mode from being reset during file switches.
- Add printer icon button to both reader and edit mode toolbars - Force light theme CSS variables in @media print for readable output - Hide toolbar and all UI chrome during print via CSS - Override height/overflow constraints so full content prints - Add allow-modals to mdviewer sandbox for window.print() - Standard page margins (20mm top/bottom, 25mm left/right)
- Add ImageUploadManager service provider registry in core - Add paste interceptor on editor-holder that detects image clipboard content in markdown/gfm files and triggers upload flow - Insert uploading placeholder with spinner SVG, replace with embed URL - Show login dialog with image preview if not logged in - Show upsell dialog if upload quota exceeded - Add i18n strings for upload dialogs and messages
- Remove image border from embed/login preview dialogs (dark theme fix) - Use DOM paste listener instead of modifying ChangeHelper interceptor - Add IMAGE_UPLOAD_UNSUPPORTED_TYPE and IMAGE_UPLOAD_LIMIT_TITLE strings - Use uploading.svg placeholder during upload - Fix language check to include gfm
…roll jump Replace full innerHTML replacement with morphdom diffing so unchanged elements (images, code blocks) stay in the DOM during CM→preview sync. Prevents image reloads and layout shifts when editing text.
…d paste - Add image dropdown in toolbar with "Image URL" and "Upload from Computer" - Add two image items to slash menu (image URL and upload) - Add image URL dialog with URL and alt text inputs - Add file picker for image upload from computer - Intercept image paste in mdviewer editor - Forward image blobs to Phoenix via bridge for cloud upload - Handle upload results (replace placeholder with embed URL) - Add menu-style dropdown CSS with left-aligned icon + text items - Collapse image section with blocks at narrow widths
…e DOM ids The mdviewer iframe now uses id="panel-md-preview-frame" instead of sharing "panel-live-preview-frame" with the HTML preview iframe. Fixes duplicate ID causing test failures in Device Size & Resize Ruler.
…itecture - Check panel-md-preview-frame and query __getActiveFilePath() for md files - Add null guards to _waitForIframeURL and _forSVGLivePreview for iframe transitions between md and HTML preview - Expose __getActiveFilePath on mdviewer window for test access
- Add /proxy/assets/* route to serve-proxy.js proxying to assets.phcode.dev - Tag proxy requests with _proxyTarget so Host header matches the target - Declare ASSETS_SERVER constant for the assets base URL
handleClearCache no longer resets editMode to false. The cache is cleared on project switch but the user's edit/reader mode persists.
…vent reload Save existing <img> elements by src before innerHTML replacement, then swap them back in after. The browser keeps the same nodes with cached image data, preventing re-fetches and flicker when editing text in CM. Replaces morphdom with simpler innerHTML + image node preservation.
Replace images with placeholder spans before morphdom runs so the diff engine never touches them. After morphdom inserts new img nodes, swap the saved originals back in. Prevents GIF restart and image flicker.
Contenteditable jumps to container end/start instead of block end/start when cursor is near an <img>. Intercept End/Home (and Cmd+Right/Left on Mac) to manually position cursor at block boundary.
- Add helpers to find md/lp iframes by their distinct IDs - Check md iframe visibility instead of src for markdown file detection - Add _ensureMdReaderMode to switch md viewer to reader mode in tests - Add _TEST_FOCUS_CLICK and _TEST_SELECT_TEXT_AND_CLICK handlers to mdviewer bridge for integration test support
…on markdown files
There was a problem hiding this comment.
SonarCloud found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.
| if (!placeholder) return; | ||
|
|
||
| if (embedURL) { | ||
| placeholder.src = embedURL; |
Check failure
Code scanning / CodeQL
Client-side cross-site scripting High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 27 minutes ago
In general, to fix this kind of issue you must not assign untrusted strings directly to dangerous DOM properties (src, href, innerHTML, etc.) without validation. Either (a) validate and constrain the value to a safe subset (e.g., only allow HTTPS URLs from trusted hosts) or (b) encode/escape it and insert it only into safe contexts.
The best minimal fix here is to validate embedURL before assigning it to placeholder.src. Since we cannot modify other files, we add a small helper in bridge.js that checks the URL using the built-in URL constructor and only allows safe protocols such as http: and https: (and optionally same-origin relative URLs). _handleImageUploadResult will call this helper; if validation fails, we treat it like an error (remove the placeholder instead of embedding a potentially dangerous URL). This preserves existing behavior for legitimate URLs while blocking attacker-controlled schemes like javascript: or data:.
Concretely:
- In
src-mdviewer/src/bridge.js, above_handleImageUploadResult, define afunction sanitizeEmbedUrl(rawUrl)that:- Returns
nullifrawUrlis falsy. - Attempts to construct a
URLobject, usingwindow.location.hrefas base for relative URLs. - Only returns the URL’s
hrefif its protocol is in an allowed list (http:andhttps:). Otherwise returnsnull.
- Returns
- In
_handleImageUploadResult, replace use ofembedURLwith the sanitized result:- Compute
const safeEmbedURL = sanitizeEmbedUrl(embedURL); - Change the condition from
if (embedURL)toif (safeEmbedURL)and assignplaceholder.src = safeEmbedURL;.
- Compute
- No new external dependencies or imports are required; we rely on the built-in
URLclass.
| @@ -656,6 +656,27 @@ | ||
| emit("file:rendered", parseResult); | ||
| } | ||
|
|
||
| /** | ||
| * Sanitize an embed URL coming from postMessage data. | ||
| * Only allow http/https URLs (including relative URLs that resolve to those). | ||
| * Returns a safe URL string, or null if the URL is invalid or uses a disallowed scheme. | ||
| */ | ||
| function sanitizeEmbedUrl(rawUrl) { | ||
| if (!rawUrl || typeof rawUrl !== "string") { | ||
| return null; | ||
| } | ||
| try { | ||
| const url = new URL(rawUrl, window.location.href); | ||
| const allowedProtocols = new Set(["http:", "https:"]); | ||
| if (!allowedProtocols.has(url.protocol)) { | ||
| return null; | ||
| } | ||
| return url.href; | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| function _handleImageUploadResult(data) { | ||
| const { uploadId, embedURL, error } = data; | ||
| const content = document.getElementById("viewer-content"); | ||
| @@ -663,8 +684,10 @@ | ||
| const placeholder = content.querySelector(`img[data-upload-id="${uploadId}"]`); | ||
| if (!placeholder) return; | ||
|
|
||
| if (embedURL) { | ||
| placeholder.src = embedURL; | ||
| const safeEmbedURL = sanitizeEmbedUrl(embedURL); | ||
|
|
||
| if (safeEmbedURL) { | ||
| placeholder.src = safeEmbedURL; | ||
| placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt; | ||
| placeholder.removeAttribute("data-upload-id"); | ||
| } else { |
| ]); | ||
|
|
||
| function sanitizePastedHTML(html) { | ||
| const doc = new DOMParser().parseFromString(html, "text/html"); |
Check failure
Code scanning / CodeQL
Client-side cross-site scripting High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 27 minutes ago
To fix the problem, keep sanitizing the pasted HTML but additionally validate and normalize any URL-bearing attributes (href, src) before allowing them, and remove or neutralize any values with dangerous schemes (like javascript:, vbscript:, data: for non-images, etc.). This keeps existing functionality (pasting formatted content with links and images) while blocking XSS vectors.
Concretely, in src-mdviewer/src/components/editor.js, inside sanitizePastedHTML, we should:
- Add a small helper to determine whether a URL is safe:
- Allow only certain schemes: empty/relative URLs,
http:,https:,mailto:(and optionally others you deem safe). - Reject anything starting with
javascript:,vbscript:, or other non-HTTP(S) schemes.
- Allow only certain schemes: empty/relative URLs,
- When iterating over attributes:
- Still preserve
altas harmless. - For
hrefandsrc, only keep them if they pass the new URL validation; otherwise remove those attributes. - We can still limit attributes to the allowlist as currently done.
- Still preserve
- Optionally, we can further constrain
IMGsrctohttp/httpsand safedata:image/...URLs, but at minimum we must removejavascript:and similar.
Implementation details:
- Add a small function
isSafeUrl(url)above or insidesanitizePastedHTML. Since we must only edit the provided snippet, define it just abovesanitizePastedHTMLineditor.js. Use standard string checks andURLconstructor where possible, but fall back gracefully on exceptions. - Modify the block that currently keeps
href,src,altunconditionally:
if (attr.name === "href" || attr.name === "src" || attr.name === "alt") continue;to:
- Always keep
alt. - For
href/src, callisSafeUrl(attr.value)and only keep them if that returnstrue; otherwise remove the attribute.
No new external dependencies are necessary; we can use built-in URL and basic string operations.
| @@ -1123,6 +1123,28 @@ | ||
| "BR", "HR", "IMG", "DEL", "S", "INPUT", "DIV" | ||
| ]); | ||
|
|
||
| function isSafeUrl(url) { | ||
| if (!url) return false; | ||
| const trimmed = url.trim(); | ||
| // Disallow obvious javascript/vbscript/data/code-like URLs | ||
| const lower = trimmed.toLowerCase(); | ||
| if (lower.startsWith("javascript:") || lower.startsWith("vbscript:")) { | ||
| return false; | ||
| } | ||
| // Allow fragment-only, query-only, and relative URLs | ||
| if (trimmed.startsWith("#") || trimmed.startsWith("?") || trimmed.startsWith("/")) { | ||
| return true; | ||
| } | ||
| try { | ||
| const parsed = new URL(trimmed, window.location.origin); | ||
| const protocol = parsed.protocol; | ||
| return protocol === "http:" || protocol === "https:" || protocol === "mailto:"; | ||
| } catch { | ||
| // If URL constructor fails, treat as unsafe | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function sanitizePastedHTML(html) { | ||
| const doc = new DOMParser().parseFromString(html, "text/html"); | ||
| doc.querySelectorAll("o\\:p, style, script, meta, link, xml").forEach((el) => el.remove()); | ||
| @@ -1142,7 +1164,8 @@ | ||
| for (const attr of attrs) { | ||
| if (tag === "INPUT" && (attr.name === "type" || attr.name === "checked")) continue; | ||
| if (tag === "PRE" && attr.name === "data-language") continue; | ||
| if (attr.name === "href" || attr.name === "src" || attr.name === "alt") continue; | ||
| if (attr.name === "alt") continue; | ||
| if ((attr.name === "href" || attr.name === "src") && isSafeUrl(attr.value)) continue; | ||
| node.removeAttribute(attr.name); | ||
| } | ||
| } |
| const { svg } = await mermaid.render(id, trimmed); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 12 hours ago
In general, to fix “DOM text reinterpreted as HTML” issues, you should avoid feeding untrusted strings directly into innerHTML or similar DOM sinks. Either (1) sanitize the HTML using a robust library before assigning it, or (2) use DOM APIs that treat the data as text (like textContent) or as data within a constrained context (e.g., SVG parsing) rather than as free-form HTML.
For this specific file, the vulnerable line is:
const temp = document.createElement("div");
temp.innerHTML = svg;
const svgEl = temp.querySelector("svg");The rest of the code then only uses svgEl. A minimal, behavior-preserving hardening is to sanitize svg before putting it into innerHTML. The most standard way in front‑end JS is to use DOMPurify, which is well-known and specifically designed to sanitize HTML/SVG. Since we are allowed to import well-known external libraries, we can:
- Add
import DOMPurify from "dompurify";at the top ofsrc-mdviewer/src/components/mermaid-editor.js. - Replace
temp.innerHTML = svg;withtemp.innerHTML = DOMPurify.sanitize(svg, {USE_PROFILES: {svg: true}});. This keeps only safe SVG constructs and strips scripts/unsafe URLs/etc., addressing all variants where the taintedsource/textarea.valueultimately flows into thisinnerHTML. - No other functionality changes are needed; the code still parses the sanitized SVG into a temporary container, selects the
<svg>element, applies accessibility attributes, and injects it into the preview.
This modification is entirely local to the shown region and doesn’t change semantics except for dropping potentially unsafe content that Mermaid might emit under attacker control.
| @@ -10,6 +10,7 @@ | ||
| import { on, off } from "../core/events.js"; | ||
| import { t } from "../core/i18n.js"; | ||
| import { flushSnapshot } from "./editor.js"; | ||
| import DOMPurify from "dompurify"; | ||
|
|
||
| const devLog = import.meta.env.DEV ? console.log.bind(console, "[mermaid-editor]") : () => {}; | ||
|
|
||
| @@ -221,7 +222,8 @@ | ||
| const { svg } = await mermaid.render(id, trimmed); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; | ||
| const safeSvg = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }); | ||
| temp.innerHTML = safeSvg; | ||
| const svgEl = temp.querySelector("svg"); | ||
|
|
||
| if (svgEl) { |
| @@ -23,6 +23,7 @@ | ||
| "morphdom": "^2.7.8", | ||
| "prismjs": "^1.29.0", | ||
| "turndown": "^7.2.0", | ||
| "turndown-plugin-gfm": "^1.0.2" | ||
| "turndown-plugin-gfm": "^1.0.2", | ||
| "dompurify": "^3.3.3" | ||
| } | ||
| } |
| Package | Version | Security advisories |
| dompurify (npm) | 3.3.3 | None |
| const { svg } = await mermaid.render(id, source); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 12 hours ago
General approach: Avoid using innerHTML to convert an SVG string into DOM nodes. Instead, parse the SVG string into a Document using DOMParser with the "image/svg+xml" MIME type, and extract the <svg> element from that parsed document. This way, the browser treats the string as SVG markup only, not as generic HTML that could include script elements or event handlers outside of SVG context. This also resolves CodeQL’s concern about DOM text being reinterpreted as HTML.
Concrete fix:
-
In
reRenderDiagram(around lines 296–299), replace:const temp = document.createElement("div"); temp.innerHTML = svg; const svgEl = temp.querySelector("svg");
with code that uses
DOMParser:const parser = new DOMParser(); const parsed = parser.parseFromString(svg, "image/svg+xml"); const svgEl = parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg" ? parsed.documentElement : parsed.querySelector("svg");
This preserves functionality (we still obtain an
<svg>element) but avoidsinnerHTML. -
In
closeEditor(lines 268–273), similar pattern: instead of usingtemp.innerHTML = lastValidSvg, parse the saved SVG string withDOMParserand append the resulting<svg>node:const parser = new DOMParser(); const parsed = parser.parseFromString(lastValidSvg, "image/svg+xml"); const svgEl = parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg" ? parsed.documentElement : parsed.querySelector("svg");
Then append this
svgEltodiagram.
No additional imports are needed; DOMParser is a standard Web API. We keep all other behavior intact: the editor still restores lastValidSvg, still applies accessibility settings, and still re-renders diagrams.
| @@ -265,9 +265,12 @@ | ||
| } | ||
|
|
||
| if (lastValidSvg) { | ||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = lastValidSvg; | ||
| const svgEl = temp.querySelector("svg"); | ||
| const parser = new DOMParser(); | ||
| const parsed = parser.parseFromString(lastValidSvg, "image/svg+xml"); | ||
| const svgEl = | ||
| parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg" | ||
| ? parsed.documentElement | ||
| : parsed.querySelector("svg"); | ||
| if (svgEl) { | ||
| diagram.appendChild(svgEl); | ||
| } | ||
| @@ -293,9 +296,12 @@ | ||
| const id = `mermaid-rerender-${Date.now()}-${idCounter++}`; | ||
| const { svg } = await mermaid.render(id, source); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; | ||
| const svgEl = temp.querySelector("svg"); | ||
| const parser = new DOMParser(); | ||
| const parsed = parser.parseFromString(svg, "image/svg+xml"); | ||
| const svgEl = | ||
| parsed.documentElement && parsed.documentElement.tagName.toLowerCase() === "svg" | ||
| ? parsed.documentElement | ||
| : parsed.querySelector("svg"); | ||
|
|
||
| if (svgEl) { | ||
| const acc = parseAccessibility(source); |
|
|
||
| // Parse SVG and apply accessibility | ||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 12 hours ago
General fix: avoid using innerHTML with data derived from untrusted content. Instead, parse and insert the SVG in a way that does not execute arbitrary HTML/JS. The best option in this context is to use DOMParser with the "image/svg+xml" MIME type and then extract the <svg> element from the resulting document. This avoids going through HTML parsing and prevents things like <script> tags or other non-SVG HTML from being interpreted in the HTML context.
Concrete change:
-
In both places where
svgis currently handled:const temp = document.createElement("div"); temp.innerHTML = svg; const svgEl = temp.querySelector("svg");
replace this with code using
DOMParser:const parser = new DOMParser(); const doc = parser.parseFromString(svg, "image/svg+xml"); const svgEl = doc.querySelector("svg");
-
This stays within the same file
src-mdviewer/src/core/mermaid-render.jsand does not alter any external behavior other than how the SVG string is turned into a DOM node. The rest of the logic (accessibility, animations, error handling) remains unchanged. -
DOMParseris a standard Web API; no imports are needed. We also do not need to hold a shared parser instance; creating it where needed keeps changes minimal and clear.
This single change removes the use of .innerHTML for tainted content and therefore addresses all variants of the alert, since both rendering paths use the same pattern.
| @@ -180,9 +180,9 @@ | ||
| devLog("render success:", id); | ||
|
|
||
| // Parse SVG and apply accessibility | ||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; | ||
| const svgEl = temp.querySelector("svg"); | ||
| const parser = new DOMParser(); | ||
| const doc = parser.parseFromString(svg, "image/svg+xml"); | ||
| const svgEl = doc.querySelector("svg"); | ||
|
|
||
| if (svgEl) { | ||
| const acc = parseAccessibility(source); | ||
| @@ -239,9 +239,9 @@ | ||
| try { | ||
| const { svg } = await mermaid.render(id, source); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; | ||
| const svgEl = temp.querySelector("svg"); | ||
| const parser = new DOMParser(); | ||
| const doc = parser.parseFromString(svg, "image/svg+xml"); | ||
| const svgEl = doc.querySelector("svg"); | ||
|
|
||
| if (svgEl) { | ||
| const acc = parseAccessibility(source); |
| const { svg } = await mermaid.render(id, source); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; |
Check failure
Code scanning / CodeQL
DOM text reinterpreted as HTML High
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI about 12 hours ago
In general, the fix is to avoid assigning untrusted or tainted strings directly to innerHTML. Instead, create DOM nodes in a way that does not reinterpret the string as HTML, or use a parsing mechanism that is restricted to SVG/XML and does not execute scripts or event handlers.
In this specific code, we only need the <svg> element from the svg string returned by Mermaid. We can obtain that <svg> element without using innerHTML by using DOMParser with the "image/svg+xml" MIME type; this parses the string as SVG/XML rather than HTML and gives us a document whose root is an <svg> element. We can then import or clone that node into the current document and append it to wrapper. This preserves existing functionality (we still render the exact same SVG), while removing the innerHTML use that CodeQL flags.
Concretely, in src-mdviewer/src/core/mermaid-render.js, within reRenderMermaidBlocks:
- Replace the creation of
tempandtemp.innerHTML = svg;/temp.querySelector("svg")with aDOMParsercall:
const parser = new DOMParser(); const svgDoc = parser.parseFromString(svg, "image/svg+xml"); const svgEl = svgDoc.documentElement;. - Optionally, guard that
svgElis indeed anSVGSVGElement(itstagNameequals"svg"ignoring case) before using it. - Keep the rest of the logic (accessibility application, clearing
wrapper.innerHTML, appending the SVG) unchanged.
No new external npm dependencies are required; DOMParser is a standard Web API available in browsers.
| @@ -239,9 +239,11 @@ | ||
| try { | ||
| const { svg } = await mermaid.render(id, source); | ||
|
|
||
| const temp = document.createElement("div"); | ||
| temp.innerHTML = svg; | ||
| const svgEl = temp.querySelector("svg"); | ||
| const parser = new DOMParser(); | ||
| const svgDoc = parser.parseFromString(svg, "image/svg+xml"); | ||
| const svgEl = svgDoc.documentElement && svgDoc.documentElement.tagName.toLowerCase() === "svg" | ||
| ? svgDoc.documentElement | ||
| : null; | ||
|
|
||
| if (svgEl) { | ||
| const acc = parseAccessibility(source); |
| if (!placeholder) return; | ||
|
|
||
| if (embedURL) { | ||
| placeholder.src = embedURL; |
Check warning
Code scanning / CodeQL
Client-side URL redirect Medium
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 27 minutes ago
In general, to fix this issue, we need to ensure that the URL assigned to placeholder.src is not fully attacker-controlled. This can be done by: (1) authenticating the message source (checking event.origin and/or a shared secret in event.data); and/or (2) validating embedURL against a whitelist of allowed schemes or patterns before using it.
The least invasive, functionality-preserving fix here is to validate embedURL before assigning it to placeholder.src. A robust approach is to parse the URL via the built-in URL constructor and enforce a whitelist of safe protocols, e.g., allow only https:, http: (if you must), data: (for embedded images), and perhaps blob:. If parsing fails or the protocol is not allowed, treat it as an error and remove the placeholder instead of setting the src. This preserves the existing behavior for valid URLs while preventing redirection/loading of arbitrary, potentially dangerous schemes (like javascript:).
Concretely, in src-mdviewer/src/bridge.js, inside _handleImageUploadResult, modify the block that currently does:
if (embedURL) {
placeholder.src = embedURL;
placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt;
placeholder.removeAttribute("data-upload-id");
} else {
placeholder.remove();
}Change it so that:
- If
embedURLis falsy, keep removing the placeholder as before. - If
embedURLis truthy, attempt to construct anew URL(embedURL, window.location.origin):- On error (invalid URL), remove the placeholder and return.
- Check
url.protocolagainst a small whitelist, e.g.["https:", "http:", "data:", "blob:"].- If not allowed, remove the placeholder and return.
- Only then set
placeholder.src = embedURLand proceed with updatingaltand removingdata-upload-id.
No new imports are needed because URL is a standard Web API. This keeps the protocol of the image under control while minimally impacting existing flows where embedURL is already a valid, safe URL.
| @@ -664,9 +664,24 @@ | ||
| if (!placeholder) return; | ||
|
|
||
| if (embedURL) { | ||
| placeholder.src = embedURL; | ||
| placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt; | ||
| placeholder.removeAttribute("data-upload-id"); | ||
| let isSafe = false; | ||
| try { | ||
| const parsed = new URL(embedURL, window.location.origin); | ||
| const allowedProtocols = ["https:", "http:", "data:", "blob:"]; | ||
| if (allowedProtocols.includes(parsed.protocol)) { | ||
| isSafe = true; | ||
| } | ||
| } catch (e) { | ||
| isSafe = false; | ||
| } | ||
|
|
||
| if (isSafe) { | ||
| placeholder.src = embedURL; | ||
| placeholder.alt = placeholder.alt === "Uploading..." ? "" : placeholder.alt; | ||
| placeholder.removeAttribute("data-upload-id"); | ||
| } else { | ||
| placeholder.remove(); | ||
| } | ||
| } else { | ||
| placeholder.remove(); | ||
| } |
…k popover
- Add trash icon remove link button in edit mode
- Move close (×) to top-right corner of edit popover
- Use trash icon for unlink in view mode
- Fix unlink by directly unwrapping <a> instead of execCommand("unlink")
- New test file md-editor-integ-test.js with 14 tests for keyboard shortcuts - Tests: Ctrl+S (save), Ctrl+Shift+F (find), Ctrl+B/I/U (formatting), Ctrl+Shift+X (strikethrough), Ctrl+K (link), Ctrl+Z/Y/Shift+Z (undo/redo), Ctrl+A (select all), Escape (focus), F8 (edit+reader mode) - Add Turndown rules for <u> and <s>/<strike>/<del> to preserve formatting - Add test helpers: __setEditModeForTest, __isSuppressingContentChange, __triggerContentSync on mdviewer window for test access - Add sample.md and test-shortcuts.md test fixture files - Mark completed tests in to-create-tests.md
- Block bold command in headings (headings are inherently bold) - Disable bold button in toolbar and format bar when cursor is in heading - Add Turndown rules for <u> and <s>/<strike>/<del> to preserve formatting - Add test helpers: __isSuppressingContentChange, __triggerContentSync - Fix formatting tests to wait for content sync to settle before applying - Revert undo/redo tests to message-forwarding approach (more reliable) - 14/14 md editor integration tests passing
Replace direct require of phoenix-pro/pro-dialogs in MarkdownSync with EventManager event dispatch to preserve extension isolation. Add PRO_DIALOGS_EVENT_MANAGER constant in Dialogs.js as single source of truth.
|




This PR introduces a full-featured markdown live preview editor (src-mdviewer/) that replaces the basic markdown rendering with a rich WYSIWYG editing experience in the live preview panel.
Screenshots
edit mode
Reader mode
Major Features
Architecture
Security
Testing
Other Changes
Test plan