@@ -102,14 +102,15 @@ pub fn inline_html(base: &str, html: &str, resolver: Resolver) -> String {
102102 let data = data_or_keep ( "img" , resolve_url ( base, & src) , & mut * resolver) ;
103103 if let Some ( d) = data {
104104 let replaced = replace_attr ( tag, "src" , & d) ;
105- out. push_str ( & replaced) ;
105+ // 内联化后仍须过净化(原标签其余 on*/危险属性一并剥除)
106+ out. push_str ( & scrub_tag ( & replaced) ) ;
106107 continue ;
107108 }
108109 }
109- out. push_str ( tag) ;
110- } else if lower. starts_with ( "<script" ) && tag . contains ( "src=" ) {
111- // 外链脚本剔除(防快照执行第三方 JS——只存档不执行),连同闭合标签
112- out. push_str ( "<!-- entropy-snapshot: external script removed -->" ) ;
110+ out. push_str ( & scrub_tag ( tag) ) ;
111+ } else if lower. starts_with ( "<script" ) {
112+ // 全部 script 剔除(外链+行内——快照离线打开语境不得执行任何原文脚本)
113+ out. push_str ( "<!-- entropy-snapshot: script removed -->" ) ;
113114 let lower_rest = rest. to_ascii_lowercase ( ) ;
114115 if let Some ( close_idx) = lower_rest. find ( "</script" ) {
115116 if let Some ( gt) = rest[ close_idx..] . find ( '>' ) {
@@ -119,7 +120,8 @@ pub fn inline_html(base: &str, html: &str, resolver: Resolver) -> String {
119120 }
120121 // 无闭合标签的畸形脚本:继续正常扫描
121122 } else {
122- out. push_str ( tag) ;
123+ // 通用标签净化:on* 事件属性与 javascript:/data:text/html URL 剥除
124+ out. push_str ( & scrub_tag ( tag) ) ;
123125 }
124126 }
125127 out. push_str ( rest) ;
@@ -172,6 +174,55 @@ fn escape_attr(s: &str) -> String {
172174 s. replace ( '"' , """ )
173175}
174176
177+ /// 标签净化(纯函数):剥除 on* 事件属性与 javascript:/data:text/html 危险
178+ /// URL——快照是离线下发的 HTML 文档,任何可执行面归零(安全边界)。
179+ fn scrub_tag ( tag : & str ) -> String {
180+ // '>' 边界单独保留(不参与属性 token 判定——防整段误吞闭合符)
181+ let ( body, closer) = match tag. rfind ( '>' ) {
182+ Some ( i) => ( & tag[ ..i] , & tag[ i..] ) ,
183+ None => ( tag, "" ) ,
184+ } ;
185+ let mut out = String :: new ( ) ;
186+ let mut rest = body;
187+ let mut first = true ;
188+ while let Some ( ws) = rest. find ( |c : char | c. is_whitespace ( ) ) {
189+ let token = & rest[ ..ws] ;
190+ rest = rest[ ws..] . trim_start ( ) ;
191+ if first {
192+ out. push_str ( token) ;
193+ first = false ;
194+ continue ;
195+ }
196+ if !token. is_empty ( ) && !dangerous_attr ( token) {
197+ out. push ( ' ' ) ;
198+ out. push_str ( token) ;
199+ }
200+ }
201+ if first {
202+ out. push_str ( rest) ;
203+ } else if !rest. is_empty ( ) && !dangerous_attr ( rest) {
204+ out. push ( ' ' ) ;
205+ out. push_str ( rest) ;
206+ }
207+ out. push_str ( closer) ;
208+ out
209+ }
210+
211+ fn dangerous_attr ( token : & str ) -> bool {
212+ let name: String = token. chars ( ) . take_while ( |c| * c != '=' ) . collect ( ) ;
213+ let lower_name = name. to_ascii_lowercase ( ) ;
214+ if lower_name. starts_with ( "on" ) {
215+ return true ; // 事件属性(onclick/onload/onerror…)
216+ }
217+ let lower_token = token. to_ascii_lowercase ( ) ;
218+ if lower_token. contains ( "javascript:" ) || lower_token. contains ( "data:text/html" ) {
219+ return true ;
220+ }
221+ // <a>/<img> 之外标签的 data:image 保留(仅图片类 data URI 安全);href 上
222+ // 的 data:text/html 已被上一行拦截
223+ false
224+ }
225+
175226#[ cfg( test) ]
176227#[ path = "web_snapshot_tests.rs" ]
177228mod tests;
0 commit comments