Skip to content

Commit 213505b

Browse files
authored
Merge pull request #12 from xrobot-org/codex/issue4-sanitize-deploy
[codex] Sanitize onboarding markdown rendering
2 parents 398cdd9 + 702a9a1 commit 213505b

1 file changed

Lines changed: 124 additions & 5 deletions

File tree

static/XRobot-Onboarding/onboarding.js

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,125 @@ function md(text) {
1818
return String(text);
1919
}
2020

21+
const ALLOWED_HTML_TAGS = new Set([
22+
"A",
23+
"B",
24+
"BLOCKQUOTE",
25+
"BR",
26+
"CODE",
27+
"DEL",
28+
"EM",
29+
"H1",
30+
"H2",
31+
"H3",
32+
"H4",
33+
"H5",
34+
"H6",
35+
"HR",
36+
"I",
37+
"LI",
38+
"OL",
39+
"P",
40+
"PRE",
41+
"STRONG",
42+
"UL"
43+
]);
44+
45+
const ALLOWED_HTML_ATTRS = {
46+
A: new Set(["href", "title"])
47+
};
48+
49+
function isSafeUrl(url) {
50+
if (!url) return false;
51+
52+
const normalized = String(url).trim();
53+
if (!normalized) return false;
54+
55+
if (normalized.startsWith("#") || normalized.startsWith("/")) {
56+
return true;
57+
}
58+
59+
try {
60+
const parsed = new URL(normalized, window.location.href);
61+
return ["http:", "https:", "mailto:"].includes(parsed.protocol);
62+
} catch (e) {
63+
return false;
64+
}
65+
}
66+
67+
function isExternalHttpUrl(url) {
68+
try {
69+
const parsed = new URL(url, window.location.href);
70+
return ["http:", "https:"].includes(parsed.protocol) && parsed.origin !== window.location.origin;
71+
} catch (e) {
72+
return false;
73+
}
74+
}
75+
76+
function sanitizeHtml(html) {
77+
const parsedHtml = new DOMParser().parseFromString(String(html || ""), "text/html");
78+
79+
const sanitizeNode = node => {
80+
if (node.nodeType === Node.TEXT_NODE) {
81+
return document.createTextNode(node.textContent || "");
82+
}
83+
84+
if (node.nodeType !== Node.ELEMENT_NODE) {
85+
return document.createDocumentFragment();
86+
}
87+
88+
const tagName = node.tagName.toUpperCase();
89+
if (!ALLOWED_HTML_TAGS.has(tagName)) {
90+
const fragment = document.createDocumentFragment();
91+
Array.from(node.childNodes).forEach(child => {
92+
fragment.appendChild(sanitizeNode(child));
93+
});
94+
return fragment;
95+
}
96+
97+
const clean = document.createElement(tagName.toLowerCase());
98+
const allowedAttrs = ALLOWED_HTML_ATTRS[tagName] || new Set();
99+
100+
Array.from(node.attributes).forEach(attr => {
101+
const attrName = attr.name.toLowerCase();
102+
if (!allowedAttrs.has(attrName)) {
103+
return;
104+
}
105+
106+
if (attrName === "href" && !isSafeUrl(attr.value)) {
107+
return;
108+
}
109+
110+
clean.setAttribute(attr.name, attr.value);
111+
});
112+
113+
if (tagName === "A" && clean.hasAttribute("href")) {
114+
const href = clean.getAttribute("href");
115+
if (isExternalHttpUrl(href)) {
116+
clean.setAttribute("rel", "noopener noreferrer");
117+
clean.setAttribute("target", "_blank");
118+
}
119+
}
120+
121+
Array.from(node.childNodes).forEach(child => {
122+
clean.appendChild(sanitizeNode(child));
123+
});
124+
125+
return clean;
126+
};
127+
128+
const fragment = document.createDocumentFragment();
129+
Array.from(parsedHtml.body.childNodes).forEach(child => {
130+
fragment.appendChild(sanitizeNode(child));
131+
});
132+
return fragment;
133+
}
134+
135+
function setSanitizedMarkdown(el, markdownText) {
136+
if (!el) return;
137+
el.replaceChildren(sanitizeHtml(md(markdownText || "")));
138+
}
139+
21140
function getStoredThemePreference() {
22141
try {
23142
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
@@ -110,7 +229,7 @@ function loadMdIntoElement(path, el) {
110229

111230
// 有缓存就直接用
112231
if (mdTextCache[path]) {
113-
el.innerHTML = md(mdTextCache[path]);
232+
setSanitizedMarkdown(el, mdTextCache[path]);
114233
return;
115234
}
116235

@@ -126,7 +245,7 @@ function loadMdIntoElement(path, el) {
126245
})
127246
.then(text => {
128247
mdTextCache[path] = text;
129-
el.innerHTML = md(text);
248+
setSanitizedMarkdown(el, text);
130249
})
131250
.catch(err => {
132251
console.error(err);
@@ -396,7 +515,7 @@ function renderChoiceNode(node) {
396515
if (opt.desc) {
397516
const d = document.createElement("div");
398517
d.className = "choice-desc";
399-
d.innerHTML = md(opt.desc);
518+
setSanitizedMarkdown(d, opt.desc);
400519
main.appendChild(d);
401520
}
402521

@@ -546,7 +665,7 @@ function renderTaskNode(nodeRaw) {
546665
} else if (t.desc) {
547666
const desc = document.createElement("div");
548667
desc.className = "task-desc";
549-
desc.innerHTML = md(t.desc);
668+
setSanitizedMarkdown(desc, t.desc);
550669
main.appendChild(desc);
551670
}
552671

@@ -617,7 +736,7 @@ function render() {
617736
const app = document.getElementById("app");
618737
if (!app) return;
619738

620-
app.innerHTML = "";
739+
app.replaceChildren();
621740

622741
const currentId = state.currentNodeId || treeConfig.root;
623742
const nodeRaw = treeConfig.nodes[currentId];

0 commit comments

Comments
 (0)