Skip to content

Commit 489d364

Browse files
fix(parser): block unsupported DOM complex attributes (#185)
1 parent a2578b3 commit 489d364

7 files changed

Lines changed: 151 additions & 57 deletions

File tree

crates/webui-parser/src/lib.rs

Lines changed: 139 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

examples/app/commerce/src/molecules/mp-search-bar/mp-search-bar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<circle cx="11" cy="11" r="8"></circle>
44
<path d="m21 21-4.35-4.35"></path>
55
</svg>
6-
<input class="search-input" type="text" name="q" :value="{{query}}" placeholder="{{placeholder}}" autocomplete="off"
6+
<input class="search-input" type="text" name="q" value="{{query}}" placeholder="{{placeholder}}" autocomplete="off"
77
@input="{onInput(e)}"
88
aria-label="{{label}}" />
99
</form>

examples/app/commerce/src/pages/mp-page-product/mp-page-product.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ <h1 class="product-title">{{productTitle}}</h1>
2525
@variant-select="{onVariantSelect(e)}"
2626
></mp-variant-selector>
2727

28-
<div class="product-description" :innerHTML="{{descriptionHtml}}"></div>
28+
<div class="product-description">{{{descriptionHtml}}}</div>
2929

3030
<mp-add-to-cart
3131
handle="{{handle}}"
4.33 KB
Loading

examples/app/contact-book-manager/src/molecules/cb-search-bar/cb-search-bar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="search-container">
22
<span class="search-icon"></span>
3-
<input class="search-input" type="text" placeholder="{{placeholder}}" :value="{{value}}" @input="{onInput(e)}" />
3+
<input class="search-input" type="text" placeholder="{{placeholder}}" value="{{value}}" @input="{onInput(e)}" />
44
<if condition="value">
55
<button class="clear-btn" type="button" title="Clear" @click="{onClear()}">&times;</button>
66
</if>

examples/app/contact-book-manager/src/organisms/cb-contact-form/cb-contact-form.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,34 @@
11
<h2 class="form-title">{{formTitle}}</h2>
2-
<input type="hidden" name="_editId" :value="{{editId}}" />
2+
<input type="hidden" name="_editId" value="{{editId}}" />
33
<div class="form-grid">
44
<div class="form-field">
55
<label class="field-label">First Name</label>
6-
<input class="field-input" type="text" placeholder="Enter first name" :value="{{firstName}}" name="firstName"
6+
<input class="field-input" type="text" placeholder="Enter first name" value="{{firstName}}" name="firstName"
77
@input="{onFieldInput(e)}" />
88
</div>
99
<div class="form-field">
1010
<label class="field-label">Last Name</label>
11-
<input class="field-input" type="text" placeholder="Enter last name" :value="{{lastName}}" name="lastName"
11+
<input class="field-input" type="text" placeholder="Enter last name" value="{{lastName}}" name="lastName"
1212
@input="{onFieldInput(e)}" />
1313
</div>
1414
<div class="form-field">
1515
<label class="field-label">Email</label>
16-
<input class="field-input" type="email" placeholder="email@example.com" :value="{{email}}" name="email"
16+
<input class="field-input" type="email" placeholder="email@example.com" value="{{email}}" name="email"
1717
@input="{onFieldInput(e)}" />
1818
</div>
1919
<div class="form-field">
2020
<label class="field-label">Phone</label>
21-
<input class="field-input" type="tel" placeholder="+1 (555) 000-0000" :value="{{phone}}" name="phone"
21+
<input class="field-input" type="tel" placeholder="+1 (555) 000-0000" value="{{phone}}" name="phone"
2222
@input="{onFieldInput(e)}" />
2323
</div>
2424
<div class="form-field">
2525
<label class="field-label">Company</label>
26-
<input class="field-input" type="text" placeholder="Company name" :value="{{company}}" name="company"
26+
<input class="field-input" type="text" placeholder="Company name" value="{{company}}" name="company"
2727
@input="{onFieldInput(e)}" />
2828
</div>
2929
<div class="form-field">
3030
<label class="field-label">Address</label>
31-
<input class="field-input" type="text" placeholder="Street address" :value="{{address}}" name="address"
31+
<input class="field-input" type="text" placeholder="Street address" value="{{address}}" name="address"
3232
@input="{onFieldInput(e)}" />
3333
</div>
3434
</div>
@@ -45,7 +45,7 @@ <h2 class="form-title">{{formTitle}}</h2>
4545
</div>
4646
<div class="form-field-full">
4747
<label class="field-label">Notes</label>
48-
<textarea class="notes-input" placeholder="Add notes..." rows="3" name="notes" :value="{{notes}}"
48+
<textarea class="notes-input" placeholder="Add notes..." rows="3" name="notes" value="{{notes}}"
4949
@input="{onNotesInput(e)}"></textarea>
5050
</div>
5151
<div class="form-actions">

examples/app/contact-book-manager/src/organisms/cb-header/cb-header.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ <h1 class="title">Contacts</h1>
44
<div class="header-center">
55
<div class="search-container">
66
<span class="search-icon"></span>
7-
<input class="search-input" type="text" placeholder="Search contacts..." :value="{{searchQuery}}" @input="{onInput(e)}" />
7+
<input class="search-input" type="text" placeholder="Search contacts..." value="{{searchQuery}}" @input="{onInput(e)}" />
88
</div>
99
</div>
1010
<div class="header-right">

0 commit comments

Comments
 (0)