Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 80 additions & 2 deletions crates/oxc_angular_compiler/src/schema/dom_security_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<animate>` →
/// `: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<crate::AngularVersion>,
) -> 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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!(
Expand Down
4 changes: 2 additions & 2 deletions crates/oxc_angular_compiler/src/schema/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 22 additions & 10 deletions crates/oxc_angular_compiler/src/transform/html_to_r3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 (`<animate>` → `: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.
Expand Down Expand Up @@ -5105,15 +5109,20 @@ 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#"<svg><script>alert(1)</script><animate [attr.to]="url"></animate></svg>"#,
version,
);
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:?}"
);
}
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<animate [attr.to]>` 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#"<animate [attr.to]="expr"></animate>"#,
"TestComponent",
version,
);
assert!(
js.contains("ɵɵvalidateAttribute"),
"v{version:?} should emit ɵɵvalidateAttribute for <animate [attr.to]>. 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#"<animate [attr.to]="expr"></animate>"#,
"TestComponent",
Some(AngularVersion::new(21, 0, 1)),
);
assert!(
!js.contains("ɵɵvalidateAttribute"),
"v21.0.1 should not emit ɵɵvalidateAttribute for <animate [attr.to]>. Got:\n{js}"
);
}

// ============================================================================
// Host Directive Alias Tests
// ============================================================================
Expand Down
Loading