Make sure scripts in md preview are properly evaluated

Fixes #243454

This restores the previous behavior. If the default security settings are used, scripts will still block blocked by the CSP. If you fully disable the security settings, then we'll try to run them
This commit is contained in:
Matt Bierner
2025-03-17 11:45:49 -07:00
parent a0425e3665
commit 23812e0d8f
3 changed files with 96 additions and 47 deletions

View File

@@ -14,7 +14,7 @@
"highlight.js": "^11.8.0",
"markdown-it": "^12.3.2",
"markdown-it-front-matter": "^0.2.4",
"morphdom": "^2.6.1",
"morphdom": "^2.7.4",
"picomatch": "^2.3.1",
"punycode": "^2.3.1",
"vscode-languageclient": "^8.0.2",
@@ -501,9 +501,10 @@
}
},
"node_modules/morphdom": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.6.1.tgz",
"integrity": "sha512-Y8YRbAEP3eKykroIBWrjcfMw7mmwJfjhqdpSvoqinu8Y702nAwikpXcNFDiIkyvfCLxLM9Wu95RZqo4a9jFBaA=="
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.4.tgz",
"integrity": "sha512-ATTbWMgGa+FaMU3FhnFYB6WgulCqwf6opOll4CBzmVDTLvPMmUPrEv8CudmLPK0MESa64+6B89fWOxP3+YIlxQ==",
"license": "MIT"
},
"node_modules/node-html-parser": {
"version": "6.1.13",

View File

@@ -768,7 +768,7 @@
"highlight.js": "^11.8.0",
"markdown-it": "^12.3.2",
"markdown-it-front-matter": "^0.2.4",
"morphdom": "^2.6.1",
"morphdom": "^2.7.4",
"picomatch": "^2.3.1",
"punycode": "^2.3.1",
"vscode-languageclient": "^8.0.2",

View File

@@ -68,7 +68,14 @@ onceDocumentLoaded(() => {
getRawData('data-initial-md-content'),
'text/html'
);
document.body.append(...markDownHtml.body.children);
const newElements = [...markDownHtml.body.children];
document.body.append(...newElements);
for (const el of newElements) {
if (el instanceof HTMLElement) {
domEval(el);
}
}
// Restore
const scrollProgress = state.scrollProgress;
@@ -216,46 +223,11 @@ window.addEventListener('message', async event => {
}
if (data.source !== documentResource) {
root.replaceWith(newContent.querySelector('.markdown-body')!);
documentResource = data.source;
const newBody = newContent.querySelector('.markdown-body')!;
root.replaceWith(newBody);
domEval(newBody);
} else {
const skippedAttrs = [
'open', // for details
];
// Compare two elements but some elements
const areEqual = (a: Element, b: Element): boolean => {
if (a.isEqualNode(b)) {
return true;
}
if (a.tagName !== b.tagName || a.textContent !== b.textContent) {
return false;
}
const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));
const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));
if (aAttrs.length !== bAttrs.length) {
return false;
}
for (let i = 0; i < aAttrs.length; ++i) {
const aAttr = aAttrs[i];
const bAttr = bAttrs[i];
if (aAttr.name !== bAttr.name) {
return false;
}
if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {
return false;
}
}
const aChildren = Array.from(a.children);
const bChildren = Array.from(b.children);
return aChildren.length === bChildren.length && aChildren.every((x, i) => areEqual(x, bChildren[i]));
};
const newRoot = newContent.querySelector('.markdown-body')!;
// Move styles to head
@@ -265,10 +237,11 @@ window.addEventListener('message', async event => {
style.remove();
}
newRoot.prepend(...styles);
morphdom(root, newRoot, {
childrenOnly: true,
onBeforeElUpdated: (fromEl, toEl) => {
if (areEqual(fromEl, toEl)) {
onBeforeElUpdated: (fromEl: Element, toEl: Element) => {
if (areNodesEqual(fromEl, toEl)) {
// areEqual doesn't look at `data-line` so copy those over manually
const fromLines = fromEl.querySelectorAll('[data-line]');
const toLines = toEl.querySelectorAll('[data-line]');
@@ -294,8 +267,14 @@ window.addEventListener('message', async event => {
}
return true;
},
addChild: (parentNode: Node, childNode: Node) => {
parentNode.appendChild(childNode);
if (childNode instanceof HTMLElement) {
domEval(childNode);
}
}
});
} as any);
}
++documentVersion;
@@ -383,3 +362,72 @@ function updateScrollProgress() {
vscode.setState(state);
}
/**
* Compares two nodes for morphdom to see if they are equal.
*
* This skips some attributes that should not cause equality to fail.
*/
function areNodesEqual(a: Element, b: Element): boolean {
const skippedAttrs = [
'open', // for details
];
if (a.isEqualNode(b)) {
return true;
}
if (a.tagName !== b.tagName || a.textContent !== b.textContent) {
return false;
}
const aAttrs = [...a.attributes].filter(attr => !skippedAttrs.includes(attr.name));
const bAttrs = [...b.attributes].filter(attr => !skippedAttrs.includes(attr.name));
if (aAttrs.length !== bAttrs.length) {
return false;
}
for (let i = 0; i < aAttrs.length; ++i) {
const aAttr = aAttrs[i];
const bAttr = bAttrs[i];
if (aAttr.name !== bAttr.name) {
return false;
}
if (aAttr.value !== bAttr.value && aAttr.name !== 'data-line') {
return false;
}
}
const aChildren = Array.from(a.children);
const bChildren = Array.from(b.children);
return aChildren.length === bChildren.length && aChildren.every((x, i) => areNodesEqual(x, bChildren[i]));
}
function domEval(el: Element): void {
const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [
'type', 'src', 'nonce', 'noModule', 'async',
];
const scriptNodes = el.tagName === 'SCRIPT' ? [el] : Array.from(el.getElementsByTagName('script'));
for (const node of scriptNodes) {
if (!(node instanceof HTMLElement)) {
continue;
}
const scriptTag = document.createElement('script');
const trustedScript = node.innerText;
scriptTag.text = trustedScript as string;
for (const key of preservedScriptAttributes) {
const val = node.getAttribute && node.getAttribute(key);
if (val) {
scriptTag.setAttribute(key, val as any);
}
}
node.insertAdjacentElement('afterend', scriptTag);
node.remove();
}
}