Skip to content

Commit 41a66ed

Browse files
authored
fix(security): promote unknown bare elements for element bindings (#490)
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 `<animate [attr.to]>` 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 — `<svg><animate [attr.to]>` 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.
1 parent 0baf86c commit 41a66ed

4 files changed

Lines changed: 134 additions & 14 deletions

File tree

crates/oxc_angular_compiler/src/schema/dom_security_schema.rs

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,33 @@ pub fn host_binding_security_context_for(
871871
reduce_security_contexts(&contexts)
872872
}
873873

874+
/// Security context of a property/attribute binding on a concrete element,
875+
/// for a specific Angular version.
876+
///
877+
/// Upstream routes element bindings through `calcPossibleSecurityContexts`
878+
/// with the element name as the selector, so a bare element missing from the
879+
/// DOM schema is promoted to its `:svg:`/`:math:` form (e.g. `<animate>` →
880+
/// `:svg:animate`) on the namespaced schema, and a `tagName === null`
881+
/// selectorless host expands over every known element. The bound attribute
882+
/// keeps `securityContexts[0]` — the numerically lowest context after
883+
/// upstream's sort, which keeps `NONE` ahead of `URL`/`RESOURCE_URL`.
884+
pub fn element_security_context_for(
885+
element: &str,
886+
prop_name: &str,
887+
version: Option<crate::AngularVersion>,
888+
) -> SecurityContext {
889+
let contexts = if security_profile(version).namespaced {
890+
collect_namespaced_contexts(element, prop_name, version)
891+
} else {
892+
collect_bare_contexts(element, prop_name, version)
893+
};
894+
contexts
895+
.iter()
896+
.copied()
897+
.min_by_key(|ctx| security_context_rank(*ctx))
898+
.unwrap_or(SecurityContext::None)
899+
}
900+
874901
fn collect_namespaced_contexts(
875902
selector: &str,
876903
prop_name: &str,
@@ -916,8 +943,12 @@ fn collect_namespaced_contexts(
916943
contexts
917944
}
918945

919-
/// v21 `calcPossibleSecurityContexts`: no namespace rewrite, and `:not(element)`
920-
/// matches the element string exactly, including case.
946+
/// Pre-namespaced `calcPossibleSecurityContexts` (everything before the
947+
/// 19.2.23 / 20.3.22 / 21.2.14 / v22 schema): no `splitNsName`, no element
948+
/// promotion — the whole selector goes through `CssSelector.parse`, so
949+
/// `:svg:animate` parses to element `animate` and hits the bare
950+
/// `animate|to` key, and `:not(element)` matches the element string
951+
/// exactly, including case.
921952
fn collect_bare_contexts(
922953
selector: &str,
923954
prop_name: &str,
@@ -1232,6 +1263,53 @@ mod tests {
12321263
);
12331264
}
12341265

1266+
#[test]
1267+
fn test_element_binding_promotes_unknown_bare_element() {
1268+
// Element bindings go through `calcPossibleSecurityContexts` upstream:
1269+
// a bare element missing from the DOM schema is looked up as its
1270+
// `:svg:`/`:math:` form on the namespaced schema.
1271+
assert_eq!(
1272+
element_security_context_for("animate", "to", None),
1273+
SecurityContext::AttributeNoBinding
1274+
);
1275+
assert_eq!(
1276+
element_security_context_for("set", "to", None),
1277+
SecurityContext::AttributeNoBinding
1278+
);
1279+
// The local name promotes even under a different namespace, matching
1280+
// upstream (`hasElement(':math:animate')` misses, so it tries
1281+
// `:svg:animate` / `:math:animate` and keeps the hit's own prefix).
1282+
assert_eq!(
1283+
element_security_context_for(":math:animate", "to", None),
1284+
SecurityContext::AttributeNoBinding
1285+
);
1286+
// No promotion before the namespaced schema (19.2.23 / 20.3.22 /
1287+
// 21.2.14 / v22): `animate|to` is a verbatim miss.
1288+
let version = Some(crate::AngularVersion::new(21, 0, 1));
1289+
assert_eq!(element_security_context_for("animate", "to", version), SecurityContext::None);
1290+
// Known elements resolve directly on every version.
1291+
assert_eq!(
1292+
element_security_context_for("iframe", "src", None),
1293+
SecurityContext::ResourceUrl
1294+
);
1295+
assert_eq!(
1296+
element_security_context_for("iframe", "src", version),
1297+
SecurityContext::ResourceUrl
1298+
);
1299+
// Unknown in every namespace stays a miss.
1300+
assert_eq!(element_security_context_for("bogus", "src", None), SecurityContext::None);
1301+
// `tagName === null` expands over every known element upstream, and
1302+
// the binding keeps `securityContexts[0]` — the numerically lowest,
1303+
// so `NONE` wins over URL/RESOURCE_URL.
1304+
assert_eq!(element_security_context_for("", "src", None), SecurityContext::None);
1305+
// `formAction` hits the `*|formAction` URL wildcard key, so the
1306+
// lowest context is URL.
1307+
assert_eq!(element_security_context_for("", "formAction", None), SecurityContext::Url);
1308+
// `innerHTML` hits the `*|innerHTML` HTML key for every element, so
1309+
// the lowest context is HTML.
1310+
assert_eq!(element_security_context_for("", "innerHTML", None), SecurityContext::Html);
1311+
}
1312+
12351313
#[test]
12361314
fn test_host_comma_selector_merges_url_kinds() {
12371315
assert_eq!(

crates/oxc_angular_compiler/src/schema/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ mod dom_security_schema;
77
mod trusted_types_sinks;
88

99
pub use dom_security_schema::{
10-
calc_security_context_for_unknown_element, get_security_context, get_security_context_for,
11-
host_binding_security_context, host_binding_security_context_for,
10+
calc_security_context_for_unknown_element, element_security_context_for, get_security_context,
11+
get_security_context_for, host_binding_security_context, host_binding_security_context_for,
1212
is_iframe_security_sensitive_attr, is_known_element, rejects_iframe_src_i18n,
1313
strips_namespaced_svg_script, strips_namespaced_svg_style, uses_iframe_attr_validation,
1414
uses_namespaced_schema,

crates/oxc_angular_compiler/src/transform/html_to_r3.rs

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use crate::i18n::placeholder::PlaceholderRegistry;
3131
use crate::parser::expression::{BindingParser, find_comment_start};
3232
use crate::parser::html::{decode_entities_in_string, get_html_tag_definition, split_ns_name};
3333
use crate::schema::{
34-
get_security_context_for, is_known_element, is_trusted_types_sink_at,
34+
element_security_context_for, is_known_element, is_trusted_types_sink_at,
3535
strips_namespaced_svg_script, strips_namespaced_svg_style, uses_namespaced_schema,
3636
};
3737
use crate::transform::control_flow::{parse_conditional_params, parse_defer_triggers};
@@ -240,7 +240,11 @@ impl<'a> HtmlToR3Transform<'a> {
240240
}
241241

242242
fn security_context(&self, element: &str, property: &str) -> SecurityContext {
243-
get_security_context_for(element, property, self.angular_version)
243+
// Element bindings go through `calcPossibleSecurityContexts` upstream,
244+
// which promotes a bare element missing from the DOM schema to its
245+
// `:svg:`/`:math:` form (`<animate>` → `:svg:animate`) on the
246+
// namespaced schema.
247+
element_security_context_for(element, property, self.angular_version)
244248
}
245249

246250
/// Allocates a new unique instance ID for an i18n message.
@@ -5324,15 +5328,20 @@ mod security_tests {
53245328
}
53255329

53265330
#[test]
5327-
fn v21_keeps_svg_script_and_does_not_validate_namespaced_animate() {
5331+
fn v21_keeps_svg_script_and_validates_namespaced_animate() {
5332+
// `:svg:animate` parses to element `animate` on the pre-namespaced
5333+
// selector path (upstream has no `splitNsName` there), so `to` hits
5334+
// the bare `animate|to` attributeNoBinding key at 21.2.7.
53285335
let version = Some(AngularVersion::new(21, 2, 7));
53295336
let (names, contexts, _) = compile_at(
53305337
r#"<svg><script>alert(1)</script><animate [attr.to]="url"></animate></svg>"#,
53315338
version,
53325339
);
53335340
assert!(names.iter().any(|name| name.contains("script")), "{names:?}");
53345341
assert!(
5335-
contexts.iter().any(|(name, ctx)| name == "to" && *ctx == SecurityContext::None),
5342+
contexts
5343+
.iter()
5344+
.any(|(name, ctx)| name == "to" && *ctx == SecurityContext::AttributeNoBinding),
53365345
"{contexts:?}"
53375346
);
53385347
}
@@ -5625,16 +5634,19 @@ mod security_tests {
56255634

56265635
#[test]
56275636
fn prefixed_element_lookup_matches_the_versions_normalize_tag_name() {
5628-
// `normalizeTagName` in `securityContext` only exists at 19.2.23 /
5629-
// 20.3.22 / 21.2.15 and later. Before that the tag is lowercased
5630-
// verbatim, so `:xml:iframe|src` is not the `iframe|src` sink.
5637+
// Pre-namespaced versions feed `:xml:iframe` to `CssSelector.parse`
5638+
// verbatim, which yields element `iframe`, so `src` resolves to
5639+
// `iframe|src` — the sink still applies. The namespaced schema splits
5640+
// the `:xml:` prefix; only versions with `normalizeTagName` (19.2.23 /
5641+
// 20.3.22 / 21.2.15+) strip it back to `iframe`, while 21.2.14 looks
5642+
// up `:xml:iframe|src` verbatim and misses.
56315643
for (major, minor, patch, expected) in [
5632-
(21, 2, 13, SecurityContext::None),
5644+
(21, 2, 13, SecurityContext::ResourceUrl),
56335645
(21, 2, 14, SecurityContext::None),
56345646
(21, 2, 15, SecurityContext::ResourceUrl),
5635-
(19, 2, 22, SecurityContext::None),
5647+
(19, 2, 22, SecurityContext::ResourceUrl),
56365648
(19, 2, 23, SecurityContext::ResourceUrl),
5637-
(20, 3, 21, SecurityContext::None),
5649+
(20, 3, 21, SecurityContext::ResourceUrl),
56385650
(20, 3, 22, SecurityContext::ResourceUrl),
56395651
] {
56405652
let (_, contexts, _) = compile_at(

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10963,6 +10963,36 @@ fn test_iframe_sensitive_attr_validation_legacy_versions() {
1096310963
);
1096410964
}
1096510965

10966+
#[test]
10967+
fn test_svg_animation_attr_binding_validates_on_namespaced_schema() {
10968+
// Upstream routes element bindings through calcPossibleSecurityContexts,
10969+
// which promotes a bare element missing from the DOM schema to its
10970+
// `:svg:`/`:math:` form. `<animate [attr.to]>` therefore resolves to
10971+
// `:svg:animate|to` → attributeNoBinding → ɵɵvalidateAttribute.
10972+
for version in [None, Some(AngularVersion::new(21, 2, 14))] {
10973+
let js = compile_template_to_js_with_version(
10974+
r#"<animate [attr.to]="expr"></animate>"#,
10975+
"TestComponent",
10976+
version,
10977+
);
10978+
assert!(
10979+
js.contains("ɵɵvalidateAttribute"),
10980+
"v{version:?} should emit ɵɵvalidateAttribute for <animate [attr.to]>. Got:\n{js}"
10981+
);
10982+
}
10983+
// Before the namespaced schema (19.2.23 / 20.3.22 / 21.2.14 / v22) there is
10984+
// no promotion and `animate|to` is a verbatim miss.
10985+
let js = compile_template_to_js_with_version(
10986+
r#"<animate [attr.to]="expr"></animate>"#,
10987+
"TestComponent",
10988+
Some(AngularVersion::new(21, 0, 1)),
10989+
);
10990+
assert!(
10991+
!js.contains("ɵɵvalidateAttribute"),
10992+
"v21.0.1 should not emit ɵɵvalidateAttribute for <animate [attr.to]>. Got:\n{js}"
10993+
);
10994+
}
10995+
1096610996
// ============================================================================
1096710997
// Host Directive Alias Tests
1096810998
// ============================================================================

0 commit comments

Comments
 (0)