Skip to content

Commit 7e7933e

Browse files
authored
Merge pull request #1359 from 3clyp50/pluginreadme
fix plugin README image rebasing and sanitize rendered markdown
2 parents 1eb7860 + c2e14b6 commit 7e7933e

7 files changed

Lines changed: 1617 additions & 81 deletions

File tree

plugins/_plugin_installer/webui/pluginInstallStore.js

Lines changed: 5 additions & 51 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,52 +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 repoBlobBase = `https://github.com/${owner}/${repo}/blob/${branch}`;
101-
const doc = new DOMParser().parseFromString(html, "text/html");
102-
103-
doc.querySelectorAll("a[href]").forEach((anchor) => {
104-
const href = (anchor.getAttribute("href") || "").trim();
105-
if (
106-
!href ||
107-
href.startsWith("#") ||
108-
href.startsWith("//") ||
109-
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)
110-
) {
111-
return;
112-
}
113-
114-
try {
115-
const resolved = new URL(href, "https://repo-root.invalid/");
116-
const repoPath = resolved.pathname.replace(/^\/+/, "");
117-
anchor.setAttribute(
118-
"href",
119-
`${repoBlobBase}/${repoPath}${resolved.search}${resolved.hash}`,
120-
);
121-
} catch {
122-
// Leave malformed links unchanged.
123-
}
124-
});
125-
126-
return doc.body.innerHTML;
127-
},
128-
12982
_pluginPrimaryTag(plugin) {
13083
const tags = Array.isArray(plugin?.tags) ? plugin.tags.filter(Boolean) : [];
13184
return tags[0] || "";
@@ -551,9 +504,10 @@ const model = {
551504
if (!response.ok) continue;
552505

553506
const readme = await response.text();
554-
let html = marked.parse(readme, { breaks: true });
555-
html = this._rebaseReadmeLinks(html, plugin?.github, branch);
556-
this.readmeContent = addBlankTargetsToLinks(html);
507+
this.readmeContent = renderSafeMarkdown(readme, {
508+
githubUrl: plugin?.github,
509+
branch,
510+
});
557511
return;
558512
} catch (error) {
559513
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

webui/js/safe-markdown.js

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import DOMPurify from "/vendor/dompurify/purify.es.mjs";
2+
import { marked } from "/vendor/marked/marked.esm.js";
3+
import { addBlankTargetsToLinks } from "/js/html-links.js";
4+
5+
const GITHUB_REPO_ROUTE_PREFIXES = new Set([
6+
"actions",
7+
"blob",
8+
"branches",
9+
"commit",
10+
"commits",
11+
"compare",
12+
"discussions",
13+
"issues",
14+
"labels",
15+
"milestones",
16+
"packages",
17+
"projects",
18+
"pulls",
19+
"raw",
20+
"releases",
21+
"security",
22+
"tags",
23+
"tree",
24+
"wiki",
25+
]);
26+
27+
const DOMPURIFY_CONFIG = Object.freeze({
28+
USE_PROFILES: { html: true },
29+
FORBID_TAGS: ["script", "iframe", "object", "embed", "svg", "math"],
30+
});
31+
32+
function parseGithubRepoContext(githubUrl) {
33+
if (!githubUrl || typeof githubUrl !== "string") return null;
34+
35+
let repoUrl;
36+
try {
37+
repoUrl = new URL(githubUrl.trim().replace(/\.git$/i, ""));
38+
} catch {
39+
return null;
40+
}
41+
42+
if (repoUrl.hostname !== "github.com") return null;
43+
44+
const [owner, repo] = repoUrl.pathname
45+
.replace(/^\/+|\/+$/g, "")
46+
.split("/");
47+
if (!owner || !repo) return null;
48+
49+
return { owner, repo };
50+
}
51+
52+
function shouldSkipRebase(value) {
53+
return (
54+
!value ||
55+
value.startsWith("#") ||
56+
value.startsWith("//") ||
57+
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value)
58+
);
59+
}
60+
61+
function resolveRepoPath(value) {
62+
if (shouldSkipRebase(value)) return null;
63+
try {
64+
const resolved = new URL(value, "https://repo-root.invalid/");
65+
return `${resolved.pathname.replace(/^\/+/, "")}${resolved.search}${resolved.hash}`;
66+
} catch {
67+
return null;
68+
}
69+
}
70+
71+
function isGithubRepoRoutePath(repoPath) {
72+
const pathOnly = repoPath
73+
.split(/[?#]/, 1)[0]
74+
.replace(/^\/+|\/+$/g, "");
75+
if (!pathOnly) return false;
76+
const firstSegment = pathOnly.split("/")[0].toLowerCase();
77+
return GITHUB_REPO_ROUTE_PREFIXES.has(firstSegment);
78+
}
79+
80+
function isSafeUrlValue(value, attributeName) {
81+
const normalized = String(value || "").trim();
82+
if (!normalized) return true;
83+
if (
84+
normalized.startsWith("#") ||
85+
normalized.startsWith("/") ||
86+
normalized.startsWith("./") ||
87+
normalized.startsWith("../") ||
88+
normalized.startsWith("?")
89+
) {
90+
return true;
91+
}
92+
93+
try {
94+
const url = new URL(normalized, "https://sanitizer.invalid/");
95+
if (url.origin === "https://sanitizer.invalid") {
96+
return true;
97+
}
98+
99+
const protocol = url.protocol.toLowerCase();
100+
if (protocol === "http:" || protocol === "https:") return true;
101+
if (attributeName === "href" && (protocol === "mailto:" || protocol === "tel:")) {
102+
return true;
103+
}
104+
} catch {
105+
return false;
106+
}
107+
108+
return false;
109+
}
110+
111+
function stripUnsafeUrlAttributes(html) {
112+
const doc = new DOMParser().parseFromString(html, "text/html");
113+
114+
doc.querySelectorAll("[href], [src]").forEach((element) => {
115+
for (const attributeName of ["href", "src"]) {
116+
if (!element.hasAttribute(attributeName)) continue;
117+
const value = element.getAttribute(attributeName) || "";
118+
if (!isSafeUrlValue(value, attributeName)) {
119+
element.removeAttribute(attributeName);
120+
}
121+
}
122+
});
123+
124+
return doc.body.innerHTML;
125+
}
126+
127+
export function sanitizeHtml(html) {
128+
if (!html || typeof html !== "string") return "";
129+
const sanitized = DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
130+
return stripUnsafeUrlAttributes(sanitized);
131+
}
132+
133+
export function rebaseGithubReadmeHtml(html, githubUrl, branch) {
134+
if (!html || typeof html !== "string" || !branch) return html;
135+
136+
const repoContext = parseGithubRepoContext(githubUrl);
137+
if (!repoContext) return html;
138+
139+
const { owner, repo } = repoContext;
140+
const repoWebBase = `https://github.com/${owner}/${repo}`;
141+
const repoBlobBase = `${repoWebBase}/blob/${branch}`;
142+
const repoRawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
143+
const doc = new DOMParser().parseFromString(html, "text/html");
144+
145+
// Single-segment links like "releases" are ambiguous, so README rebasing
146+
// needs an explicit GitHub repo-route allowlist instead of a single base URL.
147+
doc.querySelectorAll("a[href]").forEach((anchor) => {
148+
const href = (anchor.getAttribute("href") || "").trim();
149+
const repoPath = resolveRepoPath(href);
150+
if (!repoPath) return;
151+
const base = isGithubRepoRoutePath(repoPath) ? repoWebBase : repoBlobBase;
152+
anchor.setAttribute("href", `${base}/${repoPath}`);
153+
});
154+
155+
doc.querySelectorAll("img[src]").forEach((image) => {
156+
const src = (image.getAttribute("src") || "").trim();
157+
const repoPath = resolveRepoPath(src);
158+
if (!repoPath) return;
159+
image.setAttribute("src", `${repoRawBase}/${repoPath}`);
160+
});
161+
162+
return doc.body.innerHTML;
163+
}
164+
165+
export function renderSafeMarkdown(markdown, options = {}) {
166+
if (!markdown) return "";
167+
168+
const { githubUrl = "", branch = "", openExternalLinksInNewTab = true } = options;
169+
170+
let html = marked.parse(markdown, { breaks: true });
171+
if (githubUrl && branch) {
172+
html = rebaseGithubReadmeHtml(html, githubUrl, branch);
173+
}
174+
175+
html = sanitizeHtml(html);
176+
177+
if (openExternalLinksInNewTab) {
178+
html = addBlankTargetsToLinks(html);
179+
}
180+
181+
return html;
182+
}

0 commit comments

Comments
 (0)