@@ -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+
21140function 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