Skip to content

Commit c2e14b6

Browse files
committed
sanitize plugin markdown rendering with shared helper
Add a shared safe markdown pipeline for plugin READMEs and docs. - vendor DOMPurify and introduce a shared safe-markdown helper - centralize GitHub README link/image rebasing, including repo routes like `releases` - sanitize rendered HTML before all plugin-related x-html sinks - apply the shared renderer to Plugin Hub README, installed plugin README, and markdown modal docs - preserve target/rel handling for external links
1 parent f577dad commit c2e14b6

7 files changed

Lines changed: 1617 additions & 119 deletions

File tree

plugins/_plugin_installer/webui/pluginInstallStore.js

Lines changed: 5 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { createStore } from "/js/AlpineStore.js";
22
import * as api from "/js/api.js";
3-
import { addBlankTargetsToLinks } from "/js/messages.js";
43
import { openModal } from "/js/modals.js";
5-
import { marked } from "/vendor/marked/marked.esm.js";
4+
import { renderSafeMarkdown } from "/js/safe-markdown.js";
65
import { toastFrontendSuccess, toastFrontendError } from "/components/notifications/notification-store.js";
76
import { showConfirmDialog } from "/js/confirmDialog.js";
87
import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
@@ -80,90 +79,6 @@ const model = {
8079
return url.replace("https://github.com/", "https://raw.githubusercontent.com/");
8180
},
8281

