diff --git a/crates/oxc_angular_compiler/src/ast/html.rs b/crates/oxc_angular_compiler/src/ast/html.rs index 4e9291ef8..88ff23db6 100644 --- a/crates/oxc_angular_compiler/src/ast/html.rs +++ b/crates/oxc_angular_compiler/src/ast/html.rs @@ -125,6 +125,9 @@ pub struct HtmlElement<'a> { /// Whether this is a void element (area, base, br, col, embed, hr, img, input, link, meta, param, source, track, wbr). /// Void elements cannot have content and do not have end tags. pub is_void: bool, + /// Parsed from a selectorless component tag (``, ``). + /// The class is `name`; the host element is `component_prefix` / `component_tag_name`. + pub is_component: bool, } /// A selectorless component in the HTML AST. @@ -518,6 +521,7 @@ mod tests { end_span: None, is_self_closing: false, is_void: false, + is_component: false, }; let child2 = HtmlElement { @@ -532,6 +536,7 @@ mod tests { end_span: None, is_self_closing: false, is_void: false, + is_component: false, }; let mut children = Vec::new_in(&&allocator); @@ -550,6 +555,7 @@ mod tests { end_span: None, is_self_closing: false, is_void: false, + is_component: false, }; let mut nodes = Vec::new_in(&&allocator); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index c6319bed2..981a40095 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3701,8 +3701,10 @@ fn compile_component_full<'a>( }; // Stage 2: Transform HTML to R3 AST - let r3_transform_options = - R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let r3_transform_options = R3TransformOptions { + collect_comment_nodes: parse_options.collect_comment_nodes, + angular_version: options.angular_version, + }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -4161,8 +4163,10 @@ pub fn compile_component_template<'a>( }; // Stage 2: Transform HTML to R3 AST - let r3_transform_options = - R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let r3_transform_options = R3TransformOptions { + collect_comment_nodes: parse_options.collect_comment_nodes, + angular_version: None, + }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -4258,8 +4262,10 @@ pub fn compile_template_to_js_with_options<'a>( }; // Stage 2: Transform HTML to R3 AST - let r3_transform_options = - R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let r3_transform_options = R3TransformOptions { + collect_comment_nodes: parse_options.collect_comment_nodes, + angular_version: options.angular_version, + }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -4433,8 +4439,10 @@ pub fn compile_template_for_hmr<'a>( }; // Stage 2: Transform HTML to R3 AST - let r3_transform_options = - R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let r3_transform_options = R3TransformOptions { + collect_comment_nodes: parse_options.collect_comment_nodes, + angular_version: options.angular_version, + }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); @@ -5132,8 +5140,10 @@ pub fn compile_template_for_linker<'a>( }; // Stage 2: Transform HTML to R3 AST - let r3_transform_options = - R3TransformOptions { collect_comment_nodes: parse_options.collect_comment_nodes }; + let r3_transform_options = R3TransformOptions { + collect_comment_nodes: parse_options.collect_comment_nodes, + angular_version: None, + }; let transformer = HtmlToR3Transform::new(allocator, template, r3_transform_options); let r3_result = transformer.transform(nodes); diff --git a/crates/oxc_angular_compiler/src/directive/compiler.rs b/crates/oxc_angular_compiler/src/directive/compiler.rs index 7df853a01..1e43842b0 100644 --- a/crates/oxc_angular_compiler/src/directive/compiler.rs +++ b/crates/oxc_angular_compiler/src/directive/compiler.rs @@ -30,7 +30,7 @@ use crate::output::ast::{ }; use crate::parser::expression::BindingParser; use crate::pipeline::emit::{HostBindingCompilationResult, compile_host_bindings}; -use crate::pipeline::ingest::{HostBindingInput, ingest_host_binding}; +use crate::pipeline::ingest::{HostBindingInput, ingest_host_binding_with_version}; use crate::pipeline::selector::{ parse_selector_to_r3_selector as parse_css_to_r3, r3_selector_to_output_expr, }; @@ -163,9 +163,12 @@ fn build_base_directive_fields<'a>( // - hostVars: number of host variables (only if > 0) // - hostBindings: the host binding function if metadata.host.has_bindings() { - if let Some((result, new_pool_index)) = - compile_directive_host_bindings(allocator, metadata, pool_starting_index) - { + if let Some((result, new_pool_index)) = compile_directive_host_bindings( + allocator, + metadata, + pool_starting_index, + angular_version, + ) { next_pool_index = new_pool_index; // hostAttrs: [...] - static host attributes @@ -562,6 +565,7 @@ fn compile_directive_host_bindings<'a>( allocator: &'a Allocator, metadata: &R3DirectiveMetadata<'a>, pool_starting_index: u32, + angular_version: Option, ) -> Option<(HostBindingCompilationResult<'a>, u32)> { let host = &metadata.host; @@ -580,7 +584,13 @@ fn compile_directive_host_bindings<'a>( // Ingest and compile the host bindings using the IR pipeline // Use the provided pool_starting_index to continue from where previous compilations left off - let mut job = ingest_host_binding(allocator, input, pool_starting_index); + let mut job = ingest_host_binding_with_version( + allocator, + input, + pool_starting_index, + angular_version, + None, + ); let result = compile_host_bindings(&mut job); // Get the next pool index after host binding compilation diff --git a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs index 3a143e4aa..bbc47e74a 100644 --- a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs +++ b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs @@ -17,6 +17,7 @@ use crate::i18n::ast::{ }; use crate::i18n::parser::{I18nMessageFactory, create_i18n_message_factory}; use crate::i18n::translation_bundle::TranslationBundle; +use crate::schema::is_trusted_types_sink_at; use crate::util::{ParseSourceFile, ParseSourceSpan}; // ============================================================================ @@ -367,18 +368,22 @@ enum VisitorMode { /// * `implicit_tags` - Tag names that are implicitly translatable. /// * `implicit_attrs` - Attribute names that are implicitly translatable per element. /// * `preserve_significant_whitespace` - Whether to preserve significant whitespace. +/// * `angular_version` - The Angular version being targeted; gates which +/// Trusted Types sinks reject translation. pub fn extract_messages( nodes: &[HtmlNodeRef<'_>], implicit_tags: &[String], implicit_attrs: &FxHashMap>, preserve_significant_whitespace: bool, source_file: Arc, + angular_version: Option, ) -> ExtractionResult { let mut visitor = I18nVisitor::new( implicit_tags, implicit_attrs, preserve_significant_whitespace, source_file, + angular_version, ); visitor.extract(nodes) } @@ -390,14 +395,18 @@ pub fn extract_messages( /// * `translations` - The translation bundle. /// * `implicit_tags` - Tag names that are implicitly translatable. /// * `implicit_attrs` - Attribute names that are implicitly translatable per element. +/// * `angular_version` - The Angular version being targeted; gates which +/// Trusted Types sinks reject translation. pub fn merge_translations( nodes: &[HtmlNodeRef<'_>], translations: &TranslationBundle, implicit_tags: &[String], implicit_attrs: &FxHashMap>, source_file: Arc, + angular_version: Option, ) -> MergeResult { - let mut visitor = I18nVisitor::new(implicit_tags, implicit_attrs, true, source_file); + let mut visitor = + I18nVisitor::new(implicit_tags, implicit_attrs, true, source_file, angular_version); visitor.merge(nodes, translations) } @@ -567,6 +576,9 @@ struct I18nVisitor<'a> { translations: Option<&'a TranslationBundle>, /// Source file for span conversion. source_file: Arc, + /// The Angular version being targeted; gates which Trusted Types sinks + /// reject translation. + angular_version: Option, } impl<'a> I18nVisitor<'a> { @@ -580,6 +592,7 @@ impl<'a> I18nVisitor<'a> { implicit_attrs: &'a FxHashMap>, preserve_significant_whitespace: bool, source_file: Arc, + angular_version: Option, ) -> Self { Self { implicit_tags, @@ -603,6 +616,7 @@ impl<'a> I18nVisitor<'a> { ), translations: None, source_file, + angular_version, } } @@ -1083,7 +1097,7 @@ impl<'a> I18nVisitor<'a> { /// Translates attributes for merge mode, handling i18n-* attributes. fn translate_attributes_for_merge( - &self, + &mut self, element_name: &str, attrs: &[HtmlAttrRef<'_>], ) -> Vec { @@ -1095,6 +1109,15 @@ impl<'a> I18nVisitor<'a> { for attr in attrs { if attr.name.starts_with(I18N_ATTR_PREFIX) { let target_name = &attr.name[I18N_ATTR_PREFIX.len()..]; + if is_trusted_types_sink_at(element_name, target_name, self.angular_version) { + self.report_error( + attr.span, + &format!( + "Translating attribute '{target_name}' is disallowed for security reasons." + ), + ); + continue; + } explicit_attr_meta.insert(target_name.to_string(), attr.value.to_string()); } } @@ -1109,8 +1132,29 @@ impl<'a> I18nVisitor<'a> { // Check if this attribute needs translation let i18n_meta = explicit_attr_meta.get(attr.name); - let needs_translation = - i18n_meta.is_some() || implicit_attr_names.iter().any(|n| n == attr.name); + let implicit = implicit_attr_names.iter().any(|n| n == attr.name); + let needs_translation = i18n_meta.is_some() || implicit; + + // Implicit config can name a sink (`iframe` → `src`) without an + // `i18n-*` marker. The marker loop above never sees that case. + if needs_translation + && is_trusted_types_sink_at(element_name, attr.name, self.angular_version) + { + if implicit && i18n_meta.is_none() { + self.report_error( + attr.span, + &format!( + "Translating attribute '{}' is disallowed for security reasons.", + attr.name + ), + ); + } + return Some(TranslatedAttribute { + name: attr.name.to_string(), + value: attr.value.to_string(), + span: attr.span, + }); + } if needs_translation && !attr.is_interpolation_only && !attr.value.trim().is_empty() { @@ -1235,10 +1279,20 @@ impl<'a> I18nVisitor<'a> { let implicit_attr_names = self.implicit_attrs.get(element_name).cloned().unwrap_or_default(); - // Collect explicit i18n-* attributes + // Collect explicit i18n-* attributes. Trusted Types sinks are rejected + // and not extracted (`i18n/meta.ts`). for attr in attrs { if attr.name.starts_with(I18N_ATTR_PREFIX) { let target_name = &attr.name[I18N_ATTR_PREFIX.len()..]; + if is_trusted_types_sink_at(element_name, target_name, self.angular_version) { + self.report_error( + attr.span, + &format!( + "Translating attribute '{target_name}' is disallowed for security reasons." + ), + ); + continue; + } explicit_attr_names.insert(target_name.to_string(), attr.value.to_string()); } } @@ -1254,13 +1308,23 @@ impl<'a> I18nVisitor<'a> { attr.is_interpolation_only, ); } else if implicit_attr_names.iter().any(|n| n == attr.name) { - self.add_message_from_attr( - attr.name, - attr.value, - "", - attr.span, - attr.is_interpolation_only, - ); + if is_trusted_types_sink_at(element_name, attr.name, self.angular_version) { + self.report_error( + attr.span, + &format!( + "Translating attribute '{}' is disallowed for security reasons.", + attr.name + ), + ); + } else { + self.add_message_from_attr( + attr.name, + attr.value, + "", + attr.span, + attr.is_interpolation_only, + ); + } } } } @@ -1789,9 +1853,196 @@ mod tests { #[test] fn test_extract_messages_empty() { let source_file = Arc::new(ParseSourceFile::new("", "")); - let result = extract_messages(&[], &[], &FxHashMap::default(), true, source_file); + let result = extract_messages(&[], &[], &FxHashMap::default(), true, source_file, None); + assert!(result.messages.is_empty()); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_iframe_src_i18n_is_rejected() { + let source_file = Arc::new(ParseSourceFile::new("", "")); + let span = Span::default(); + let nodes = vec![HtmlNodeRef::Element { + name: "iframe", + attrs: vec![ + HtmlAttrRef { + name: "i18n-src", + value: "translated url", + span, + is_interpolation_only: false, + }, + HtmlAttrRef { + name: "src", + value: "https://example.com", + span, + is_interpolation_only: false, + }, + ], + children: vec![], + span, + start_span: span, + end_span: None, + }]; + let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file, None); assert!(result.messages.is_empty()); + assert!(result.errors.iter().any(|err| err.message.contains("disallowed"))); + } + + #[test] + fn test_plain_title_i18n_is_still_extracted() { + let source_file = Arc::new(ParseSourceFile::new("", "")); + let span = Span::default(); + let nodes = vec![HtmlNodeRef::Element { + name: "div", + attrs: vec![ + HtmlAttrRef { + name: "i18n-title", + value: "meaning|desc", + span, + is_interpolation_only: false, + }, + HtmlAttrRef { name: "title", value: "Hello", span, is_interpolation_only: false }, + ], + children: vec![], + span, + start_span: span, + end_span: None, + }]; + let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file, None); assert!(result.errors.is_empty()); + assert!(!result.messages.is_empty()); + } + + #[test] + fn test_implicit_iframe_src_is_rejected() { + let source_file = Arc::new(ParseSourceFile::new("", "")); + let span = Span::default(); + let mut implicit_attrs = FxHashMap::default(); + implicit_attrs.insert("iframe".to_string(), vec!["src".to_string()]); + let nodes = vec![HtmlNodeRef::Element { + name: "iframe", + attrs: vec![HtmlAttrRef { + name: "src", + value: "https://example.com", + span, + is_interpolation_only: false, + }], + children: vec![], + span, + start_span: span, + end_span: None, + }]; + let result = extract_messages(&nodes, &[], &implicit_attrs, true, source_file, None); + assert!(result.messages.is_empty()); + assert!(result.errors.iter().any(|err| err.message.contains("disallowed"))); + } + + #[test] + fn test_implicit_iframe_src_is_not_rewritten_on_merge() { + let source_file = Arc::new(ParseSourceFile::new("", "")); + let span = Span::default(); + let mut implicit_attrs = FxHashMap::default(); + implicit_attrs.insert("iframe".to_string(), vec!["src".to_string()]); + let nodes = vec![HtmlNodeRef::Element { + name: "iframe", + attrs: vec![HtmlAttrRef { + name: "src", + value: "https://example.com", + span, + is_interpolation_only: false, + }], + children: vec![], + span, + start_span: span, + end_span: None, + }]; + let bundle = crate::i18n::translation_bundle::TranslationBundle::new_empty( + crate::i18n::digest::compute_digest, + crate::i18n::i18n_html_parser::MissingTranslationStrategy::Ignore, + None, + ); + let result = merge_translations(&nodes, &bundle, &[], &implicit_attrs, source_file, None); + assert!(result.errors.iter().any(|err| err.message.contains("disallowed"))); + match &result.nodes[0] { + TranslatedNode::Element { attrs, .. } => { + assert_eq!(attrs[0].name, "src"); + assert_eq!(attrs[0].value, "https://example.com"); + } + _ => panic!("expected an element"), + } + } + + #[test] + fn test_iframe_src_i18n_allowed_before_21_2_4() { + // `iframe|src` joined the Trusted Types sinks at 21.2.4; standalone + // extraction and merge must follow the same cutoff as compilation. + let span = Span::default(); + let nodes = || { + vec![HtmlNodeRef::Element { + name: "iframe", + attrs: vec![ + HtmlAttrRef { + name: "i18n-src", + value: "translated url", + span, + is_interpolation_only: false, + }, + HtmlAttrRef { + name: "src", + value: "https://example.com", + span, + is_interpolation_only: false, + }, + ], + children: vec![], + span, + start_span: span, + end_span: None, + }] + }; + + let allowed = extract_messages( + &nodes(), + &[], + &FxHashMap::default(), + true, + Arc::new(ParseSourceFile::new("", "")), + Some(crate::AngularVersion::new(21, 2, 3)), + ); + assert!(allowed.errors.is_empty(), "{:?}", allowed.errors); + assert!(!allowed.messages.is_empty()); + + let rejected = extract_messages( + &nodes(), + &[], + &FxHashMap::default(), + true, + Arc::new(ParseSourceFile::new("", "")), + Some(crate::AngularVersion::new(21, 2, 4)), + ); + assert!(rejected.messages.is_empty()); + assert!(rejected.errors.iter().any(|err| err.message.contains("disallowed"))); + + // Merge follows the same cutoff: on 21.2.3 the i18n-src marker is + // collected and the attribute is translatable. + let bundle = crate::i18n::translation_bundle::TranslationBundle::new_empty( + crate::i18n::digest::compute_digest, + crate::i18n::i18n_html_parser::MissingTranslationStrategy::Ignore, + None, + ); + let merged = merge_translations( + &nodes(), + &bundle, + &[], + &FxHashMap::default(), + Arc::new(ParseSourceFile::new("", "")), + Some(crate::AngularVersion::new(21, 2, 3)), + ); + assert!( + !merged.errors.iter().any(|err| err.message.contains("disallowed")), + "{:?}", + merged.errors + ); } #[test] diff --git a/crates/oxc_angular_compiler/src/parser/html/parser.rs b/crates/oxc_angular_compiler/src/parser/html/parser.rs index 161c12742..c23f79f21 100644 --- a/crates/oxc_angular_compiler/src/parser/html/parser.rs +++ b/crates/oxc_angular_compiler/src/parser/html/parser.rs @@ -227,6 +227,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from(""), component_prefix: None, component_tag_name: None, + is_component: false, attrs: Vec::new_in(&self.allocator), directives: Vec::new_in(&self.allocator), children: Vec::new_in(&self.allocator), @@ -376,6 +377,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from(""), component_prefix: None, component_tag_name: None, + is_component: false, attrs: Vec::new_in(&self.allocator), directives: Vec::new_in(&self.allocator), children: Vec::new_in(&self.allocator), @@ -421,6 +423,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from(""), component_prefix: None, component_tag_name: None, + is_component: false, attrs: Vec::new_in(&self.allocator), directives: Vec::new_in(&self.allocator), children: Vec::new_in(&self.allocator), @@ -568,10 +571,11 @@ impl<'a> HtmlParser<'a> { return; // No token to consume }; let start = start_token.start; + let is_component = start_token.token_type == HtmlTokenType::ComponentOpenStart; // TagOpenStart has parts [prefix, name] // ComponentOpenStart has parts [component_name, prefix, tag_name] let (tag_name, local_name, has_ns_prefix, component_prefix, component_tag_name) = - if start_token.token_type == HtmlTokenType::ComponentOpenStart { + if is_component { // For components, extract all three parts: // parts[0] = component_name, parts[1] = prefix, parts[2] = tag_name let component_name = start_token.parts.first().cloned().unwrap_or_default(); @@ -656,6 +660,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from_in(tag_name.clone(), &self.allocator), component_prefix: component_prefix.map(|p| Ident::from_in(p, &self.allocator)), component_tag_name: component_tag_name.map(|t| Ident::from_in(t, &self.allocator)), + is_component, attrs, directives, children: Vec::new_in(&self.allocator), @@ -1148,6 +1153,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from_in(tag_name.clone(), &self.allocator), component_prefix: None, component_tag_name: None, + is_component: false, attrs: Vec::new_in(&self.allocator), directives: Vec::new_in(&self.allocator), children: Vec::new_in(&self.allocator), @@ -1461,6 +1467,7 @@ impl<'a> HtmlParser<'a> { name: Ident::from(""), component_prefix: None, component_tag_name: None, + is_component: false, attrs: Vec::new_in(&self.allocator), directives: Vec::new_in(&self.allocator), children: Vec::new_in(&self.allocator), diff --git a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs index bcb4025d7..2640b286d 100644 --- a/crates/oxc_angular_compiler/src/parser/html/whitespace.rs +++ b/crates/oxc_angular_compiler/src/parser/html/whitespace.rs @@ -370,6 +370,7 @@ impl<'a> WhitespaceVisitor<'a> { name: el.name.clone(), component_prefix: el.component_prefix.clone(), component_tag_name: el.component_tag_name.clone(), + is_component: el.is_component, attrs: self.clone_attributes(&el.attrs), directives: self.clone_directives(&el.directives), children: self.clone_children(&el.children), @@ -563,6 +564,7 @@ impl<'a> WhitespaceVisitor<'a> { name: element.name.clone(), component_prefix: element.component_prefix.clone(), component_tag_name: element.component_tag_name.clone(), + is_component: element.is_component, attrs, directives: self.clone_directives(&element.directives), children: self.clone_children(&element.children), @@ -584,6 +586,7 @@ impl<'a> WhitespaceVisitor<'a> { name: element.name.clone(), component_prefix: element.component_prefix.clone(), component_tag_name: element.component_tag_name.clone(), + is_component: element.is_component, attrs: self.clone_attributes(&element.attrs), directives: self.clone_directives(&element.directives), children, diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 22c9955af..2e920a755 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -4122,7 +4122,18 @@ fn ingest_host_dom_property<'a>( name, expression, unit: property.unit, - security_context: property.security_context, + // Host property bindings recompute the context from the selector. + // `style` / `class` / animation ops are specialized before sanitizers run. + security_context: match binding_kind { + BindingKind::Attribute | BindingKind::Property | BindingKind::TwoWayProperty => { + crate::schema::host_binding_security_context_for( + job.component_selector.as_str(), + name.as_str(), + job.angular_version, + ) + } + _ => SecurityContext::None, + }, i18n_message: None, is_text_attribute: false, }); @@ -4130,58 +4141,15 @@ fn ingest_host_dom_property<'a>( job.root.update.push(op); } -/// Computes the security context for an attribute binding. -/// -/// This is a simplified implementation of Angular's `calcPossibleSecurityContexts` -/// that handles the most common cases based on element and property names. -/// -/// Ported from Angular's `binding_parser.ts` and `dom_security_schema.ts`. -fn compute_security_context(selector: &str, attr_name: &str) -> SecurityContext { - use crate::schema::{calc_security_context_for_unknown_element, get_security_context}; - - // Extract element name from selector if present (e.g., "a[myDirective]" → "a") - let element = extract_element_from_selector(selector); - - match element { - Some(element_name) => { - // Element is known - use the specific lookup - get_security_context(&element_name, attr_name) - } - None => { - // Element is unknown (e.g., attribute-only directive like [myDirective]) - // Use the ambiguous lookup that checks all possible elements - calc_security_context_for_unknown_element(attr_name) - } - } -} - -/// Extracts the element name from a CSS selector. +/// Security context for a static host attribute. /// -/// Examples: -/// - "a[myDirective]" → Some("a") -/// - "div.my-class" → Some("div") -/// - "[myDirective]" → None -/// - ".my-class" → None -fn extract_element_from_selector(selector: &str) -> Option { - // Skip leading whitespace - let s = selector.trim(); - - // If starts with [, ., or :, there's no element - if s.starts_with('[') || s.starts_with('.') || s.starts_with(':') || s.starts_with('#') { - return None; - } - - // Find the element name (alphanumeric and hyphens until a special char) - let mut element_end = 0; - for (i, c) in s.char_indices() { - if c.is_alphanumeric() || c == '-' || c == '_' { - element_end = i + c.len_utf8(); - } else { - break; - } - } - - if element_end > 0 { Some(s[..element_end].to_lowercase()) } else { None } +/// Same selector rules as host property bindings (`calcPossibleSecurityContexts`). +fn compute_security_context( + selector: &str, + attr_name: &str, + version: Option, +) -> SecurityContext { + crate::schema::host_binding_security_context_for(selector, attr_name, version) } /// Ingests a static host attribute. @@ -4202,7 +4170,11 @@ fn ingest_host_attribute<'a>( let allocator = job.allocator; // Compute security context based on selector and attribute name - let security_context = compute_security_context(job.component_selector.as_str(), name.as_str()); + let security_context = compute_security_context( + job.component_selector.as_str(), + name.as_str(), + job.angular_version, + ); // Wrap the OutputExpression in IrExpression::OutputExpr // This matches TypeScript which passes o.Expression directly to the IR @@ -4442,7 +4414,11 @@ fn ingest_control_flow_insertion_point<'a, 'b>( continue; } - let security_context = crate::schema::get_security_context(NG_TEMPLATE_TAG_NAME, attr_name); + let security_context = crate::schema::get_security_context_for( + NG_TEMPLATE_TAG_NAME, + attr_name, + job.angular_version, + ); let value_expr = create_string_literal_atom(allocator, attr.value.clone()); // Handle i18n message if present (for i18n-* attribute markers) @@ -4531,8 +4507,11 @@ fn ingest_control_flow_insertion_point<'a, 'b>( continue; } - let security_context = - crate::schema::get_security_context(NG_TEMPLATE_TAG_NAME, &input.name); + let security_context = crate::schema::get_security_context_for( + NG_TEMPLATE_TAG_NAME, + &input.name, + job.angular_version, + ); let extracted_attr_op = CreateOp::ExtractedAttribute(ExtractedAttributeOp { base: CreateOpBase { source_span: Some(input.source_span), ..Default::default() }, diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs index 2b56aac97..3848d7174 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs @@ -7,11 +7,13 @@ //! Ported from Angular's `template/pipeline/src/phases/resolve_sanitizers.ts`. use oxc_str::Ident; +use rustc_hash::FxHashMap; use crate::ast::r3::SecurityContext; -use crate::ir::ops::{CreateOp, UpdateOp}; +use crate::ir::ops::{CreateOp, UpdateOp, XrefId}; use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob}; use crate::r3::Identifiers; +use crate::schema::{is_iframe_security_sensitive_attr, uses_iframe_attr_validation}; /// Map a security context to its sanitizer function name. fn get_sanitizer_fn(security_context: SecurityContext) -> Option<&'static str> { @@ -27,10 +29,8 @@ fn get_sanitizer_fn(security_context: SecurityContext) -> Option<&'static str> { // selects the actual sanitizer at runtime based on the tag name. SecurityContext::UrlOrResourceUrl => Some(Identifiers::SANITIZE_URL_OR_RESOURCE_URL), SecurityContext::None => None, - // AttributeNoBinding means the attribute should not be bound at all. - // This should produce a compile-time error in the HTML-to-R3 transform. - // For now, return None but the binding should have been rejected earlier. - SecurityContext::AttributeNoBinding => None, + // `resolve_sanitizers.ts` maps ATTRIBUTE_NO_BINDING to `ɵɵvalidateAttribute`. + SecurityContext::AttributeNoBinding => Some(Identifiers::VALIDATE_ATTRIBUTE), } } @@ -47,12 +47,74 @@ fn get_trusted_value_fn(security_context: SecurityContext) -> Option<&'static st } } +/// The element-or-container create ops upstream indexes with +/// `createOpXrefMap`, reduced to what the iframe fallback reads: whether the +/// owner op is an `elementStart` whose tag is `iframe`. +/// +/// Upstream's `isIframeElement` checks `op.kind === OpKind.ElementStart`, so +/// self-closing `element()` ops are intentionally not matched. +fn create_op_xref_map<'a>(ops: impl Iterator>) -> FxHashMap { + let mut elements = FxHashMap::default(); + for op in ops { + let (xref, is_iframe) = match op { + CreateOp::ElementStart(op) => (op.xref, op.tag.as_str().eq_ignore_ascii_case("iframe")), + CreateOp::Element(op) => (op.xref, false), + CreateOp::ContainerStart(op) => (op.xref, false), + CreateOp::Container(op) => (op.xref, false), + CreateOp::Template(op) => (op.xref, false), + CreateOp::Conditional(op) => (op.xref, false), + CreateOp::ConditionalBranch(op) => (op.xref, false), + CreateOp::RepeaterCreate(op) => { + // Upstream indexes the `@empty` view under the same repeater op. + if let Some(empty_view) = op.empty_view { + elements.insert(empty_view, false); + } + (op.xref, false) + } + _ => continue, + }; + elements.insert(xref, is_iframe); + } + elements +} + +/// Apply upstream's legacy `ɵɵvalidateIframeAttribute` fallback: when a +/// `Property` / `Attribute` / `DomProperty` op got no sanitizer from its +/// security context, a security-sensitive iframe attribute gets the runtime +/// validator. Removed upstream once the schema's `attributeNoBinding` iframe +/// keys covered the same attributes (19.2.17 / 20.3.15 / 21.0.2). +fn resolve_iframe_sanitizer( + assume_iframe: bool, + elements: &FxHashMap, + target: XrefId, + name: &str, + sanitizer: &mut Option>, +) { + if sanitizer.is_some() { + return; + } + // For host bindings and `DomProperty` ops the element is not known at + // compile time, so upstream assumes it may be an iframe; the emitted + // validator checks the real tag at runtime. + let is_iframe = if assume_iframe { + true + } else { + *elements + .get(&target) + .unwrap_or_else(|| panic!("Property should have an element-like owner")) + }; + if is_iframe && is_iframe_security_sensitive_attr(name) { + *sanitizer = Some(Ident::from(Identifiers::VALIDATE_IFRAME_ATTRIBUTE)); + } +} + /// Resolves security sanitizers for property bindings. /// /// This phase: /// 1. For ExtractedAttribute ops (constant attributes), sets the trusted value function /// 2. For Property, Attribute, and DomProperty ops, sets the sanitizer function pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) { + let iframe_validation = uses_iframe_attr_validation(job.angular_version); // Collect view xrefs to avoid borrow issues let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect(); @@ -67,6 +129,8 @@ pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) { } } + let elements = iframe_validation.then(|| create_op_xref_map(view.create.iter())); + // Process update ops - set sanitizers for property/attribute bindings for op in view.update.iter_mut() { match op { @@ -74,16 +138,43 @@ pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) { if let Some(fn_name) = get_sanitizer_fn(prop.security_context) { prop.sanitizer = Some(Ident::from(fn_name)); } + if let Some(elements) = &elements { + resolve_iframe_sanitizer( + false, + elements, + prop.target, + prop.name.as_str(), + &mut prop.sanitizer, + ); + } } UpdateOp::Attribute(attr) => { if let Some(fn_name) = get_sanitizer_fn(attr.security_context) { attr.sanitizer = Some(Ident::from(fn_name)); } + if let Some(elements) = &elements { + resolve_iframe_sanitizer( + false, + elements, + attr.target, + attr.name.as_str(), + &mut attr.sanitizer, + ); + } } UpdateOp::DomProperty(dom_prop) => { if let Some(fn_name) = get_sanitizer_fn(dom_prop.security_context) { dom_prop.sanitizer = Some(Ident::from(fn_name)); } + if let Some(elements) = &elements { + resolve_iframe_sanitizer( + true, + elements, + dom_prop.target, + dom_prop.name.as_str(), + &mut dom_prop.sanitizer, + ); + } } _ => {} } @@ -96,6 +187,8 @@ pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) { /// /// Host version - only processes the root unit (no embedded views). pub fn resolve_sanitizers_for_host(job: &mut HostBindingCompilationJob<'_>) { + let iframe_validation = uses_iframe_attr_validation(job.angular_version); + // Process create ops - set trusted value functions for extracted attributes for op in job.root.create.iter_mut() { if let CreateOp::ExtractedAttribute(attr) = op { @@ -107,23 +200,31 @@ pub fn resolve_sanitizers_for_host(job: &mut HostBindingCompilationJob<'_>) { // Process update ops - set sanitizers for property/attribute bindings for op in job.root.update.iter_mut() { - match op { + let (name, sanitizer) = match op { UpdateOp::Property(prop) => { if let Some(fn_name) = get_sanitizer_fn(prop.security_context) { prop.sanitizer = Some(Ident::from(fn_name)); } + (prop.name.as_str(), &mut prop.sanitizer) } UpdateOp::Attribute(attr) => { if let Some(fn_name) = get_sanitizer_fn(attr.security_context) { attr.sanitizer = Some(Ident::from(fn_name)); } + (attr.name.as_str(), &mut attr.sanitizer) } UpdateOp::DomProperty(dom_prop) => { if let Some(fn_name) = get_sanitizer_fn(dom_prop.security_context) { dom_prop.sanitizer = Some(Ident::from(fn_name)); } + (dom_prop.name.as_str(), &mut dom_prop.sanitizer) } - _ => {} + _ => continue, + }; + // A host job cannot know its host element at compile time, so upstream + // assumes iframe and defers the tag check to the runtime validator. + if iframe_validation && sanitizer.is_none() && is_iframe_security_sensitive_attr(name) { + *sanitizer = Some(Ident::from(Identifiers::VALIDATE_IFRAME_ATTRIBUTE)); } } } diff --git a/crates/oxc_angular_compiler/src/pipeline/selector.rs b/crates/oxc_angular_compiler/src/pipeline/selector.rs index c00ea2349..a58031825 100644 --- a/crates/oxc_angular_compiler/src/pipeline/selector.rs +++ b/crates/oxc_angular_compiler/src/pipeline/selector.rs @@ -245,9 +245,10 @@ impl CssSelector { continue; } - // Handle * wildcard element + // `*` is a wildcard, not an element name: upstream's regexp has no + // `*` production, so `element` stays unset and selector consumers + // treat it as "any element". if c == '*' { - target.set_element("*"); i += 1; continue; } diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index 3787ffe5a..38ae09ea7 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -912,6 +912,9 @@ impl Identifiers { /// Validate attribute. pub const VALIDATE_ATTRIBUTE: &'static str = "ɵɵvalidateAttribute"; + /// Validate iframe attribute. + pub const VALIDATE_IFRAME_ATTRIBUTE: &'static str = "ɵɵvalidateIframeAttribute"; + /// Sanitize resource URL. pub const SANITIZE_RESOURCE_URL: &'static str = "ɵɵsanitizeResourceUrl"; 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 30c5337e9..f7c1f7155 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -1,272 +1,1064 @@ //! DOM Security Schema //! -//! This module contains the security schema that maps element|property combinations -//! to their appropriate security context for sanitization. -//! -//! Ported from Angular's `schema/dom_security_schema.ts`. +//! Maps `element|property` pairs to the sanitizer Angular applies. +//! Ported from `@angular/compiler` v22 `schema/dom_security_schema.ts`. //! //! DO NOT EDIT THIS LIST OF SECURITY SENSITIVE PROPERTIES WITHOUT A SECURITY REVIEW! -use crate::ast::r3::SecurityContext; -use rustc_hash::FxHashMap; use std::sync::LazyLock; -/// Security schema mapping `"element|property"` to `SecurityContext`. -/// Properties applying to all elements use `"*"` as the element name. -static SECURITY_SCHEMA: LazyLock> = LazyLock::new(|| { +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::ast::r3::SecurityContext; +use crate::parser::html::split_ns_name; +use crate::pipeline::selector::CssSelector; + +/// Which Angular security schema a compilation is targeting. +/// +/// `None` means the latest schema (v22). Security fixes were backported per +/// release line, so the cutovers are not monotonic: e.g. `attributeName` +/// no-binding reached 20.3.15 and 21.0.2 but `script|href` only reached +/// 20.3.16 and 21.0.7, and the namespaced schema landed on 20.3.22 and +/// 21.2.14 while 21.0.x / 21.1.x never received it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SchemaKind { + /// 21.0.0 / 21.0.1 and everything below 20.3.15. Old URL set with + /// `*|ping`, `*|cite`, `applet|code`, `media|src`, etc. + Legacy, + /// 19.2.17, 20.3.15, 21.0.2–21.0.5. Adds MathML hrefs, `attributeName` + /// no-binding, and iframe sandbox keys on top of the old URL set. + V20_3_15, + /// 21.0.6 only. The hardening without the legacy URL keys, and before + /// `script|href` landed in 21.0.7. + V21_0_6, + /// 20.3.16–20.3.21 and 21.0.7 through 21.2.6. Adds `script|href`; the + /// `ping`/`cite`/`applet`/`media` keys are gone from 21.0.6 on. + V21_1, + /// Same as `V21_1` but still carrying the legacy URL keys: 19.2.18–19.2.22 + /// and 20.3.16–20.3.21 (the key removal was never backported to either + /// maintained line). + V20_3_16, + /// 21.2.7 through 21.2.13. Animation `to` / `from` / `values` are bare keys. + V21_2_7, + /// 21.2.14 only. Namespaced keys but no `:svg:a|href` yet. + V21_2_14, + /// 19.2.23+, 20.3.22+, 21.2.15+, 22+. Namespaced keys with `:svg:a|href`. + /// `script|src` and `script|href` are gone. + V22, +} + +struct SecurityProfile { + kind: SchemaKind, + /// The schema keys keep `:svg:` / `:math:` prefixes (21.2.14+, 19.2.23+, + /// 20.3.22+). `calcPossibleSecurityContexts` rewrites selectors for these + /// versions. + namespaced: bool, + /// `securityContext` runs `normalizeTagName`, stripping non-svg/math + /// prefixes from the element before the lookup. Upstream added the + /// normalizer one release after the namespaced schema: 21.2.14 looks the + /// verbatim tag up, so `:xml:iframe|src` misses there but hits `iframe|src` + /// at 21.2.15+. + normalizes_tag_names: bool, + /// The preparser strips `:svg:script` as well as `script`. + strip_svg_script: bool, + /// The preparser also strips `:svg:style`. Only 19.2.23, 20.3.22 and + /// 21.2.14 did this; it was reverted everywhere else. + strip_svg_style: bool, + /// `iframe|src` joined Trusted Types sinks (19.2.20+, 20.3.18–21, 21.2.4+; + /// never on the 21.0.x / 21.1.x lines). + iframe_src_i18n: bool, + /// `resolve_sanitizers` falls back to `ɵɵvalidateIframeAttribute` for + /// security-sensitive iframe attributes with no other sanitizer. Upstream + /// removed this when the iframe `attributeNoBinding` keys landed + /// (19.2.17 / 20.3.15 / 21.0.2), so it only exists on the legacy schema. + iframe_attr_validation: bool, +} + +fn security_profile(version: Option) -> SecurityProfile { + let Some(version) = version else { + return v22_profile(); + }; + if version.major >= 22 { + return v22_profile(); + } + + let (kind, iframe_src_i18n) = match version.major { + 19 if version.minor >= 2 => match version.patch { + 0..=16 => (SchemaKind::Legacy, false), + 17 => (SchemaKind::V20_3_15, false), + 18..=19 => (SchemaKind::V20_3_16, false), + 20..=22 => (SchemaKind::V20_3_16, true), + // 19.2.23+ has the namespaced schema with `:svg:a|href`. + _ => (SchemaKind::V22, true), + }, + 20 if version.minor >= 3 => match version.patch { + 0..=14 => (SchemaKind::Legacy, false), + 15 => (SchemaKind::V20_3_15, false), + 16..=17 => (SchemaKind::V20_3_16, false), + 18..=21 => (SchemaKind::V20_3_16, true), + // 20.3.22+ has the namespaced schema with `:svg:a|href`. + _ => (SchemaKind::V22, true), + }, + 21 => match (version.minor, version.patch) { + (0, 0..=1) => (SchemaKind::Legacy, false), + (0, 2..=5) => (SchemaKind::V20_3_15, false), + (0, 6) => (SchemaKind::V21_0_6, false), + (0, _) => (SchemaKind::V21_1, false), + (1, _) => (SchemaKind::V21_1, false), + (2, 0..=3) => (SchemaKind::V21_1, false), + (2, 4..=6) => (SchemaKind::V21_1, true), + (2, 7..=13) => (SchemaKind::V21_2_7, true), + (2, 14) => (SchemaKind::V21_2_14, true), + // 21.2.15+ and any later 21.x minor use the namespaced schema. + _ => (SchemaKind::V22, true), + }, + _ => (SchemaKind::Legacy, false), + }; + + let namespaced = matches!(kind, SchemaKind::V21_2_14 | SchemaKind::V22); + // `:svg:style` stripping existed only in 19.2.23, 20.3.22 and 21.2.14. + let strip_svg_style = matches!(kind, SchemaKind::V21_2_14) + || (version.major == 20 && version.minor == 3 && version.patch == 22) + || (version.major == 19 && version.minor == 2 && version.patch == 23); + SecurityProfile { + kind, + namespaced, + // `normalizeTagName` in `securityContext` landed with the schema + // backports that carried `:svg:a|href` (19.2.23 / 20.3.22 / 21.2.15); + // 21.2.14 has namespaced keys but no normalizer. + normalizes_tag_names: matches!(kind, SchemaKind::V22), + strip_svg_script: namespaced, + strip_svg_style, + iframe_src_i18n, + iframe_attr_validation: matches!(kind, SchemaKind::Legacy), + } +} + +fn v22_profile() -> SecurityProfile { + SecurityProfile { + kind: SchemaKind::V22, + namespaced: true, + normalizes_tag_names: true, + strip_svg_script: true, + strip_svg_style: false, + iframe_src_i18n: true, + iframe_attr_validation: false, + } +} + +/// Whether this Angular version strips `:svg:script` during template lowering. +pub fn strips_namespaced_svg_script(version: Option) -> bool { + security_profile(version).strip_svg_script +} + +/// Whether this Angular version's preparser classifies `:svg:style` as a style +/// element (its text is collected into component styles and the element is +/// dropped). Only 20.3.22 and 21.2.14 did this. +pub fn strips_namespaced_svg_style(version: Option) -> bool { + security_profile(version).strip_svg_style +} + +/// Whether this Angular version's schema keys keep `:svg:` / `:math:` prefixes. +/// `calcPossibleSecurityContexts` only promotes bare selector elements to +/// their `:svg:` / `:math:` forms on the namespaced schema. +pub fn uses_namespaced_schema(version: Option) -> bool { + security_profile(version).namespaced +} + +/// Whether i18n must reject `iframe` `src` as a Trusted Types sink. +pub fn rejects_iframe_src_i18n(version: Option) -> bool { + security_profile(version).iframe_src_i18n +} + +/// Whether `resolve_sanitizers` applies the `ɵɵvalidateIframeAttribute` +/// fallback. Upstream kept it on versions without the iframe +/// `attributeNoBinding` schema keys (everything before 19.2.17 / 20.3.15 / +/// 21.0.2) and dropped it once those keys covered the same attributes. +pub fn uses_iframe_attr_validation(version: Option) -> bool { + security_profile(version).iframe_attr_validation +} + +/// Whether `attr_name` is a security-sensitive `"#); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?}" + ); + } + + #[test] + fn namespaced_iframe_i18n_src_stays_allowed() { + let (_, _, errors) = + compile(r#""#); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn v21_keeps_svg_script_and_does_not_validate_namespaced_animate() { + let version = Some(AngularVersion::new(21, 2, 7)); + let (names, contexts, _) = compile_at( + r#""#, + version, + ); + assert!(names.iter().any(|name| name.contains("script")), "{names:?}"); + assert!( + contexts.iter().any(|(name, ctx)| name == "to" && *ctx == SecurityContext::None), + "{contexts:?}" + ); + } + + #[test] + fn xml_script_is_kept_and_xml_iframe_src_stays_translatable() { + let (names, _, _) = compile(r#"alert(1)"#); + assert!(names.iter().any(|name| name.contains("script")), "{names:?}"); + let (_, _, errors) = + compile(r#""#); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn v21_2_3_allows_iframe_src_translation() { + let (_, _, errors) = compile_at( + r#""#, + Some(AngularVersion::new(21, 2, 3)), + ); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn selectorless_iframe_host_rejects_i18n_src() { + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?}" + ); + } + + #[test] + fn selectorless_svg_iframe_host_is_not_the_iframe_sink() { + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn selectorless_without_host_tag_is_not_a_sink() { + let (_, _, errors) = + compile_selectorless(r#""#); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn selectorless_script_class_is_kept_and_script_host_is_rejected() { + let (names, _, errors) = compile_selectorless(r#""#); + assert!(errors.is_empty(), "{errors:?}"); + assert!(names.iter().any(|name| name == "component:Script"), "{names:?}"); + + let (names, _, errors) = compile_selectorless( + r#""#, + ); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?} {names:?}" + ); + assert!(names.iter().any(|name| name == "component:Script"), "{names:?}"); + + let (_, _, errors) = compile_selectorless(r#""#); + assert!( + errors + .iter() + .any(|msg| msg.contains("Tag name \"script\" cannot be used as a component tag")), + "{errors:?}" + ); + } + + #[test] + fn element_script_class_is_still_stripped() { + let (names, _, _) = compile(r#""#); + assert!( + !names.iter().any(|name| name.to_ascii_lowercase().contains("script")), + "{names:?}" + ); + } + + #[test] + fn selectorless_inside_svg_inherits_the_namespace() { + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?}" + ); + + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + + let (names, _, errors) = + compile_selectorless(r#""#); + assert!( + !errors.iter().any(|msg| msg.contains("cannot be used as a component tag")), + "{errors:?}" + ); + assert!(names.iter().any(|name| name == "component:MyComp"), "{names:?}"); + + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?}" + ); + } + + #[test] + fn selectorless_inside_foreign_object_does_not_inherit_svg() { + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + + let (_, _, errors) = compile_selectorless( + r#""#, + ); + assert!( + errors.iter().any(|msg| msg.contains("disallowed for security reasons")), + "{errors:?}" + ); + } + + #[test] + fn selectorless_iframe_src_i18n_follows_version() { + let (_, _, errors) = compile_selectorless_at( + r#""#, + Some(AngularVersion::new(21, 2, 3)), + ); + assert!(!errors.iter().any(|msg| msg.contains("disallowed")), "{errors:?}"); + } + + #[test] + fn selectorless_binding_security_uses_the_host_tag() { + // `iframe|src` is a resource URL on the resolved host tag. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "src" && *ctx == SecurityContext::ResourceUrl), + "{contexts:?}" + ); + + // The `:svg:` host hits the namespaced animation schema. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "to" && *ctx == SecurityContext::AttributeNoBinding), + "{contexts:?}" + ); + + // A namespaced host does not fall back to the bare iframe sink. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts.iter().all(|(name, ctx)| name != "src" || *ctx == SecurityContext::None), + "{contexts:?}" + ); + + // `tagName === null` resolves over every element: `*|innerHTML`. + let (_, contexts, _) = compile_selectorless(r#""#); + assert!( + contexts.iter().any(|(name, ctx)| name == "innerHTML" && *ctx == SecurityContext::Html), + "{contexts:?}" + ); + } + + #[test] + fn prefixed_element_does_not_inherit_the_parent_namespace_for_security() { + // `normalizeTagName` drops a non-svg/math prefix, so `` + // inside `` still requires the resource-URL sanitizer. + let (_, contexts, _) = compile(r#""#); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "src" && *ctx == SecurityContext::ResourceUrl), + "{contexts:?}" + ); + } + + #[test] + fn children_inherit_an_arbitrary_prefix() { + // `_getPrefix` inherits `getNsPrefix(parentName)` verbatim, so `
"#, + "TestComponent", + Some(AngularVersion::new(21, 0, 1)), + ); + assert!( + !js.contains("ɵɵvalidateIframeAttribute"), + "Non-iframe host should not get the iframe validator. Got:\n{js}" + ); + let js = compile_template_to_js_with_version( + r#""#, + "TestComponent", + Some(AngularVersion::new(21, 0, 1)), + ); + assert!( + !js.contains("ɵɵvalidateIframeAttribute"), + "Non-sensitive attribute should not get the iframe validator. Got:\n{js}" + ); +} + // ============================================================================ // Host Directive Alias Tests // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs b/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs index de6102647..65f2c0078 100644 --- a/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs +++ b/crates/oxc_angular_compiler/tests/r3_template_transform_test.rs @@ -394,7 +394,8 @@ fn parse_internal( html_result.errors.iter().map(|e| &e.msg).collect::>() ); - let options = TransformOptions { collect_comment_nodes: collect_comments }; + let options = + TransformOptions { collect_comment_nodes: collect_comments, ..Default::default() }; let transformer = HtmlToR3Transform::new(allocator_ref, html, options); let r3_result = transformer.transform(&html_result.nodes); @@ -758,7 +759,7 @@ fn get_transform_errors(html: &str) -> Vec { let parser = HtmlParser::new(allocator_ref, html, "test.html"); let html_result = parser.parse(); - let options = TransformOptions { collect_comment_nodes: false }; + let options = TransformOptions { collect_comment_nodes: false, ..Default::default() }; let transformer = HtmlToR3Transform::new(allocator_ref, html, options); let r3_result = transformer.transform(&html_result.nodes); @@ -2340,7 +2341,7 @@ mod switch_invalid_case_unknown_blocks { let parser = HtmlParser::new(allocator_ref, html, "test.html"); let html_result = parser.parse(); - let options = TransformOptions { collect_comment_nodes: false }; + let options = TransformOptions { collect_comment_nodes: false, ..Default::default() }; let transformer = HtmlToR3Transform::new(allocator_ref, html, options); let r3_result = transformer.transform(&html_result.nodes);