@@ -921,22 +921,34 @@ impl HtmlParser {
921921 ) ? {
922922 binding_count += 1 ;
923923 }
924- } else if attr_name. starts_with ( ':' ) {
924+ } else if let Some ( prop_name ) = attr_name. strip_prefix ( ':' ) {
925925 // Complex attribute: :config="{{settings}}"
926- if is_component {
927- if let Some ( val) = & attr_value {
928- if let Some ( signal_name) = Self :: extract_single_handlebars ( val) {
929- let frag = Self :: maybe_mark_attr_start (
930- WebUIFragment :: attribute_complex ( & attr_name, signal_name) ,
931- & mut first_dynamic_emitted,
932- ) ;
933- self . add_fragment ( frag, fragments) ;
934- binding_count += 1 ;
935- }
926+ // Only valid on custom elements — passes JS values by
927+ // reference (objects, arrays). Native HTML elements should
928+ // use regular attribute bindings (the framework already
929+ // handles value, checked, selected as DOM properties).
930+ if !is_component {
931+ return Err ( ParserError :: Parse ( format ! (
932+ ":{prop_name} complex binding is only allowed on custom elements. \
933+ Use {prop_name}=\" {{{{expr}}}}\" for native HTML elements."
934+ ) ) ) ;
935+ }
936+ if Self :: is_blocked_complex_property ( prop_name) {
937+ return Err ( ParserError :: Parse ( format ! (
938+ ":{prop_name} is not allowed as a complex attribute binding \
939+ because it enables arbitrary HTML injection. \
940+ Use {{{{{{expr}}}}}} (triple-brace) syntax for raw HTML."
941+ ) ) ) ;
942+ }
943+ if let Some ( val) = & attr_value {
944+ if let Some ( signal_name) = Self :: extract_single_handlebars ( val) {
945+ let frag = Self :: maybe_mark_attr_start (
946+ WebUIFragment :: attribute_complex ( & attr_name, signal_name) ,
947+ & mut first_dynamic_emitted,
948+ ) ;
949+ self . add_fragment ( frag, fragments) ;
950+ binding_count += 1 ;
936951 }
937- } else {
938- self . process_complex_attribute ( & attr_name, attr_value. as_deref ( ) , fragments) ?;
939- binding_count += 1 ;
940952 }
941953 } else if is_component && Self :: is_skipped_attribute ( & attr_name) {
942954 // Skipped component attribute (class, style, role, data-*, aria-*)
@@ -1129,27 +1141,6 @@ impl HtmlParser {
11291141 Ok ( false )
11301142 }
11311143
1132- /// Process a complex attribute (:prefix).
1133- fn process_complex_attribute (
1134- & mut self ,
1135- name : & str ,
1136- value : Option < & str > ,
1137- fragments : & mut Vec < WebUIFragment > ,
1138- ) -> Result < ( ) > {
1139- if let Some ( val) = value {
1140- if let Some ( signal_name) = Self :: extract_single_handlebars ( val) {
1141- self . add_fragment (
1142- WebUIFragment :: attribute_complex ( name, signal_name) ,
1143- fragments,
1144- ) ;
1145- return Ok ( ( ) ) ;
1146- }
1147- }
1148- // No valid handlebars — emit as raw
1149- self . add_raw_fragment ( & format ! ( " {}=\" {}\" " , name, value. unwrap_or( "" ) ) ) ;
1150- Ok ( ( ) )
1151- }
1152-
11531144 /// Process a dynamic attribute (regular name with handlebars in value).
11541145 fn process_dynamic_attribute (
11551146 & mut self ,
@@ -1542,6 +1533,12 @@ impl HtmlParser {
15421533 . any ( |prefix| name. starts_with ( prefix) )
15431534 }
15441535
1536+ /// Properties that must never be set via `:attr` complex bindings.
1537+ /// These enable XSS (HTML injection) or arbitrary code execution.
1538+ fn is_blocked_complex_property ( name : & str ) -> bool {
1539+ matches ! ( name, "innerHTML" | "outerHTML" | "srcdoc" | "content" ) || name. starts_with ( "on" )
1540+ }
1541+
15451542 fn process_component_directive (
15461543 & mut self ,
15471544 node : Node ,
@@ -2373,6 +2370,24 @@ mod tests {
23732370 ( fragments, records)
23742371 }
23752372
2373+ /// Helper to parse HTML with a pre-registered component.
2374+ fn parse_with_component ( tag : & str , html : & str ) -> ( Vec < WebUIFragment > , WebUIFragmentRecords ) {
2375+ let mut parser = HtmlParser :: new ( ) ;
2376+ parser
2377+ . component_registry
2378+ . register_component ( tag, "<div></div>" , None )
2379+ . expect ( "register" ) ;
2380+ let result = parser. parse ( "index.html" , html) ;
2381+ assert ! ( result. is_ok( ) , "Parse error: {:?}" , result. err( ) ) ;
2382+ let records = parser. into_fragment_records ( ) ;
2383+ let fragments = records
2384+ . get ( "index.html" )
2385+ . expect ( "Failed to get index.html fragment" )
2386+ . fragments
2387+ . clone ( ) ;
2388+ ( fragments, records)
2389+ }
2390+
23762391 #[ test]
23772392 fn test_attribute_handlebars_in_href ( ) {
23782393 // Port of: 'should process handlebars from attributes as signals'
@@ -2499,15 +2514,19 @@ mod tests {
24992514 fn test_attribute_colon_prefixed_complex ( ) {
25002515 // Port of: 'should process colon-prefixed attribute with handlebars'
25012516 // <my-component :config="{{settings}}"></my-component>
2502- let ( fragments, _) =
2503- parse_and_get_fragments ( r#"<my-component :config="{{settings}}"></my-component>"# ) ;
2517+ let ( fragments, _) = parse_with_component (
2518+ "my-component" ,
2519+ r#"<my-component :config="{{settings}}"></my-component>"# ,
2520+ ) ;
25042521
25052522 assert_fragments ! (
25062523 fragments,
25072524 [
25082525 raw( "<my-component" ) ,
2509- attr_complex( ":config" , "settings" ) ,
2510- raw( "></my-component>" ) ,
2526+ attr_complex_start( ":config" , "settings" ) ,
2527+ raw( ">" ) ,
2528+ component( "my-component" ) ,
2529+ raw( "</my-component>" ) ,
25112530 ]
25122531 ) ;
25132532 }
@@ -2516,17 +2535,88 @@ mod tests {
25162535 fn test_attribute_multiple_colon_prefixed ( ) {
25172536 // Port of: 'should process multiple colon-prefixed complex attributes'
25182537 // <my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>
2519- let ( fragments, _) = parse_and_get_fragments (
2538+ let ( fragments, _) = parse_with_component (
2539+ "my-component" ,
25202540 r#"<my-component :prop1="{{val1}}" :prop2="{{val2}}"></my-component>"# ,
25212541 ) ;
25222542
25232543 assert_fragments ! (
25242544 fragments,
25252545 [
25262546 raw( "<my-component" ) ,
2527- attr_complex ( ":prop1" , "val1" ) ,
2547+ attr_complex_start ( ":prop1" , "val1" ) ,
25282548 attr_complex( ":prop2" , "val2" ) ,
2529- raw( "></my-component>" ) ,
2549+ raw( ">" ) ,
2550+ component( "my-component" ) ,
2551+ raw( "</my-component>" ) ,
2552+ ]
2553+ ) ;
2554+ }
2555+
2556+ #[ test]
2557+ fn test_blocked_complex_property_innerhtml ( ) {
2558+ let mut parser = HtmlParser :: new ( ) ;
2559+ let result = parser. parse ( "index.html" , r#"<div :innerHTML="{{content}}"></div>"# ) ;
2560+ assert ! (
2561+ result. is_err( ) ,
2562+ "Expected error for :innerHTML on native element"
2563+ ) ;
2564+ let err = result. unwrap_err ( ) . to_string ( ) ;
2565+ assert ! (
2566+ err. contains( "only allowed on custom elements" ) ,
2567+ "Error: {err}"
2568+ ) ;
2569+ }
2570+
2571+ #[ test]
2572+ fn test_blocked_complex_on_native_element ( ) {
2573+ let mut parser = HtmlParser :: new ( ) ;
2574+ let result = parser. parse ( "index.html" , r#"<div :data="{{config}}"></div>"# ) ;
2575+ assert ! (
2576+ result. is_err( ) ,
2577+ "Expected error for :data on native element"
2578+ ) ;
2579+ let err = result. unwrap_err ( ) . to_string ( ) ;
2580+ assert ! (
2581+ err. contains( "only allowed on custom elements" ) ,
2582+ "Error: {err}"
2583+ ) ;
2584+ }
2585+
2586+ #[ test]
2587+ fn test_blocked_complex_property_on_component ( ) {
2588+ let mut parser = HtmlParser :: new ( ) ;
2589+ parser
2590+ . component_registry
2591+ . register_component ( "my-widget" , "<div></div>" , None )
2592+ . expect ( "register" ) ;
2593+ let result = parser. parse (
2594+ "index.html" ,
2595+ r#"<my-widget :innerHTML="{{html}}"></my-widget>"# ,
2596+ ) ;
2597+ assert ! (
2598+ result. is_err( ) ,
2599+ "Expected error for :innerHTML on component"
2600+ ) ;
2601+ let err = result. unwrap_err ( ) . to_string ( ) ;
2602+ assert ! ( err. contains( "HTML injection" ) , "Error: {err}" ) ;
2603+ }
2604+
2605+ #[ test]
2606+ fn test_allowed_complex_property ( ) {
2607+ // :config on a component should still work
2608+ let ( fragments, _) = parse_with_component (
2609+ "my-component" ,
2610+ r#"<my-component :config="{{settings}}"></my-component>"# ,
2611+ ) ;
2612+ assert_fragments ! (
2613+ fragments,
2614+ [
2615+ raw( "<my-component" ) ,
2616+ attr_complex_start( ":config" , "settings" ) ,
2617+ raw( ">" ) ,
2618+ component( "my-component" ) ,
2619+ raw( "</my-component>" ) ,
25302620 ]
25312621 ) ;
25322622 }
@@ -2535,17 +2625,21 @@ mod tests {
25352625 fn test_attribute_mixed_normal_boolean_colon ( ) {
25362626 // Port of: 'should process mixed normal, boolean, and colon-prefixed attributes'
25372627 // <my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>
2538- let ( fragments, _) = parse_and_get_fragments (
2628+ let ( fragments, _) = parse_with_component (
2629+ "my-component" ,
25392630 r#"<my-component id="comp" :config="{{settings}}" ?enabled="{{isEnabled}}"></my-component>"# ,
25402631 ) ;
25412632
25422633 assert_fragments ! (
25432634 fragments,
25442635 [
2545- raw( "<my-component id=\" comp\" " ) ,
2636+ raw( "<my-component" ) ,
2637+ attr_raw_start( "id" , "comp" ) ,
25462638 attr_complex( ":config" , "settings" ) ,
25472639 bool_attr( "enabled" , "isEnabled" ) ,
2548- raw( "></my-component>" ) ,
2640+ raw( ">" ) ,
2641+ component( "my-component" ) ,
2642+ raw( "</my-component>" ) ,
25492643 ]
25502644 ) ;
25512645 }
0 commit comments