@@ -89,17 +89,43 @@ fn extract_title(html: &str) -> String {
8989 String :: new ( )
9090}
9191
92- /// URL 主机名(会话标题兜底;纯函数)。
92+ /// URL 主机名(会话标题兜底;纯函数;剥 userinfo——审查 M2:凭证不得入库 )。
9393pub fn host_of ( url : & str ) -> Option < String > {
9494 let rest = url. split ( "://" ) . nth ( 1 ) ?;
95- Some (
96- rest. split ( [ '/' , '?' , '#' ] )
97- . next ( )
98- . unwrap_or ( "" )
99- . chars ( )
100- . take ( 100 )
101- . collect ( ) ,
102- )
95+ let authority = rest. split ( [ '/' , '?' , '#' ] ) . next ( ) . unwrap_or ( "" ) ;
96+ let host = authority. rsplit ( '@' ) . next ( ) . unwrap_or ( "" ) . to_string ( ) ;
97+ Some ( host. chars ( ) . take ( 100 ) . collect ( ) )
98+ }
99+
100+ /// 内网/回环/链路本地主机拦截(审查 M2:SSRF-lite 边界——公网页面的跳转与
101+ /// 子资源不得把本机/内网内容拉入产物)。局限注明:不做 DNS 再解析(域名解析
102+ /// 到内网 IP 的场景留待 resolver 级防护迭代)。
103+ pub fn is_blocked_host ( url : & str ) -> bool {
104+ let Some ( host) = host_of ( url) else { return true } ;
105+ let host = host. trim ( ) . to_ascii_lowercase ( ) ;
106+ let host = host. trim_start_matches ( '[' ) . trim_end_matches ( ']' ) ;
107+ if host. is_empty ( ) {
108+ return true ;
109+ }
110+ if host == "localhost" || host. contains ( ':' ) || host. contains ( '%' ) {
111+ return true ; // 含冒号=IPv6 字面量(::1/fe80 等回环与链路本地一并拦截)
112+ }
113+ if host == "0.0.0.0"
114+ || host. starts_with ( "127." )
115+ || host. starts_with ( "10." )
116+ || host. starts_with ( "169.254." )
117+ || host. starts_with ( "192.168." )
118+ {
119+ return true ;
120+ }
121+ if let Some ( rest) = host. strip_prefix ( "172." ) {
122+ if let Some ( second) = rest. split ( '.' ) . next ( ) . and_then ( |s| s. parse :: < u32 > ( ) . ok ( ) ) {
123+ if ( 16 ..=31 ) . contains ( & second) {
124+ return true ;
125+ }
126+ }
127+ }
128+ false
103129}
104130
105131/// HTML → Markdown 正文(轻量规则:script/style 剔除;块级换行;标题/列表/
0 commit comments