83-
_rebaseReadmeLinks(html, githubUrl, branch) {
84-
if (!html || typeof html !== "string" || !githubUrl || !branch) return html;
85-
86-
let repoUrl;
87-
try {
88-
repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
89-
} catch {
90-
return html;
91-
}
92-
93-
if (repoUrl.hostname !== "github.com") return html;
94-
95-
const [owner, repo] = repoUrl.pathname
96-
.replace(/^\/+|\/+$/g, "")
97-
.split("/");
98-
if (!owner || !repo) return html;
99-
100-
const repoWebBase = `https://github.com/${owner}/${repo}`;
101-
const repoBlobBase = `${repoWebBase}/blob/${branch}`;
102-
const repoRawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
103-
const doc = new DOMParser().parseFromString(html, "text/html");
104-
const githubRepoRoutePrefixes = new Set([
105-
"actions",
106-
"blob",
107-
"branches",
108-
"commit",
109-
"commits",
110-
"compare",
111-
"discussions",
112-
"issues",
113-
"labels",
114-
"milestones",
115-
"packages",
116-
"projects",
117-
"pulls",
118-
"raw",
119-
"releases",
120-
"security",
121-
"tags",
122-
"tree",
123-
"wiki",
124-
]);
125-
const shouldSkipRebase = (value) =>
126-
!value ||
127-
value.startsWith("#") ||
128-
value.startsWith("//") ||
129-
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
130-
131-
const resolveRepoPath = (value) => {
132-
if (shouldSkipRebase(value)) return null;
133-
try {
134-
const resolved = new URL(value, "https://repo-root.invalid/");
135-
return `${resolved.pathname.replace(/^\/+/, "")}${resolved.search}${resolved.hash}`;
136-
} catch {
137-
return null;
138-
}
139-
};
140-
const isRepoRoutePath = (repoPath) => {
141-
const pathOnly = repoPath
142-
.split(/[?#]/, 1)[0]
143-
.replace(/^\/+|\/+$/g, "");
144-
if (!pathOnly) return false;
145-
const firstSegment = pathOnly.split("/")[0].toLowerCase();
146-
return githubRepoRoutePrefixes.has(firstSegment);
147-
};
148-
149-
doc.querySelectorAll("a[href]").forEach((anchor) => {
150-
const href = (anchor.getAttribute("href") || "").trim();
151-
const repoPath = resolveRepoPath(href);
152-
if (!repoPath) return;
153-
const base = isRepoRoutePath(repoPath) ? repoWebBase : repoBlobBase;
154-
anchor.setAttribute("href", `${base}/${repoPath}`);
155-
});
156-
157-
doc.querySelectorAll("img[src]").forEach((image) => {
158-
const src = (image.getAttribute("src") || "").trim();
159-
const repoPath = resolveRepoPath(src);
160-
if (!repoPath) return;
161-
image.setAttribute("src", `${repoRawBase}/${repoPath}`);
162-
});
163-
164-
return doc.body.innerHTML;
165-
},
166-
16782
_pluginPrimaryTag(plugin) {
16883
const tags = Array.isArray(plugin?.tags) ? plugin.tags.filter(Boolean) : [];
16984
return tags[0] || "";
@@ -589,9 +504,10 @@ const model = {
589504
if (!response.ok) continue;
590505

591506
const readme = await response.text();
592-
let html = marked.parse(readme, { breaks: true });
593-
html = this._rebaseReadmeLinks(html, plugin?.github, branch);
594-
this.readmeContent = addBlankTargetsToLinks(html);
507+
this.readmeContent = renderSafeMarkdown(readme, {
508+
githubUrl: plugin?.github,
509+
branch,
510+
});
595511
return;
596512
} catch (error) {
597513
lastError = error;

webui/components/modals/markdown/markdown-store.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { marked } from "/vendor/marked/marked.esm.js";
21
import { createStore } from "/js/AlpineStore.js";
2+
import { renderSafeMarkdown } from "/js/safe-markdown.js";
33

44
export const store = createStore("markdownModal", {
55
title: "",
@@ -14,7 +14,7 @@ export const store = createStore("markdownModal", {
1414

1515
get renderedHtml() {
1616
if (!this.content) return "";
17-
return marked.parse(this.content, { breaks: true });
17+
return renderSafeMarkdown(this.content);
1818
},
1919

2020
cleanup() {

webui/components/plugins/list/pluginListStore.js

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createStore } from "/js/AlpineStore.js";
22
import * as api from "/js/api.js";
3-
import { marked } from "/vendor/marked/marked.esm.js";
4-
import { addBlankTargetsToLinks } from "/js/messages.js";
3+
import { renderSafeMarkdown } from "/js/safe-markdown.js";
54
import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
65
import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js";
76
import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js";
@@ -167,8 +166,7 @@ const model = {
167166
doc: "readme",
168167
});
169168
if (response?.error) throw new Error(response.error);
170-
const html = marked.parse(response.content || "", { breaks: true });
171-
this.readmeContent = addBlankTargetsToLinks(html);
169+
this.readmeContent = renderSafeMarkdown(response.content || "");
172170
} catch (e) {
173171
const error = e instanceof Error ? e : new Error(String(e));
174172
this.readmeError = error.message || "Failed to load README";

webui/js/html-links.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function addBlankTargetsToLinks(str) {
2+
const doc = new DOMParser().parseFromString(str, "text/html");
3+
4+
doc.querySelectorAll("a").forEach((anchor) => {
5+
const href = anchor.getAttribute("href") || "";
6+
if (
7+
href.startsWith("#") ||
8+
href.trim().toLowerCase().startsWith("javascript")
9+
) {
10+
return;
11+
}
12+
13+
if (
14+
!anchor.hasAttribute("target") ||
15+
anchor.getAttribute("target") === ""
16+
) {
17+
anchor.setAttribute("target", "_blank");
18+
}
19+
20+
const rel = (anchor.getAttribute("rel") || "").split(/\s+/).filter(Boolean);
21+
if (!rel.includes("noopener")) rel.push("noopener");
22+
if (!rel.includes("noreferrer")) rel.push("noreferrer");
23+
anchor.setAttribute("rel", rel.join(" "));
24+
});
25+
26+
return doc.body.innerHTML;
27+
}

webui/js/messages.js

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { store as preferencesStore } from "/components/sidebar/bottom/preference
1313
import { formatDuration } from "./time-utils.js";
1414
import { Scroller } from "./scroller.js";
1515
import { callJsExtensions } from "/js/extensions.js";
16+
import { addBlankTargetsToLinks } from "/js/html-links.js";
1617

1718
// Delay before collapsing previous steps when a new step is added
1819
const STEP_COLLAPSE_DELAY = {
@@ -762,30 +763,7 @@ export function _drawMessage({
762763
return messageDiv;
763764
}
764765

765-
export function addBlankTargetsToLinks(str) {
766-
const doc = new DOMParser().parseFromString(str, "text/html");
767-
768-
doc.querySelectorAll("a").forEach((anchor) => {
769-
const href = anchor.getAttribute("href") || "";
770-
if (
771-
href.startsWith("#") ||
772-
href.trim().toLowerCase().startsWith("javascript")
773-
)
774-
return;
775-
if (
776-
!anchor.hasAttribute("target") ||
777-
anchor.getAttribute("target") === ""
778-
) {
779-
anchor.setAttribute("target", "_blank");
780-
}
781-
782-
const rel = (anchor.getAttribute("rel") || "").split(/\s+/).filter(Boolean);
783-
if (!rel.includes("noopener")) rel.push("noopener");
784-
if (!rel.includes("noreferrer")) rel.push("noreferrer");
785-
anchor.setAttribute("rel", rel.join(" "));
786-
});
787-
return doc.body.innerHTML;
788-
}
766+
export { addBlankTargetsToLinks };
789767

790768
/**
791769
* @param {MessageHandlerArgs & Record<string, any>} param0

0 commit comments

Comments
 (0)