From 6d45cec2033901d19b0626fd24275b55d9cc3da8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 23 Sep 2026 01:06:31 +0800 Subject: [PATCH] fix(security): promote unknown bare elements for element bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream routes element bindings through `calcPossibleSecurityContexts` (`binding_parser.ts`), which promotes a bare element missing from the DOM schema to its `:svg:`/`:math:` form — the same promotion our host-binding path already applies for selectors. Our element-binding path looked up the verbatim name, so `` missed `:svg:animate|to` and emitted no `ɵɵvalidateAttribute`. `security_context` now shares `collect_namespaced_contexts` / `collect_bare_contexts` via `element_security_context_for`, which keeps the numerically lowest context — matching upstream's `securityContexts[0]` after its enum sort. That preserves `NONE` for `tagName === null` selectorless hosts, which expand over every known element upstream. Upstream quirks preserved: - Promotion exists only alongside the namespaced schema (19.2.23 / 20.3.22 / 21.2.14 / v22). On earlier versions the whole selector goes through `CssSelector.parse` verbatim, so `:svg:animate` still parses to element `animate` and hits the bare `animate|to` key — `` keeps `ɵɵvalidateAttribute` at 21.2.7. - `:xml:iframe` on pre-namespaced versions likewise parses to `iframe` and hits `iframe|src`; only namespaced versions split the prefix, and only normalizer versions (19.2.23 / 20.3.22 / 21.2.15+) strip it back — 21.2.14 alone misses, matching upstream. --- .../src/schema/dom_security_schema.rs | 82 ++++++++++++++++++- crates/oxc_angular_compiler/src/schema/mod.rs | 4 +- .../src/transform/html_to_r3.rs | 32 +++++--- .../tests/integration_test.rs | 30 +++++++ 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs index f7c1f7155..61ade27eb 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -871,6 +871,33 @@ pub fn host_binding_security_context_for( reduce_security_contexts(&contexts) } +/// Security context of a property/attribute binding on a concrete element, +/// for a specific Angular version. +/// +/// Upstream routes element bindings through `calcPossibleSecurityContexts` +/// with the element name as the selector, so a bare element missing from the +/// DOM schema is promoted to its `:svg:`/`:math:` form (e.g. `` → +/// `:svg:animate`) on the namespaced schema, and a `tagName === null` +/// selectorless host expands over every known element. The bound attribute +/// keeps `securityContexts[0]` — the numerically lowest context after +/// upstream's sort, which keeps `NONE` ahead of `URL`/`RESOURCE_URL`. +pub fn element_security_context_for( + element: &str, + prop_name: &str, + version: Option, +) -> SecurityContext { + let contexts = if security_profile(version).namespaced { + collect_namespaced_contexts(element, prop_name, version) + } else { + collect_bare_contexts(element, prop_name, version) + }; + contexts + .iter() + .copied() + .min_by_key(|ctx| security_context_rank(*ctx)) + .unwrap_or(SecurityContext::None) +} + fn collect_namespaced_contexts( selector: &str, prop_name: &str, @@ -916,8 +943,12 @@ fn collect_namespaced_contexts( contexts } -/// v21 `calcPossibleSecurityContexts`: no namespace rewrite, and `:not(element)` -/// matches the element string exactly, including case. +/// Pre-namespaced `calcPossibleSecurityContexts` (everything before the +/// 19.2.23 / 20.3.22 / 21.2.14 / v22 schema): no `splitNsName`, no element +/// promotion — the whole selector goes through `CssSelector.parse`, so +/// `:svg:animate` parses to element `animate` and hits the bare +/// `animate|to` key, and `:not(element)` matches the element string +/// exactly, including case. fn collect_bare_contexts( selector: &str, prop_name: &str, @@ -1232,6 +1263,53 @@ mod tests { ); } + #[test] + fn test_element_binding_promotes_unknown_bare_element() { + // Element bindings go through `calcPossibleSecurityContexts` upstream: + // a bare element missing from the DOM schema is looked up as its + // `:svg:`/`:math:` form on the namespaced schema. + assert_eq!( + element_security_context_for("animate", "to", None), + SecurityContext::AttributeNoBinding + ); + assert_eq!( + element_security_context_for("set", "to", None), + SecurityContext::AttributeNoBinding + ); + // The local name promotes even under a different namespace, matching + // upstream (`hasElement(':math:animate')` misses, so it tries + // `:svg:animate` / `:math:animate` and keeps the hit's own prefix). + assert_eq!( + element_security_context_for(":math:animate", "to", None), + SecurityContext::AttributeNoBinding + ); + // No promotion before the namespaced schema (19.2.23 / 20.3.22 / + // 21.2.14 / v22): `animate|to` is a verbatim miss. + let version = Some(crate::AngularVersion::new(21, 0, 1)); + assert_eq!(element_security_context_for("animate", "to", version), SecurityContext::None); + // Known elements resolve directly on every version. + assert_eq!( + element_security_context_for("iframe", "src", None), + SecurityContext::ResourceUrl + ); + assert_eq!( + element_security_context_for("iframe", "src", version), + SecurityContext::ResourceUrl + ); + // Unknown in every namespace stays a miss. + assert_eq!(element_security_context_for("bogus", "src", None), SecurityContext::None); + // `tagName === null` expands over every known element upstream, and + // the binding keeps `securityContexts[0]` — the numerically lowest, + // so `NONE` wins over URL/RESOURCE_URL. + assert_eq!(element_security_context_for("", "src", None), SecurityContext::None); + // `formAction` hits the `*|formAction` URL wildcard key, so the + // lowest context is URL. + assert_eq!(element_security_context_for("", "formAction", None), SecurityContext::Url); + // `innerHTML` hits the `*|innerHTML` HTML key for every element, so + // the lowest context is HTML. + assert_eq!(element_security_context_for("", "innerHTML", None), SecurityContext::Html); + } + #[test] fn test_host_comma_selector_merges_url_kinds() { assert_eq!( diff --git a/crates/oxc_angular_compiler/src/schema/mod.rs b/crates/oxc_angular_compiler/src/schema/mod.rs index 0c7b2f1cc..a2208a395 100644 --- a/crates/oxc_angular_compiler/src/schema/mod.rs +++ b/crates/oxc_angular_compiler/src/schema/mod.rs @@ -7,8 +7,8 @@ mod dom_security_schema; mod trusted_types_sinks; pub use dom_security_schema::{ - calc_security_context_for_unknown_element, get_security_context, get_security_context_for, - host_binding_security_context, host_binding_security_context_for, + calc_security_context_for_unknown_element, element_security_context_for, get_security_context, + get_security_context_for, host_binding_security_context, host_binding_security_context_for, is_iframe_security_sensitive_attr, is_known_element, rejects_iframe_src_i18n, strips_namespaced_svg_script, strips_namespaced_svg_style, uses_iframe_attr_validation, uses_namespaced_schema, diff --git a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs index a70c31055..ebfad31f6 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -31,7 +31,7 @@ use crate::i18n::placeholder::PlaceholderRegistry; use crate::parser::expression::{BindingParser, find_comment_start}; use crate::parser::html::{decode_entities_in_string, get_html_tag_definition, split_ns_name}; use crate::schema::{ - get_security_context_for, is_known_element, is_trusted_types_sink_at, + element_security_context_for, is_known_element, is_trusted_types_sink_at, strips_namespaced_svg_script, strips_namespaced_svg_style, uses_namespaced_schema, }; use crate::transform::control_flow::{parse_conditional_params, parse_defer_triggers}; @@ -203,7 +203,11 @@ impl<'a> HtmlToR3Transform<'a> { } fn security_context(&self, element: &str, property: &str) -> SecurityContext { - get_security_context_for(element, property, self.angular_version) + // Element bindings go through `calcPossibleSecurityContexts` upstream, + // which promotes a bare element missing from the DOM schema to its + // `:svg:`/`:math:` form (`` → `:svg:animate`) on the + // namespaced schema. + element_security_context_for(element, property, self.angular_version) } /// Allocates a new unique instance ID for an i18n message. @@ -5105,7 +5109,10 @@ mod security_tests { } #[test] - fn v21_keeps_svg_script_and_does_not_validate_namespaced_animate() { + fn v21_keeps_svg_script_and_validates_namespaced_animate() { + // `:svg:animate` parses to element `animate` on the pre-namespaced + // selector path (upstream has no `splitNsName` there), so `to` hits + // the bare `animate|to` attributeNoBinding key at 21.2.7. let version = Some(AngularVersion::new(21, 2, 7)); let (names, contexts, _) = compile_at( r#""#, @@ -5113,7 +5120,9 @@ mod security_tests { ); assert!(names.iter().any(|name| name.contains("script")), "{names:?}"); assert!( - contexts.iter().any(|(name, ctx)| name == "to" && *ctx == SecurityContext::None), + contexts + .iter() + .any(|(name, ctx)| name == "to" && *ctx == SecurityContext::AttributeNoBinding), "{contexts:?}" ); } @@ -5406,16 +5415,19 @@ mod security_tests { #[test] fn prefixed_element_lookup_matches_the_versions_normalize_tag_name() { - // `normalizeTagName` in `securityContext` only exists at 19.2.23 / - // 20.3.22 / 21.2.15 and later. Before that the tag is lowercased - // verbatim, so `:xml:iframe|src` is not the `iframe|src` sink. + // Pre-namespaced versions feed `:xml:iframe` to `CssSelector.parse` + // verbatim, which yields element `iframe`, so `src` resolves to + // `iframe|src` — the sink still applies. The namespaced schema splits + // the `:xml:` prefix; only versions with `normalizeTagName` (19.2.23 / + // 20.3.22 / 21.2.15+) strip it back to `iframe`, while 21.2.14 looks + // up `:xml:iframe|src` verbatim and misses. for (major, minor, patch, expected) in [ - (21, 2, 13, SecurityContext::None), + (21, 2, 13, SecurityContext::ResourceUrl), (21, 2, 14, SecurityContext::None), (21, 2, 15, SecurityContext::ResourceUrl), - (19, 2, 22, SecurityContext::None), + (19, 2, 22, SecurityContext::ResourceUrl), (19, 2, 23, SecurityContext::ResourceUrl), - (20, 3, 21, SecurityContext::None), + (20, 3, 21, SecurityContext::ResourceUrl), (20, 3, 22, SecurityContext::ResourceUrl), ] { let (_, contexts, _) = compile_at( diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 7ad1ef87e..7224a06fe 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -10396,6 +10396,36 @@ fn test_iframe_sensitive_attr_validation_legacy_versions() { ); } +#[test] +fn test_svg_animation_attr_binding_validates_on_namespaced_schema() { + // Upstream routes element bindings through calcPossibleSecurityContexts, + // which promotes a bare element missing from the DOM schema to its + // `:svg:`/`:math:` form. `` therefore resolves to + // `:svg:animate|to` → attributeNoBinding → ɵɵvalidateAttribute. + for version in [None, Some(AngularVersion::new(21, 2, 14))] { + let js = compile_template_to_js_with_version( + r#""#, + "TestComponent", + version, + ); + assert!( + js.contains("ɵɵvalidateAttribute"), + "v{version:?} should emit ɵɵvalidateAttribute for . Got:\n{js}" + ); + } + // Before the namespaced schema (19.2.23 / 20.3.22 / 21.2.14 / v22) there is + // no promotion and `animate|to` is a verbatim miss. + let js = compile_template_to_js_with_version( + r#""#, + "TestComponent", + Some(AngularVersion::new(21, 0, 1)), + ); + assert!( + !js.contains("ɵɵvalidateAttribute"), + "v21.0.1 should not emit ɵɵvalidateAttribute for . Got:\n{js}" + ); +} + // ============================================================================ // Host Directive Alias Tests // ============================================================================