From d80213b53a9c4546f8f1df9cd1d5629d81dd5475 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 10:12:21 +0800 Subject: [PATCH 01/15] fix(security): align sanitizer sinks with Angular v22 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #315 is still open on main, which vendors @angular/compiler v22.0.0. The previous branch pinned v21.2.7 and stripped namespaces before lookup. v22 keeps :svg: and :math: in the security key and strips script elements instead of treating script|src as a resource URL. - Register the v22 DOM security schema, including SVG animation attributes as ATTRIBUTE_NO_BINDING and namespaced MathML hrefs. - Compute host-binding security from the directive selector, including :not() and svg/math promotion of unknown element names. - Map ATTRIBUTE_NO_BINDING to ɵɵvalidateAttribute. - Reject i18n of Trusted Types sinks without stripping a namespace prefix. - Drop script and :svg:script while lowering templates, and look security up on the qualified element name. --- .../src/i18n/extractor_merger.rs | 79 +- .../src/pipeline/ingest.rs | 65 +- .../src/pipeline/phases/resolve_sanitizers.rs | 6 +- .../src/schema/dom_security_schema.rs | 815 +++++++++++++----- crates/oxc_angular_compiler/src/schema/mod.rs | 6 +- .../src/schema/trusted_types_sinks.rs | 44 + .../src/transform/html_to_r3.rs | 162 +++- 7 files changed, 875 insertions(+), 302 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs diff --git a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs index 3a143e4aa..f7fd55b2f 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; use crate::util::{ParseSourceFile, ParseSourceSpan}; // ============================================================================ @@ -1083,7 +1084,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 +1096,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(element_name, target_name) { + 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()); } } @@ -1235,10 +1245,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(element_name, target_name) { + 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()); } } @@ -1794,6 +1814,61 @@ mod tests { 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); + 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); + assert!(result.errors.is_empty()); + assert!(!result.messages.is_empty()); + } + #[test] fn test_parse_translated_text_plain() { let nodes = parse_translated_text("Hello World", Span::default()); diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 22c9955af..87ac752c9 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -4122,7 +4122,17 @@ 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( + job.component_selector.as_str(), + name.as_str(), + ) + } + _ => SecurityContext::None, + }, i18n_message: None, is_text_attribute: false, }); @@ -4130,58 +4140,11 @@ fn ingest_host_dom_property<'a>( job.root.update.push(op); } -/// Computes the security context for an attribute binding. +/// Security context for a static host attribute. /// -/// 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`. +/// Same selector rules as host property bindings (`calcPossibleSecurityContexts`). 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. -/// -/// 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 } + crate::schema::host_binding_security_context(selector, attr_name) } /// Ingests a static host attribute. 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..a911d3644 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs @@ -27,10 +27,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), } } 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..3e333b77d 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,603 @@ //! 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; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::ast::r3::SecurityContext; +use crate::parser::html::split_ns_name; +use crate::pipeline::selector::CssSelector; + /// Security schema mapping `"element|property"` to `SecurityContext`. -/// Properties applying to all elements use `"*"` as the element name. -static SECURITY_SCHEMA: LazyLock> = LazyLock::new(|| { +/// +/// Keys follow `registerContext` in `dom_security_schema.ts`: an `svg` or `math` +/// namespace is stored as `:svg:tag|attr` / `:math:tag|attr`. `*` and `unknown` +/// stay un-namespaced. Lookup lowercases both sides. +static SECURITY_SCHEMA: LazyLock> = LazyLock::new(|| { let mut schema = FxHashMap::default(); - // HTML contexts - content that will be parsed as HTML - register_context( + register( &mut schema, SecurityContext::Html, - &["iframe|srcdoc", "*|innerhtml", "*|outerhtml"], + None, + &[("iframe", &["srcdoc"]), ("*", &["innerHTML", "outerHTML"])], ); + register(&mut schema, SecurityContext::Style, None, &[("*", &["style"])]); - // Style contexts - CSS style content - register_context(&mut schema, SecurityContext::Style, &["*|style"]); - - // URL contexts - URLs that are navigable (less dangerous than resource URLs) - register_context( + // No SCRIPT contexts: the parser strips `"#); + assert!(errors.is_empty(), "{errors:?}"); + assert!(names.iter().any(|name| name == ":svg:svg" || name == "svg")); + assert!(!names.iter().any(|name| name.contains("script"))); + assert!(names.iter().any(|name| name.contains("animate"))); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "to" && *ctx == SecurityContext::AttributeNoBinding), + "{names:?} {contexts:?}" + ); + } + + #[test] + fn math_href_is_a_url() { + let (names, contexts, _) = compile(r#""#); + assert!(names.iter().any(|name| name.contains("mi")), "{names:?}"); + assert!( + contexts.iter().any(|(name, ctx)| name == "href" && *ctx == SecurityContext::Url), + "{contexts:?}" + ); + } + + #[test] + fn iframe_i18n_src_is_rejected() { + let (_, _, errors) = compile(r#""#); + 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:?}"); + } +} From d999394ba9a6e9534d9f2c1645ac5ec745d337cf Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 10:35:28 +0800 Subject: [PATCH 02/15] fix(security): keep pre-v22 sanitizer schema by Angular version v22 stays the default. Compiling for an earlier version uses that version's security schema instead of the namespaced v22 keys. - Before 21.1: script|src only. - 21.1 through 21.2.6: script|href, MathML hrefs, attributeName no-binding. - 21.2.4+: i18n rejects iframe|src. - 21.2.7 through 21.x: bare animate/set to, from, and values. - 22+: namespaced keys, and :svg:script is stripped. --- .../src/component/transform.rs | 30 +- .../src/directive/compiler.rs | 20 +- .../src/pipeline/ingest.rs | 30 +- .../src/schema/dom_security_schema.rs | 368 +++++++++++++++++- crates/oxc_angular_compiler/src/schema/mod.rs | 6 +- .../src/schema/trusted_types_sinks.rs | 35 +- .../src/transform/html_to_r3.rs | 81 +++- .../tests/r3_template_transform_test.rs | 7 +- 8 files changed, 515 insertions(+), 62 deletions(-) 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/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 87ac752c9..2e920a755 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -4126,9 +4126,10 @@ fn ingest_host_dom_property<'a>( // `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( + crate::schema::host_binding_security_context_for( job.component_selector.as_str(), name.as_str(), + job.angular_version, ) } _ => SecurityContext::None, @@ -4143,8 +4144,12 @@ fn ingest_host_dom_property<'a>( /// Security context for a static host attribute. /// /// Same selector rules as host property bindings (`calcPossibleSecurityContexts`). -fn compute_security_context(selector: &str, attr_name: &str) -> SecurityContext { - crate::schema::host_binding_security_context(selector, attr_name) +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. @@ -4165,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 @@ -4405,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) @@ -4494,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/schema/dom_security_schema.rs b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs index 3e333b77d..6a8d4ba9e 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -13,12 +13,75 @@ use crate::ast::r3::SecurityContext; use crate::parser::html::split_ns_name; use crate::pipeline::selector::CssSelector; -/// Security schema mapping `"element|property"` to `SecurityContext`. +/// Which Angular security schema a compilation is targeting. /// -/// Keys follow `registerContext` in `dom_security_schema.ts`: an `svg` or `math` -/// namespace is stored as `:svg:tag|attr` / `:math:tag|attr`. `*` and `unknown` -/// stay un-namespaced. Lookup lowercases both sides. -static SECURITY_SCHEMA: LazyLock> = LazyLock::new(|| { +/// `None` means the latest schema (v22). The v22 schema namespaces SVG and +/// MathML keys. Earlier versions store bare `tag|attr` keys and gained the +/// SVG animation and Trusted Types entries on the v21 line. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SchemaKind { + /// Before 21.1. `script|src` is a resource URL. No SVG animation sinks. + Legacy, + /// 21.1.0 through 21.2.6. Adds `script|href`, MathML hrefs, and + /// `attributeName` no-binding keys. No animation value attributes yet. + V21_1, + /// 21.2.7 through 21.x. Animation `to` / `from` / `values` are bare keys. + V21_2_7, + /// 22+. Namespaced keys. `script|src` and `script|href` are gone. + V22, +} + +struct SecurityProfile { + kind: SchemaKind, + /// v22 `normalizeTagName` keeps `:svg:` and `:math:`. + namespaced: bool, + /// v22 preparser strips `:svg:script` as well as `script`. + strip_svg_script: bool, + /// `iframe|src` joined Trusted Types sinks in 21.2.4. + iframe_src_i18n: bool, +} + +fn security_profile(version: Option) -> SecurityProfile { + let Some(version) = version else { + return v22_profile(); + }; + if version.major >= 22 { + return v22_profile(); + } + let on_21 = version.major == 21; + let v21_1 = on_21 && version.minor >= 1; + let v21_2_4 = on_21 && (version.minor > 2 || (version.minor == 2 && version.patch >= 4)); + let v21_2_7 = on_21 && (version.minor > 2 || (version.minor == 2 && version.patch >= 7)); + let kind = if v21_2_7 { + SchemaKind::V21_2_7 + } else if v21_1 { + SchemaKind::V21_1 + } else { + SchemaKind::Legacy + }; + SecurityProfile { kind, namespaced: false, strip_svg_script: false, iframe_src_i18n: v21_2_4 } +} + +fn v22_profile() -> SecurityProfile { + SecurityProfile { + kind: SchemaKind::V22, + namespaced: true, + strip_svg_script: true, + iframe_src_i18n: true, + } +} + +/// 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 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 +} + +fn build_v22_schema() -> FxHashMap { let mut schema = FxHashMap::default(); register( @@ -111,7 +174,134 @@ static SECURITY_SCHEMA: LazyLock> = LazyLock: ); schema -}); +} + +static V22_SCHEMA: LazyLock> = LazyLock::new(build_v22_schema); +static V21_27_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::V21_2_7)); +static V21_1_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::V21_1)); +static LEGACY_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::Legacy)); + +fn schema_for(kind: SchemaKind) -> &'static FxHashMap { + match kind { + SchemaKind::Legacy => &LEGACY_SCHEMA, + SchemaKind::V21_1 => &V21_1_SCHEMA, + SchemaKind::V21_2_7 => &V21_27_SCHEMA, + SchemaKind::V22 => &V22_SCHEMA, + } +} + +/// Bare-key schema used before Angular 22. +/// +/// 21.1 adds MathML hrefs, `script|href`, iframe sandbox keys, and +/// `attributeName` no-binding. 21.2.7 adds the animation value attributes. +fn build_pren22_schema(kind: SchemaKind) -> FxHashMap { + let mut schema = FxHashMap::default(); + register_base_html_style_and_url(&mut schema); + register( + &mut schema, + SecurityContext::ResourceUrl, + None, + &[ + ("base", &["href"]), + ("embed", &["src"]), + ("frame", &["src"]), + ("iframe", &["src"]), + ("link", &["href"]), + ("object", &["codebase", "data"]), + ("script", &["src"]), + ], + ); + + let extended = matches!(kind, SchemaKind::V21_1 | SchemaKind::V21_2_7); + if extended { + register_uniform( + &mut schema, + SecurityContext::Url, + None, + MATHML_URL_ELEMENTS, + &["href", "xlink:href"], + ); + register( + &mut schema, + SecurityContext::ResourceUrl, + None, + &[("script", &["href", "xlink:href"])], + ); + register( + &mut schema, + SecurityContext::AttributeNoBinding, + None, + &[ + ("animate", &["attributeName"]), + ("set", &["attributeName"]), + ("animateMotion", &["attributeName"]), + ("animateTransform", &["attributeName"]), + ("unknown", &["attributeName"]), + ( + "iframe", + &[ + "sandbox", + "allow", + "allowFullscreen", + "referrerPolicy", + "csp", + "fetchPriority", + ], + ), + ( + "unknown", + &[ + "sandbox", + "allow", + "allowFullscreen", + "referrerPolicy", + "csp", + "fetchPriority", + ], + ), + ], + ); + } + if matches!(kind, SchemaKind::V21_2_7) { + register( + &mut schema, + SecurityContext::AttributeNoBinding, + None, + &[ + ("animate", &["values", "to", "from"]), + ("set", &["to"]), + ("unknown", &["values", "to", "from"]), + ], + ); + } + schema +} + +fn register_base_html_style_and_url(schema: &mut FxHashMap) { + register( + schema, + SecurityContext::Html, + None, + &[("iframe", &["srcdoc"]), ("*", &["innerHTML", "outerHTML"])], + ); + register(schema, SecurityContext::Style, None, &[("*", &["style"])]); + register( + schema, + SecurityContext::Url, + None, + &[ + ("*", &["formAction"]), + ("area", &["href"]), + ("a", &["href", "xlink:href"]), + ("form", &["action"]), + ("img", &["src"]), + ("video", &["src"]), + ], + ); +} /// MathML elements whose `href` / `xlink:href` are URL sinks in the security schema. /// `annotation`, `malignmark`, `mglyph`, `mprescripts`, and `none` are not in the @@ -439,20 +629,36 @@ fn normalize_tag_name(tag_name: &str) -> String { } } -/// Security context for one element and property. +/// Security context for one element and property on the latest schema (v22). /// /// Case-insensitive. Returns `SecurityContext::None` when the pair is not a sink. pub fn get_security_context(element: &str, property: &str) -> SecurityContext { - let tag = normalize_tag_name(element); + get_security_context_for(element, property, None) +} + +/// Security context for the Angular version being compiled. +/// +/// v22 keeps `:svg:` and `:math:` in the lookup key. Earlier versions lowercase +/// the tag as written and look up a bare `tag|attr` key, so `:svg:animate|to` +/// misses and `animate|to` hits on 21.2.7. +pub fn get_security_context_for( + element: &str, + property: &str, + version: Option, +) -> SecurityContext { + let profile = security_profile(version); + let tag = + if profile.namespaced { normalize_tag_name(element) } else { element.to_ascii_lowercase() }; let property_lower = property.to_ascii_lowercase(); + let schema = schema_for(profile.kind); let key = format!("{tag}|{property_lower}"); - if let Some(&ctx) = SECURITY_SCHEMA.get(&key) { + if let Some(&ctx) = schema.get(&key) { return ctx; } let wildcard_key = format!("*|{property_lower}"); - if let Some(&ctx) = SECURITY_SCHEMA.get(&wildcard_key) { + if let Some(&ctx) = schema.get(&wildcard_key) { return ctx; } @@ -466,20 +672,39 @@ pub fn calc_security_context_for_unknown_element(property: &str) -> SecurityCont host_binding_security_context("", property) } -/// Security context of a host binding. +/// Security context of a host binding on the latest schema (v22). /// /// Mirrors `calcPossibleSecurityContexts` plus the host ingest filter that drops /// `NONE` and the `{URL, RESOURCE_URL}` pair in `resolve_sanitizers.ts`. -/// `style` / `class` / animation bindings are classified by the caller; this -/// function is the element-selector lookup for attribute and property bindings. pub fn host_binding_security_context(selector: &str, prop_name: &str) -> SecurityContext { - reduce_security_contexts(&collect_security_contexts(selector, prop_name)) + host_binding_security_context_for(selector, prop_name, None) } -fn collect_security_contexts(selector: &str, prop_name: &str) -> Vec { +/// Host-binding security context for a specific Angular version. +pub fn host_binding_security_context_for( + selector: &str, + prop_name: &str, + version: Option, +) -> SecurityContext { + let contexts = if security_profile(version).namespaced { + collect_namespaced_contexts(selector, prop_name, version) + } else { + collect_bare_contexts(selector, prop_name, version) + }; + reduce_security_contexts(&contexts) +} + +fn collect_namespaced_contexts( + selector: &str, + prop_name: &str, + version: Option, +) -> Vec { let selector = selector.trim(); if selector.is_empty() { - return KNOWN_ELEMENT_NAMES.iter().map(|el| get_security_context(el, prop_name)).collect(); + return KNOWN_ELEMENT_NAMES + .iter() + .map(|el| get_security_context_for(el, prop_name, version)) + .collect(); } let (namespace_key, base_selector) = split_ns_name(selector); @@ -495,8 +720,51 @@ fn collect_security_contexts(selector: &str, prop_name: &str) -> Vec, +) -> Vec { + let selector = selector.trim(); + if selector.is_empty() { + return KNOWN_ELEMENT_NAMES + .iter() + .map(|el| get_security_context_for(el, prop_name, version)) + .collect(); + } + + let mut contexts = Vec::new(); + for css in CssSelector::parse(selector) { + let excluded: FxHashSet = css + .not_selectors + .iter() + .filter(|sel| { + sel.element.is_some() + && sel.class_names.is_empty() + && sel.attrs.is_empty() + && sel.not_selectors.is_empty() + }) + .filter_map(|sel| sel.element.clone()) + .collect(); + let element_names: Vec = if let Some(element) = &css.element { + vec![element.clone()] + } else { + KNOWN_ELEMENT_NAMES.iter().map(|name| (*name).to_string()).collect() + }; + for element_name in element_names { + if excluded.contains(&element_name) { + continue; } + contexts.push(get_security_context_for(&element_name, prop_name, version)); } } contexts @@ -733,4 +1001,70 @@ mod tests { SecurityContext::UrlOrResourceUrl ); } + + fn v21_2_7() -> Option { + Some(crate::AngularVersion::new(21, 2, 7)) + } + + #[test] + fn v21_2_7_uses_bare_keys() { + let version = v21_2_7(); + assert_eq!( + get_security_context_for("animate", "to", version), + SecurityContext::AttributeNoBinding + ); + assert_eq!(get_security_context_for(":svg:animate", "to", version), SecurityContext::None); + assert_eq!( + get_security_context_for("script", "src", version), + SecurityContext::ResourceUrl + ); + assert_eq!( + get_security_context_for("script", "href", version), + SecurityContext::ResourceUrl + ); + assert_eq!(get_security_context_for("mi", "href", version), SecurityContext::Url); + assert_eq!(get_security_context_for(":math:mi", "href", version), SecurityContext::None); + assert_eq!( + host_binding_security_context_for("animate", "to", version), + SecurityContext::AttributeNoBinding + ); + assert_eq!( + host_binding_security_context_for("[x]", "to", version), + SecurityContext::AttributeNoBinding + ); + } + + #[test] + fn v21_2_6_has_no_animation_value_sinks() { + let version = Some(crate::AngularVersion::new(21, 2, 6)); + assert_eq!(get_security_context_for("animate", "to", version), SecurityContext::None); + assert_eq!( + get_security_context_for("animate", "attributeName", version), + SecurityContext::AttributeNoBinding + ); + assert!(rejects_iframe_src_i18n(version)); + assert_eq!(host_binding_security_context_for("[x]", "to", version), SecurityContext::None); + } + + #[test] + fn v21_0_has_script_src_only() { + let version = Some(crate::AngularVersion::new(21, 0, 0)); + assert_eq!( + get_security_context_for("script", "src", version), + SecurityContext::ResourceUrl + ); + assert_eq!(get_security_context_for("script", "href", version), SecurityContext::None); + assert_eq!( + get_security_context_for("animate", "attributeName", version), + SecurityContext::None + ); + assert!(!rejects_iframe_src_i18n(version)); + assert!(!strips_namespaced_svg_script(version)); + } + + #[test] + fn v21_2_3_still_allows_iframe_src_i18n() { + assert!(!rejects_iframe_src_i18n(Some(crate::AngularVersion::new(21, 2, 3)))); + assert!(rejects_iframe_src_i18n(Some(crate::AngularVersion::new(21, 2, 4)))); + } } diff --git a/crates/oxc_angular_compiler/src/schema/mod.rs b/crates/oxc_angular_compiler/src/schema/mod.rs index 2e2826cf6..7e9c537cd 100644 --- a/crates/oxc_angular_compiler/src/schema/mod.rs +++ b/crates/oxc_angular_compiler/src/schema/mod.rs @@ -7,6 +7,8 @@ mod dom_security_schema; mod trusted_types_sinks; pub use dom_security_schema::{ - calc_security_context_for_unknown_element, get_security_context, host_binding_security_context, + calc_security_context_for_unknown_element, get_security_context, get_security_context_for, + host_binding_security_context, host_binding_security_context_for, rejects_iframe_src_i18n, + strips_namespaced_svg_script, }; -pub use trusted_types_sinks::is_trusted_types_sink; +pub use trusted_types_sinks::{is_trusted_types_sink, is_trusted_types_sink_at}; diff --git a/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs b/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs index 74b04317b..882f733ec 100644 --- a/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs +++ b/crates/oxc_angular_compiler/src/schema/trusted_types_sinks.rs @@ -16,10 +16,24 @@ const TRUSTED_TYPES_SINKS: &[&str] = &[ "object|data", ]; -/// Whether `prop_name` on `tag_name` is a Trusted Types sink. +/// Whether `prop_name` on `tag_name` is a Trusted Types sink on the latest schema. pub fn is_trusted_types_sink(tag_name: &str, prop_name: &str) -> bool { + is_trusted_types_sink_at(tag_name, prop_name, None) +} + +/// Trusted Types sink check for a specific Angular version. +/// +/// `iframe|src` is rejected from 21.2.4 onward. Earlier compilers allow it. +pub fn is_trusted_types_sink_at( + tag_name: &str, + prop_name: &str, + version: Option, +) -> bool { let tag_name = tag_name.to_ascii_lowercase(); let prop_name = prop_name.to_ascii_lowercase(); + if tag_name == "iframe" && prop_name == "src" && !super::rejects_iframe_src_i18n(version) { + return false; + } let specific = format!("{tag_name}|{prop_name}"); let wildcard = format!("*|{prop_name}"); TRUSTED_TYPES_SINKS.iter().any(|sink| *sink == specific || *sink == wildcard) @@ -41,4 +55,23 @@ mod tests { assert!(!is_trusted_types_sink(":svg:iframe", "src")); assert!(!is_trusted_types_sink("div", "title")); } + + #[test] + fn iframe_src_sink_starts_at_21_2_4() { + assert!(!is_trusted_types_sink_at( + "iframe", + "src", + Some(crate::AngularVersion::new(21, 2, 3)) + )); + assert!(is_trusted_types_sink_at( + "iframe", + "src", + Some(crate::AngularVersion::new(21, 2, 4)) + )); + assert!(is_trusted_types_sink_at( + "iframe", + "srcdoc", + Some(crate::AngularVersion::new(21, 0, 0)) + )); + } } 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 cf664bf10..b967a5b1e 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -10,6 +10,7 @@ use oxc_span::Span; use oxc_str::Ident; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::AngularVersion; use crate::ast::expression::{ AbsoluteSourceSpan, AngularExpression, BindingType, ParseSpan, ParsedEventType, }; @@ -29,7 +30,9 @@ use crate::i18n::parser::I18nMessageFactory; use crate::i18n::placeholder::PlaceholderRegistry; use crate::parser::expression::{BindingParser, find_comment_start}; use crate::parser::html::{decode_entities_in_string, split_ns_name}; -use crate::schema::{get_security_context, is_trusted_types_sink}; +use crate::schema::{ + get_security_context_for, is_trusted_types_sink_at, strips_namespaced_svg_script, +}; use crate::transform::control_flow::{parse_conditional_params, parse_defer_triggers}; use crate::util::ParseError; @@ -98,6 +101,8 @@ struct TemplateAttrInfo<'a> { pub struct TransformOptions { /// Whether to collect comment nodes. pub collect_comment_nodes: bool, + /// Angular version being compiled. `None` uses the latest (v22) security schema. + pub angular_version: Option, } /// Inserts or updates a var entry in an ordered Vec, preserving first-insertion order. @@ -156,6 +161,8 @@ pub struct HtmlToR3Transform<'a> { /// Placeholder registry for generating unique tag placeholder names within i18n blocks. /// Reset when entering a new i18n block. i18n_placeholder_registry: PlaceholderRegistry, + /// Angular version for security-schema and script-stripping compatibility. + angular_version: Option, /// Counter for generating unique i18n message instance IDs. /// /// Each i18n message gets a unique instance ID that's used to track message identity @@ -195,9 +202,14 @@ impl<'a> HtmlToR3Transform<'a> { icu_placeholder_counts: FxHashMap::default(), i18n_placeholder_registry: PlaceholderRegistry::new(), i18n_message_instance_counter: 0, + angular_version: options.angular_version, } } + fn security_context(&self, element: &str, property: &str) -> SecurityContext { + get_security_context_for(element, property, self.angular_version) + } + /// Allocates a new unique instance ID for an i18n message. fn allocate_i18n_message_instance_id(&mut self) -> u32 { let id = self.i18n_message_instance_counter; @@ -323,12 +335,13 @@ impl<'a> HtmlToR3Transform<'a> { child_namespace }; let local_name = split_ns_name(raw_name).1.to_ascii_lowercase(); + let security_name = Self::security_element_name(raw_name, element_namespace); - // `"#, + version, + ); + assert!(names.iter().any(|name| name.contains("script")), "{names:?}"); + assert!( + contexts.iter().any(|(name, ctx)| name == "to" && *ctx == SecurityContext::None), + "{contexts:?}" + ); + } + + #[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:?}"); + } } 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); From a55970ea14591c7aecc93c640b0b7b7ec70940ee Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 10:41:44 +0800 Subject: [PATCH 03/15] fix(security): keep namespace prefixes on sink and script checks Trusted Types compares the parser's full element name and does not drop a non-svg/math prefix. Script stripping matches only `script` and `:svg:script`. Implicit i18n attributes that name a sink are rejected on extract and merge, same as an explicit i18n-* marker. --- .../src/i18n/extractor_merger.rs | 106 ++++++++++++++++-- .../src/transform/html_to_r3.rs | 67 +++++++++-- 2 files changed, 153 insertions(+), 20 deletions(-) diff --git a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs index f7fd55b2f..04c2c29fc 100644 --- a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs +++ b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs @@ -1119,8 +1119,27 @@ 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(element_name, attr.name) { + 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() { @@ -1274,13 +1293,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(element_name, attr.name) { + 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, + ); + } } } } @@ -1869,6 +1898,65 @@ mod tests { 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); + 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); + 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_parse_translated_text_plain() { let nodes = parse_translated_text("Hello World", Span::default()); 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 b967a5b1e..6a9eb9003 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -334,18 +334,21 @@ impl<'a> HtmlToR3Transform<'a> { } else { child_namespace }; - let local_name = split_ns_name(raw_name).1.to_ascii_lowercase(); let security_name = Self::security_element_name(raw_name, element_namespace); + // Trusted Types and the script/style sets use the parser's full name. + // `security_element_name` drops non-svg/math prefixes (`:xml:iframe` → + // `iframe`), which is correct for the security schema and wrong here. + let qualified_name = Self::qualified_element_name(raw_name, element_namespace); // HTML `"#); + 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_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:?}"); + } } From 933649f7f7fd485f8c8349a6c389cbc4856efeaa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 11:37:24 +0800 Subject: [PATCH 05/15] fix(security): inherit svg and math namespaces on selectorless hosts A selectorless component inside `` or `` takes that namespace, the same way Angular builds `tagName`. `` is `:svg:ng-component` and is rejected. `` is `:svg:iframe` and is not the `iframe|src` sink. `foreignObject` still resets the namespace to HTML. --- .../src/transform/html_to_r3.rs | 136 +++++++++++++++--- 1 file changed, 117 insertions(+), 19 deletions(-) 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 d128f7f3a..361d95a8e 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -29,7 +29,7 @@ use crate::ast::r3::{ use crate::i18n::parser::I18nMessageFactory; use crate::i18n::placeholder::PlaceholderRegistry; use crate::parser::expression::{BindingParser, find_comment_start}; -use crate::parser::html::{decode_entities_in_string, split_ns_name}; +use crate::parser::html::{decode_entities_in_string, get_html_tag_definition, split_ns_name}; use crate::schema::{ get_security_context_for, is_trusted_types_sink_at, strips_namespaced_svg_script, }; @@ -339,11 +339,15 @@ impl<'a> HtmlToR3Transform<'a> { // `security_element_name` drops non-svg/math prefixes (`:xml:iframe` → // `iframe`), which is correct for the security schema and wrong here. let qualified_name = Self::qualified_element_name(raw_name, element_namespace); - // `` keeps the class in `name`. The host tag, including an - // explicit namespace (`:svg:iframe`, `:svg:ng-component`), is what - // `isTrustedTypesSink` sees. No host tag means `tagName === null`. - let host_tag = - if element.is_component { Self::selectorless_host_tag(element) } else { None }; + // `` keeps the class in `name`. The host tag is what + // `isTrustedTypesSink` sees, including an explicit prefix and a namespace + // inherited from `` / `` (`:svg:ng-component`, `:svg:iframe`). + // No prefix and no local tag is `tagName === null`. + let host_tag = if element.is_component { + Self::selectorless_host_tag(element, parent_namespace) + } else { + None + }; if element.is_component { // `visitComponent` does not run the element preparser. A class named @@ -706,18 +710,20 @@ impl<'a> HtmlToR3Transform<'a> { /// Visits an HTML component (selectorless component AST node). fn visit_html_component(&mut self, component: &HtmlComponent<'a>) -> Option> { - if let Some(tag) = component.tag_name.as_ref() - && UNSUPPORTED_SELECTORLESS_TAGS.contains(&tag.as_str()) + let parent_namespace = self.current_namespace(); + let host_tag = Self::component_node_host_tag(component, parent_namespace); + if let Some(tag) = host_tag.as_deref() + && UNSUPPORTED_SELECTORLESS_TAGS.contains(&tag) { self.report_error( - &format!("Tag name \"{}\" cannot be used as a component tag", tag.as_str()), + &format!("Tag name \"{tag}\" cannot be used as a component tag"), component.start_span, ); return None; } // `tagName === null` is not a Trusted Types sink. `full_name` is the class. - let i18n_element_name = component.tag_name.as_ref().map(|tag| tag.as_str()); + let i18n_element_name = host_tag.as_deref(); let (attributes, inputs, outputs, references, _variables, template_attr) = self .parse_attributes( &component.attrs, @@ -727,7 +733,6 @@ impl<'a> HtmlToR3Transform<'a> { ); // Resolve namespace for this component and its children. - let parent_namespace = self.current_namespace(); let element_namespace = self.resolve_namespace(component.full_name.as_str(), parent_namespace); self.namespace_stack.push(element_namespace); @@ -903,14 +908,59 @@ impl<'a> HtmlToR3Transform<'a> { /// Host tag of a selectorless component, matching Angular's `tagName`. /// - /// `None` is `` (`tagName === null`). An explicit prefix is kept - /// (`:svg:iframe`, or `:svg:ng-component` when the prefix has no local name). - fn selectorless_host_tag(element: &HtmlElement<'a>) -> Option { - match (&element.component_prefix, &element.component_tag_name) { - (None, None) => None, - (None, Some(tag)) => Some(tag.as_str().to_string()), - (Some(prefix), None) => Some(format!(":{}:ng-component", prefix.as_str())), - (Some(prefix), Some(tag)) => Some(format!(":{}:{}", prefix.as_str(), tag.as_str())), + /// Prefix order matches `_getPrefix`: explicit prefix, then the host tag's + /// implicit namespace (`svg`, `math`, `foreignObject`), then the parent + /// namespace. `foreignObject` already resets that parent to HTML. A prefix + /// with no local tag becomes `ng-component`. No prefix and no local tag is + /// `tagName === null`. + fn selectorless_host_tag( + element: &HtmlElement<'a>, + parent_namespace: ElementNamespace, + ) -> Option { + Self::canonical_host_tag( + element.component_prefix.as_ref().map(|prefix| prefix.as_str()), + element.component_tag_name.as_ref().map(|tag| tag.as_str()), + parent_namespace, + ) + } + + /// `HtmlComponent.tag_name` is either already `:ns:local` or a local name. + fn component_node_host_tag( + component: &HtmlComponent<'a>, + parent_namespace: ElementNamespace, + ) -> Option { + match component.tag_name.as_ref().map(|tag| tag.as_str()) { + Some(tag) if tag.starts_with(':') => Some(tag.to_string()), + other => Self::canonical_host_tag(None, other, parent_namespace), + } + } + + fn canonical_host_tag( + explicit_prefix: Option<&str>, + local_tag: Option<&str>, + parent_namespace: ElementNamespace, + ) -> Option { + let mut prefix = explicit_prefix.unwrap_or("").to_string(); + if prefix.is_empty() + && let Some(tag) = local_tag + && let Some(implicit) = get_html_tag_definition(tag).implicit_namespace_prefix + { + prefix = implicit.to_string(); + } + if prefix.is_empty() { + prefix = match parent_namespace { + ElementNamespace::Svg => "svg".to_string(), + ElementNamespace::Math => "math".to_string(), + ElementNamespace::Html => String::new(), + }; + } + match (prefix.is_empty(), local_tag) { + (true, None) => None, + (true, Some(tag)) => Some(tag.to_string()), + (false, local) => { + let local = local.unwrap_or("ng-component"); + Some(format!(":{prefix}:{local}")) + } } } @@ -5193,6 +5243,54 @@ mod security_tests { ); } + #[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( From d6bf367e0116b4f869ab527e0a84b5a49d9fdc16 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 12:36:32 +0800 Subject: [PATCH 06/15] fix(security): derive binding contexts from the selectorless host tag --- .../src/schema/dom_security_schema.rs | 42 +++++- crates/oxc_angular_compiler/src/schema/mod.rs | 4 +- .../src/transform/html_to_r3.rs | 131 +++++++++++++++--- 3 files changed, 150 insertions(+), 27 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 6a8d4ba9e..faad7b188 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -614,7 +614,9 @@ fn register_uniform( } } -fn is_known_element(name: &str) -> bool { +/// Whether `name` is in the DOM element schema (`allKnownElementNames`), +/// including `:svg:`/`:math:`-prefixed entries. +pub fn is_known_element(name: &str) -> bool { KNOWN_ELEMENT_SET.contains(name.to_ascii_lowercase().as_str()) } @@ -707,12 +709,28 @@ fn collect_namespaced_contexts( .collect(); } - let (namespace_key, base_selector) = split_ns_name(selector); + // `splitNsName` treats any leading `:x:` as a namespace, including the + // `:not(` of a pseudo-class. Only a plain identifier is a namespace, so a + // selector like `:not(img):not(video)` reaches `CssSelector::parse` whole + // instead of parsing `not(video)` as the element. + let (namespace_key, base_selector) = match split_ns_name(selector) { + (Some(ns), _) if !ns.is_empty() && ns.bytes().all(is_selector_ident_byte) => { + split_ns_name(selector) + } + _ => (None, selector), + }; let mut contexts = Vec::new(); for css in CssSelector::parse(base_selector) { let excluded = not_element_names(&css); let element_names: Vec = if let Some(element) = &css.element { - resolve_concrete_element(element) + if element == "*" && !excluded.is_empty() { + // `*` on a `:not(...)` selector means "any element". Expanding it + // lets the exclusions apply; a literal `*|attr` lookup would + // silently drop the sanitizer. + KNOWN_ELEMENT_NAMES.iter().map(|name| (*name).to_string()).collect() + } else { + resolve_concrete_element(element) + } } else { KNOWN_ELEMENT_NAMES.iter().map(|name| (*name).to_string()).collect() }; @@ -799,6 +817,10 @@ fn qualify_with_selector_namespace(element_name: &str, namespace_key: Option<&st } } +fn is_selector_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'-' || b == b'_' +} + fn not_element_names(css: &CssSelector) -> FxHashSet { css.not_selectors .iter() @@ -994,6 +1016,20 @@ mod tests { ); } + #[test] + fn test_host_selector_leading_not_is_not_a_namespace() { + // `:not(...)` is a pseudo-class, not a `:ns:` prefix. + assert_eq!( + host_binding_security_context(":not(img):not(video)", "src"), + SecurityContext::ResourceUrl + ); + // A real `:svg:` prefix still namespaces the lookup. + assert_eq!( + host_binding_security_context(":svg:animate", "to"), + SecurityContext::AttributeNoBinding + ); + } + #[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 7e9c537cd..fefebe26c 100644 --- a/crates/oxc_angular_compiler/src/schema/mod.rs +++ b/crates/oxc_angular_compiler/src/schema/mod.rs @@ -8,7 +8,7 @@ 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, rejects_iframe_src_i18n, - strips_namespaced_svg_script, + host_binding_security_context, host_binding_security_context_for, is_known_element, + rejects_iframe_src_i18n, strips_namespaced_svg_script, }; pub use trusted_types_sinks::{is_trusted_types_sink, is_trusted_types_sink_at}; 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 361d95a8e..39d3ab333 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,8 @@ 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_trusted_types_sink_at, strips_namespaced_svg_script, + get_security_context_for, is_known_element, is_trusted_types_sink_at, + strips_namespaced_svg_script, }; use crate::transform::control_flow::{parse_conditional_params, parse_defer_triggers}; use crate::util::ParseError; @@ -334,20 +335,25 @@ impl<'a> HtmlToR3Transform<'a> { } else { child_namespace }; - let security_name = Self::security_element_name(raw_name, element_namespace); - // Trusted Types and the script/style sets use the parser's full name. - // `security_element_name` drops non-svg/math prefixes (`:xml:iframe` → - // `iframe`), which is correct for the security schema and wrong here. - let qualified_name = Self::qualified_element_name(raw_name, element_namespace); // `` keeps the class in `name`. The host tag is what - // `isTrustedTypesSink` sees, including an explicit prefix and a namespace - // inherited from `` / `` (`:svg:ng-component`, `:svg:iframe`). - // No prefix and no local tag is `tagName === null`. + // `isTrustedTypesSink` and the binding security lookup see, including an + // explicit prefix and a namespace inherited from `` / `` + // (`:svg:ng-component`, `:svg:iframe`). No prefix and no local tag is + // `tagName === null`. let host_tag = if element.is_component { Self::selectorless_host_tag(element, parent_namespace) } else { None }; + let security_name = if element.is_component { + Self::component_security_name(host_tag.as_deref()) + } else { + Self::security_element_name(raw_name, element_namespace) + }; + // Trusted Types and the script/style sets use the parser's full name. + // `security_element_name` drops non-svg/math prefixes (`:xml:iframe` → + // `iframe`), which is correct for the security schema and wrong here. + let qualified_name = Self::qualified_element_name(raw_name, element_namespace); if element.is_component { // `visitComponent` does not run the element preparser. A class named @@ -510,7 +516,7 @@ impl<'a> HtmlToR3Transform<'a> { self.namespace_stack.pop(); // Transform selectorless directives from HTML AST - let directives = self.transform_directives(&element.directives, raw_name); + let directives = self.transform_directives(&element.directives, &security_name); // Determine if element is self-closing (explicitly closed with />) let is_self_closing = element.is_self_closing; @@ -724,13 +730,9 @@ impl<'a> HtmlToR3Transform<'a> { // `tagName === null` is not a Trusted Types sink. `full_name` is the class. let i18n_element_name = host_tag.as_deref(); - let (attributes, inputs, outputs, references, _variables, template_attr) = self - .parse_attributes( - &component.attrs, - component.full_name.as_str(), - i18n_element_name, - false, - ); + let security_name = Self::component_security_name(host_tag.as_deref()); + let (attributes, inputs, outputs, references, _variables, template_attr) = + self.parse_attributes(&component.attrs, &security_name, i18n_element_name, false); // Resolve namespace for this component and its children. let element_namespace = @@ -775,10 +777,8 @@ impl<'a> HtmlToR3Transform<'a> { self.namespace_stack.pop(); // Transform selectorless directives from HTML AST - // For components, tag_name may be None (e.g., ``), in which case we use empty string - // which matches TypeScript's behavior where elementName can be null. - let element_name = component.tag_name.as_ref().map_or("", Ident::as_str); - let directives = self.transform_directives(&component.directives, element_name); + // `security_name` is empty when the host tag is `tagName === null`. + let directives = self.transform_directives(&component.directives, &security_name); // Validate selectorless references self.validate_selectorless_references(&references); @@ -892,13 +892,18 @@ impl<'a> HtmlToR3Transform<'a> { /// Element name passed to `securityContext` / `isTrustedTypesSink`. /// /// Matches `normalizeTagName` plus the namespace Angular's HTML parser bakes - /// into the node name (`:svg:animate`, `:math:mi`). + /// into the node name (`:svg:animate`, `:math:mi`). An explicit non-svg/math + /// prefix is dropped (`:xml:iframe` → `iframe`); the parent namespace is not + /// inherited by an already-prefixed name. fn security_element_name(raw_name: &str, namespace: ElementNamespace) -> String { let lower = raw_name.to_ascii_lowercase(); let (ns, local) = split_ns_name(&lower); if let Some(ns @ ("svg" | "math")) = ns { return format!(":{ns}:{local}"); } + if ns.is_some() { + return local.to_string(); + } match namespace { ElementNamespace::Svg => format!(":svg:{local}"), ElementNamespace::Math => format!(":math:{local}"), @@ -964,6 +969,33 @@ impl<'a> HtmlToR3Transform<'a> { } } + /// Security-schema name for a selectorless component, matching + /// `calcPossibleSecurityContexts(component.tagName, ...)`. + /// + /// A bare host tag that is not an HTML element is rewritten to its known + /// `:svg:`/`:math:` form (`animate` → `:svg:animate`). `tagName === null` + /// resolves over every known element upstream; the empty name reproduces + /// that through the `*|attr` fallback (`src` → `NONE`, `innerHTML` → + /// `HTML`). + fn component_security_name(host_tag: Option<&str>) -> String { + let Some(tag) = host_tag else { + return String::new(); + }; + let lower = tag.to_ascii_lowercase(); + let (ns, local) = split_ns_name(&lower); + if ns.is_none() && !is_known_element(local) { + let svg = format!(":svg:{local}"); + if is_known_element(&svg) { + return svg; + } + let math = format!(":math:{local}"); + if is_known_element(&math) { + return math; + } + } + tag.to_string() + } + /// Full element name for Trusted Types on real elements, and for the script/style sets. /// /// Keeps every `:prefix:name`, and applies an inherited `svg` or `math` @@ -5097,6 +5129,10 @@ mod security_tests { if let Some(tag) = component.tag_name { names.push(format!("host:{}", tag.as_str())); } + for input in &component.inputs { + contexts + .push((input.name.as_str().to_string(), input.security_context)); + } walk(&component.children, names, contexts); } R3Node::Template(template) => walk(&template.children, names, contexts), @@ -5299,4 +5335,55 @@ mod security_tests { ); 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:?}" + ); + } } From 8fab161c646e7d4b91706475e4f1523f80379c4f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 18:00:33 +0800 Subject: [PATCH 07/15] fix(security): derive child namespaces from the selectorless host tag --- .../src/transform/html_to_r3.rs | 110 ++++++++++++++++-- 1 file changed, 98 insertions(+), 12 deletions(-) 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 39d3ab333..4957777e8 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -327,14 +327,6 @@ impl<'a> HtmlToR3Transform<'a> { // local name and tracks the namespace on a stack, so the lookup name is // qualified explicitly. let parent_namespace = self.current_namespace(); - let child_namespace = self.resolve_namespace(raw_name, parent_namespace); - let element_namespace = if parent_namespace == ElementNamespace::Svg - && raw_name.eq_ignore_ascii_case("foreignObject") - { - ElementNamespace::Svg - } else { - child_namespace - }; // `` keeps the class in `name`. The host tag is what // `isTrustedTypesSink` and the binding security lookup see, including an // explicit prefix and a namespace inherited from `` / `` @@ -345,6 +337,25 @@ impl<'a> HtmlToR3Transform<'a> { } else { None }; + // For a component the host tag drives both namespaces: the element's own + // namespace is the host's, and children inherit it unless the host tag + // prevents namespace inheritance (`foreignObject`). `tagName === null` + // resets to HTML (`_getPrefix` skips a null parent tagName). + let (element_namespace, child_namespace) = if element.is_component { + let host_namespace = Self::component_host_namespace(host_tag.as_deref()); + let child = Self::component_children_namespace(host_tag.as_deref(), host_namespace); + (host_namespace, child) + } else { + let child = self.resolve_namespace(raw_name, parent_namespace); + let own = if parent_namespace == ElementNamespace::Svg + && raw_name.eq_ignore_ascii_case("foreignObject") + { + ElementNamespace::Svg + } else { + child + }; + (own, child) + }; let security_name = if element.is_component { Self::component_security_name(host_tag.as_deref()) } else { @@ -734,10 +745,13 @@ impl<'a> HtmlToR3Transform<'a> { let (attributes, inputs, outputs, references, _variables, template_attr) = self.parse_attributes(&component.attrs, &security_name, i18n_element_name, false); - // Resolve namespace for this component and its children. - let element_namespace = - self.resolve_namespace(component.full_name.as_str(), parent_namespace); - self.namespace_stack.push(element_namespace); + // Children inherit the host tag's namespace (`_getPrefix` uses + // `component.tagName` as the parent name), honoring + // `preventNamespaceInheritance` on hosts like `foreignObject`. + let element_namespace = Self::component_host_namespace(host_tag.as_deref()); + let child_namespace = + Self::component_children_namespace(host_tag.as_deref(), element_namespace); + self.namespace_stack.push(child_namespace); // Check if component has ngNonBindable attribute let has_non_bindable = @@ -969,6 +983,35 @@ impl<'a> HtmlToR3Transform<'a> { } } + /// The namespace a selectorless host tag itself belongs to, from its + /// resolved `:ns:` prefix. Bare local tags and `tagName === null` are HTML. + fn component_host_namespace(host_tag: Option<&str>) -> ElementNamespace { + host_tag + .and_then(|tag| { + let (ns, _) = split_ns_name(tag); + ns.and_then(Self::namespace_from_prefix) + }) + .unwrap_or(ElementNamespace::Html) + } + + /// Namespace pushed for children of a selectorless component. + /// + /// `_getPrefix` inherits the parent namespace unless the parent's tag + /// definition sets `preventNamespaceInheritance` (`foreignObject`). The + /// local part of the resolved host tag is what upstream looks up. + fn component_children_namespace( + host_tag: Option<&str>, + host_namespace: ElementNamespace, + ) -> ElementNamespace { + let prevents_inheritance = host_tag + .map(|tag| { + let (_, local) = split_ns_name(tag); + get_html_tag_definition(local).prevent_namespace_inheritance + }) + .unwrap_or(false); + if prevents_inheritance { ElementNamespace::Html } else { host_namespace } + } + /// Security-schema name for a selectorless component, matching /// `calcPossibleSecurityContexts(component.tagName, ...)`. /// @@ -5386,4 +5429,47 @@ mod security_tests { "{contexts:?}" ); } + + #[test] + fn selectorless_children_inherit_the_host_tag_namespace() { + // Children of `` are MathML: `mi` is only an href sink in + // the `:math:` namespace. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts.iter().any(|(name, ctx)| name == "href" && *ctx == SecurityContext::Url), + "{contexts:?}" + ); + + // `foreignObject` prevents namespace inheritance, so children of + // `` inside `` are HTML again. + let (_, contexts, _) = compile_selectorless( + r#""#, + ); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "src" && *ctx == SecurityContext::ResourceUrl), + "{contexts:?}" + ); + + // `` inside `` resolves to `:svg:ng-component`, so its + // children stay namespaced. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts.iter().all(|(name, ctx)| name != "src" || *ctx == SecurityContext::None), + "{contexts:?}" + ); + + // At the HTML root `tagName === null`: children are plain HTML. + let (_, contexts, _) = + compile_selectorless(r#""#); + assert!( + contexts + .iter() + .any(|(name, ctx)| name == "src" && *ctx == SecurityContext::ResourceUrl), + "{contexts:?}" + ); + } } From ee63c9e9f61bcb01db245fb85f5ec6ee92d2686c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 18:36:26 +0800 Subject: [PATCH 08/15] fix(security): gate schemas per backported release line Upstream backported the security-schema fixes non-monotonically, so a flat major/minor gate mis-assigns keys on several versions: - 21.0.6 dropped the legacy ping/cite/applet/media keys before script|href landed in 21.0.7, so it needs its own profile. - 20.3.16-20.3.21 keep the legacy URL keys alongside script|href; the key removal was never backported to the 20.3 line. - 21.2.14 is namespaced but lacks :svg:a|href (added in 20.3.22 / 21.2.15). - The preparser classified :svg:style as a style element only on 20.3.22 and 21.2.14 (STYLE_ELEMENTS); it was reverted everywhere else. --- .../src/schema/dom_security_schema.rs | 303 +++++++++++++++--- crates/oxc_angular_compiler/src/schema/mod.rs | 2 +- .../src/transform/html_to_r3.rs | 10 +- 3 files changed, 271 insertions(+), 44 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 faad7b188..16adde0e3 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -15,29 +15,48 @@ use crate::pipeline::selector::CssSelector; /// Which Angular security schema a compilation is targeting. /// -/// `None` means the latest schema (v22). The v22 schema namespaces SVG and -/// MathML keys. Earlier versions store bare `tag|attr` keys and gained the -/// SVG animation and Trusted Types entries on the v21 line. +/// `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 { - /// Before 21.1. `script|src` is a resource URL. No SVG animation sinks. + /// 21.0.0 / 21.0.1 and everything below 20.3.15. Old URL set with + /// `*|ping`, `*|cite`, `applet|code`, `media|src`, etc. Legacy, - /// 21.1.0 through 21.2.6. Adds `script|href`, MathML hrefs, and - /// `attributeName` no-binding keys. No animation value attributes yet. + /// 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, - /// 21.2.7 through 21.x. Animation `to` / `from` / `values` are bare keys. + /// Same as `V21_1` but still carrying the legacy URL keys: only + /// 20.3.16–20.3.21 (the key removal was never backported to 20.3). + V20_3_16, + /// 21.2.7 through 21.2.13. Animation `to` / `from` / `values` are bare keys. V21_2_7, - /// 22+. Namespaced keys. `script|src` and `script|href` are gone. + /// 21.2.14 only. Namespaced keys but no `:svg:a|href` yet. + V21_2_14, + /// 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, - /// v22 `normalizeTagName` keeps `:svg:` and `:math:`. + /// `normalizeTagName` keeps `:svg:` and `:math:` (namespaced schema). namespaced: bool, - /// v22 preparser strips `:svg:script` as well as `script`. + /// The preparser strips `:svg:script` as well as `script`. strip_svg_script: bool, - /// `iframe|src` joined Trusted Types sinks in 21.2.4. + /// The preparser also strips `:svg:style`. Only 20.3.22 and 21.2.14 did + /// this; it was reverted everywhere else. + strip_svg_style: bool, + /// `iframe|src` joined Trusted Types sinks (20.3.18–21, 21.2.4+; never on + /// the 21.0.x / 21.1.x lines). iframe_src_i18n: bool, } @@ -48,18 +67,43 @@ fn security_profile(version: Option) -> SecurityProfile { if version.major >= 22 { return v22_profile(); } - let on_21 = version.major == 21; - let v21_1 = on_21 && version.minor >= 1; - let v21_2_4 = on_21 && (version.minor > 2 || (version.minor == 2 && version.patch >= 4)); - let v21_2_7 = on_21 && (version.minor > 2 || (version.minor == 2 && version.patch >= 7)); - let kind = if v21_2_7 { - SchemaKind::V21_2_7 - } else if v21_1 { - SchemaKind::V21_1 - } else { - SchemaKind::Legacy + + let (kind, iframe_src_i18n) = match version.major { + 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), }; - SecurityProfile { kind, namespaced: false, strip_svg_script: false, iframe_src_i18n: v21_2_4 } + + let namespaced = matches!(kind, SchemaKind::V21_2_14 | SchemaKind::V22); + // `:svg:style` stripping existed only in 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); + SecurityProfile { + kind, + namespaced, + strip_svg_script: namespaced, + strip_svg_style, + iframe_src_i18n, + } } fn v22_profile() -> SecurityProfile { @@ -67,6 +111,7 @@ fn v22_profile() -> SecurityProfile { kind: SchemaKind::V22, namespaced: true, strip_svg_script: true, + strip_svg_style: false, iframe_src_i18n: true, } } @@ -76,12 +121,19 @@ pub fn strips_namespaced_svg_script(version: Option) -> b 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 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 } -fn build_v22_schema() -> FxHashMap { +fn build_v22_schema(with_svg_a: bool) -> FxHashMap { let mut schema = FxHashMap::default(); register( @@ -130,7 +182,10 @@ fn build_v22_schema() -> FxHashMap { ], ); - register(&mut schema, SecurityContext::Url, Some("svg"), &[("a", &["href", "xlink:href"])]); + // `:svg:a|href` landed in 20.3.22 / 21.2.15; 21.2.14 did not have it. + if with_svg_a { + register(&mut schema, SecurityContext::Url, Some("svg"), &[("a", &["href", "xlink:href"])]); + } // SVG animation value attributes can retarget `href` / `xlink:href`. // Upstream registers them under the SVG namespace as ATTRIBUTE_NO_BINDING. @@ -176,30 +231,94 @@ fn build_v22_schema() -> FxHashMap { schema } -static V22_SCHEMA: LazyLock> = LazyLock::new(build_v22_schema); +static V22_SCHEMA: LazyLock> = + LazyLock::new(|| build_v22_schema(true)); +static V21_2_14_SCHEMA: LazyLock> = + LazyLock::new(|| build_v22_schema(false)); static V21_27_SCHEMA: LazyLock> = LazyLock::new(|| build_pren22_schema(SchemaKind::V21_2_7)); static V21_1_SCHEMA: LazyLock> = LazyLock::new(|| build_pren22_schema(SchemaKind::V21_1)); +static V20_3_16_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::V20_3_16)); +static V20_3_15_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::V20_3_15)); +static V21_0_6_SCHEMA: LazyLock> = + LazyLock::new(|| build_pren22_schema(SchemaKind::V21_0_6)); static LEGACY_SCHEMA: LazyLock> = LazyLock::new(|| build_pren22_schema(SchemaKind::Legacy)); fn schema_for(kind: SchemaKind) -> &'static FxHashMap { match kind { SchemaKind::Legacy => &LEGACY_SCHEMA, + SchemaKind::V20_3_15 => &V20_3_15_SCHEMA, + SchemaKind::V21_0_6 => &V21_0_6_SCHEMA, + SchemaKind::V20_3_16 => &V20_3_16_SCHEMA, SchemaKind::V21_1 => &V21_1_SCHEMA, SchemaKind::V21_2_7 => &V21_27_SCHEMA, + SchemaKind::V21_2_14 => &V21_2_14_SCHEMA, SchemaKind::V22 => &V22_SCHEMA, } } - -/// Bare-key schema used before Angular 22. -/// -/// 21.1 adds MathML hrefs, `script|href`, iframe sandbox keys, and -/// `attributeName` no-binding. 21.2.7 adds the animation value attributes. +/// Pre-v22 schemas look up bare `tag|attr` keys (`normalizeTagName` did not keep +/// namespaces yet), so nothing here registers `:svg:` or `:math:` keys. fn build_pren22_schema(kind: SchemaKind) -> FxHashMap { let mut schema = FxHashMap::default(); register_base_html_style_and_url(&mut schema); + + let hardened = kind != SchemaKind::Legacy; + let legacy_url_keys = + matches!(kind, SchemaKind::Legacy | SchemaKind::V20_3_15 | SchemaKind::V20_3_16); + // `script|href` / `script|xlink:href` landed in 20.3.16 and 21.0.7. + let script_href = + matches!(kind, SchemaKind::V20_3_16 | SchemaKind::V21_1 | SchemaKind::V21_2_7); + + if hardened { + // `a|xlink:href` was added with the MathML hardening; the Legacy URL + // set has only `a|href` / `a|ping`. + register(&mut schema, SecurityContext::Url, None, &[("a", &["xlink:href"])]); + register_uniform( + &mut schema, + SecurityContext::Url, + None, + MATHML_URL_ELEMENTS, + &["href", "xlink:href"], + ); + } + + if legacy_url_keys { + register( + &mut schema, + SecurityContext::Url, + None, + &[ + ("area", &["ping"]), + ("audio", &["src"]), + ("a", &["ping"]), + ("blockquote", &["cite"]), + ("body", &["background"]), + ("del", &["cite"]), + ("input", &["src"]), + ("ins", &["cite"]), + ("q", &["cite"]), + ("source", &["src"]), + ("track", &["src"]), + ("video", &["poster"]), + ], + ); + register( + &mut schema, + SecurityContext::ResourceUrl, + None, + &[ + ("applet", &["code", "codebase"]), + ("head", &["profile"]), + ("html", &["manifest"]), + ("media", &["src"]), + ], + ); + } + register( &mut schema, SecurityContext::ResourceUrl, @@ -214,22 +333,16 @@ fn build_pren22_schema(kind: SchemaKind) -> FxHashMap { ("script", &["src"]), ], ); - - let extended = matches!(kind, SchemaKind::V21_1 | SchemaKind::V21_2_7); - if extended { - register_uniform( - &mut schema, - SecurityContext::Url, - None, - MATHML_URL_ELEMENTS, - &["href", "xlink:href"], - ); + if script_href { register( &mut schema, SecurityContext::ResourceUrl, None, &[("script", &["href", "xlink:href"])], ); + } + + if hardened { register( &mut schema, SecurityContext::AttributeNoBinding, @@ -295,7 +408,9 @@ fn register_base_html_style_and_url(schema: &mut FxHashMap HtmlToR3Transform<'a> { { return None; } - if qualified_name == "style" { + // The preparser classified `:svg:style` as a style element only on + // 20.3.22 and 21.2.14 (`STYLE_ELEMENTS`); elsewhere it stays an + // ordinary element. + if qualified_name == "style" + || (strips_namespaced_svg_style(self.angular_version) + && qualified_name == ":svg:style") + { if let Some(content) = self.get_text_content(element) { self.styles.push(content); } From 0324bb1a9f3d1823b4e450566d993234c1fbb321 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 18:45:53 +0800 Subject: [PATCH 09/15] fix(security): inherit arbitrary prefixes like upstream _getPrefix Upstream _getPrefix passes getNsPrefix(parentName) to children verbatim, so a parent like :xml:div resolves its children to :xml:* and normalizeTagName then drops the non-svg/math prefix for the security lookup. The previous ElementNamespace stack could only represent svg/math/html, so an arbitrary prefix fell back to the surrounding namespace and
"#, + "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 // ============================================================================ From da571ca4922635e5c0b1ed1f9a630737d8fca820 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 23:22:09 +0800 Subject: [PATCH 13/15] fix(security): normalize tag names only where upstream does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream added `normalizeTagName` to `securityContext` one release after the namespaced schema keys: 19.2.23, 20.3.22, and 21.2.15. At 21.2.14 (and every earlier version) the tag is lowercased verbatim, so `:xml:iframe|src` misses the schema and produces no sanitizer. Our lookup stripped the prefix unconditionally, emitting `ɵɵsanitizeResourceUrl` for targets where upstream assigns NONE. `get_security_context_for` now takes the verbatim resolved name and a new `normalizes_tag_names` profile flag (the V22 schema kind) decides whether `normalizeTagName` applies. --- .../src/schema/dom_security_schema.rs | 52 ++++++++++++++++--- .../src/transform/html_to_r3.rs | 45 ++++++++++------ 2 files changed, 75 insertions(+), 22 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 6352566f3..fd0829d54 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -49,8 +49,16 @@ enum SchemaKind { struct SecurityProfile { kind: SchemaKind, - /// `normalizeTagName` keeps `:svg:` and `:math:` (namespaced schema). + /// 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 @@ -115,6 +123,10 @@ fn security_profile(version: Option) -> SecurityProfile { 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, @@ -126,6 +138,7 @@ 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, @@ -797,17 +810,22 @@ pub fn get_security_context(element: &str, property: &str) -> SecurityContext { /// Security context for the Angular version being compiled. /// -/// v22 keeps `:svg:` and `:math:` in the lookup key. Earlier versions lowercase -/// the tag as written and look up a bare `tag|attr` key, so `:svg:animate|to` -/// misses and `animate|to` hits on 21.2.7. +/// Callers pass the resolved (possibly `:ns:`-prefixed) element name. Versions +/// with `normalizeTagName` (19.2.23+, 20.3.22+, 21.2.15+) strip non-svg/math +/// prefixes; every earlier version lowercases the tag as written, so +/// `:xml:iframe|src` misses the schema while `:svg:animate|to` hits its +/// namespaced key at 21.2.14. pub fn get_security_context_for( element: &str, property: &str, version: Option, ) -> SecurityContext { let profile = security_profile(version); - let tag = - if profile.namespaced { normalize_tag_name(element) } else { element.to_ascii_lowercase() }; + let tag = if profile.normalizes_tag_names { + normalize_tag_name(element) + } else { + element.to_ascii_lowercase() + }; let property_lower = property.to_ascii_lowercase(); let schema = schema_for(profile.kind); @@ -1440,6 +1458,28 @@ mod tests { assert!(!uses_iframe_attr_validation(Some(crate::AngularVersion::new(22, 0, 0)))); } + #[test] + fn v21_2_14_looks_up_the_tag_verbatim() { + // 21.2.14 has the namespaced schema keys but `securityContext` had no + // `normalizeTagName` yet: the tag is lowercased verbatim. + let v21_2_14 = Some(crate::AngularVersion::new(21, 2, 14)); + assert_eq!(get_security_context_for(":xml:iframe", "src", v21_2_14), SecurityContext::None); + assert_eq!( + get_security_context_for(":svg:animate", "to", v21_2_14), + SecurityContext::AttributeNoBinding + ); + // 21.2.15+ normalizes `:xml:iframe` down to `iframe`. + let v21_2_15 = Some(crate::AngularVersion::new(21, 2, 15)); + assert_eq!( + get_security_context_for(":xml:iframe", "src", v21_2_15), + SecurityContext::ResourceUrl + ); + // Pre-namespaced versions never normalized either. + let v21_2_13 = Some(crate::AngularVersion::new(21, 2, 13)); + assert_eq!(get_security_context_for(":xml:iframe", "src", v21_2_13), SecurityContext::None); + assert_eq!(get_security_context_for(":svg:animate", "to", v21_2_13), SecurityContext::None); + } + #[test] fn iframe_security_sensitive_attrs_match_case_insensitively() { for attr in 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 5220fbd41..a70c31055 100644 --- a/crates/oxc_angular_compiler/src/transform/html_to_r3.rs +++ b/crates/oxc_angular_compiler/src/transform/html_to_r3.rs @@ -343,11 +343,12 @@ impl<'a> HtmlToR3Transform<'a> { let security_name = if element.is_component { Self::component_security_name(host_tag.as_deref(), self.angular_version) } else { - Self::security_lookup_name(&resolved_name) + // The security lookup gets the verbatim resolved name; + // `get_security_context_for` decides per target version whether + // `normalizeTagName` strips non-svg/math prefixes. + resolved_name.clone() }; // Trusted Types and the script/style sets use the parser's full name. - // `security_lookup_name` drops non-svg/math prefixes (`:xml:iframe` → - // `iframe`), which is correct for the security schema and wrong here. let qualified_name = resolved_name.to_ascii_lowercase(); // Children inherit this element's own resolved prefix, or nothing when // its tag definition prevents namespace inheritance (`foreignObject`) or @@ -838,19 +839,6 @@ impl<'a> HtmlToR3Transform<'a> { ns.unwrap_or("").to_string() } - /// Element name passed to the security schema (`normalizeTagName`). - /// - /// `:svg:` and `:math:` are kept; any other prefix is dropped - /// (`:xml:iframe` → `iframe`). - fn security_lookup_name(resolved_name: &str) -> String { - let lower = resolved_name.to_ascii_lowercase(); - let (ns, local) = split_ns_name(&lower); - match ns { - Some(ns @ ("svg" | "math")) => format!(":{ns}:{local}"), - _ => local.to_string(), - } - } - /// Host tag of a selectorless component, matching Angular's `tagName`. /// /// Prefix order matches `_getPrefix`: explicit prefix, then the host tag's @@ -5415,4 +5403,29 @@ mod security_tests { "{contexts:?}" ); } + + #[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. + for (major, minor, patch, expected) in [ + (21, 2, 13, SecurityContext::None), + (21, 2, 14, SecurityContext::None), + (21, 2, 15, SecurityContext::ResourceUrl), + (19, 2, 22, SecurityContext::None), + (19, 2, 23, SecurityContext::ResourceUrl), + (20, 3, 21, SecurityContext::None), + (20, 3, 22, SecurityContext::ResourceUrl), + ] { + let (_, contexts, _) = compile_at( + r#""#, + Some(AngularVersion::new(major, minor, patch)), + ); + assert!( + contexts.iter().any(|(name, ctx)| name == "src" && *ctx == expected), + "v{major}.{minor}.{patch}: {contexts:?}" + ); + } + } } From 6290207efd16b7dc5b12015eb576cdf4c0c75f0e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 22 Sep 2026 23:45:17 +0800 Subject: [PATCH 14/15] fix(security): version-gate Trusted Types sinks in standalone i18n `extract_messages`/`merge_translations` hard-coded the v22 sink list via `is_trusted_types_sink`, so a 21.2.3 target that compiles `iframe i18n-src` fine was still rejected during extraction. Both APIs now take the target Angular version and the visitor uses `is_trusted_types_sink_at`, keeping extraction, merge, and template compilation on the same 21.2.4 cutoff. --- .../src/i18n/extractor_merger.rs | 110 ++++++++++++++++-- 1 file changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs index 04c2c29fc..bbc47e74a 100644 --- a/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs +++ b/crates/oxc_angular_compiler/src/i18n/extractor_merger.rs @@ -17,7 +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; +use crate::schema::is_trusted_types_sink_at; use crate::util::{ParseSourceFile, ParseSourceSpan}; // ============================================================================ @@ -368,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) } @@ -391,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) } @@ -568,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> { @@ -581,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, @@ -604,6 +616,7 @@ impl<'a> I18nVisitor<'a> { ), translations: None, source_file, + angular_version, } } @@ -1096,7 +1109,7 @@ 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(element_name, target_name) { + if is_trusted_types_sink_at(element_name, target_name, self.angular_version) { self.report_error( attr.span, &format!( @@ -1124,7 +1137,9 @@ impl<'a> I18nVisitor<'a> { // 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(element_name, attr.name) { + 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, @@ -1269,7 +1284,7 @@ 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(element_name, target_name) { + if is_trusted_types_sink_at(element_name, target_name, self.angular_version) { self.report_error( attr.span, &format!( @@ -1293,7 +1308,7 @@ impl<'a> I18nVisitor<'a> { attr.is_interpolation_only, ); } else if implicit_attr_names.iter().any(|n| n == attr.name) { - if is_trusted_types_sink(element_name, attr.name) { + if is_trusted_types_sink_at(element_name, attr.name, self.angular_version) { self.report_error( attr.span, &format!( @@ -1838,7 +1853,7 @@ 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()); } @@ -1868,7 +1883,7 @@ mod tests { start_span: span, end_span: None, }]; - let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file); + 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"))); } @@ -1893,7 +1908,7 @@ mod tests { start_span: span, end_span: None, }]; - let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file); + let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file, None); assert!(result.errors.is_empty()); assert!(!result.messages.is_empty()); } @@ -1917,7 +1932,7 @@ mod tests { start_span: span, end_span: None, }]; - let result = extract_messages(&nodes, &[], &implicit_attrs, true, source_file); + 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"))); } @@ -1946,7 +1961,7 @@ mod tests { crate::i18n::i18n_html_parser::MissingTranslationStrategy::Ignore, None, ); - let result = merge_translations(&nodes, &bundle, &[], &implicit_attrs, source_file); + 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, .. } => { @@ -1957,6 +1972,79 @@ mod tests { } } + #[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] fn test_parse_translated_text_plain() { let nodes = parse_translated_text("Hello World", Span::default()); From f103a7ffcf97fe5a265ddb44c1e1818dab15c9c8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 23 Sep 2026 00:26:57 +0800 Subject: [PATCH 15/15] fix(security): treat `*` selectors as element-less like upstream Upstream's selector regexp has no `*` production, so `CssSelector.parse` leaves `element` unset for `*` and `*[x]`, and `calcPossibleSecurityContexts` expands them over all known element names. Our parser set `element` to `*`, so a directive like `*[feature]` with a `src` host binding only hit the `*|src` schema key and got no sanitizer instead of `sanitizeUrlOrResourceUrl`. The one place upstream keeps a literal `*` element is a `:not(...)`-only selector; it looks up the `*|attr` key verbatim there, so the expansion in `collect_namespaced_contexts` is removed to match. --- .../src/pipeline/selector.rs | 5 +- .../src/schema/dom_security_schema.rs | 47 +++++++++++++++---- 2 files changed, 40 insertions(+), 12 deletions(-) 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/schema/dom_security_schema.rs b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs index fd0829d54..f7c1f7155 100644 --- a/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs +++ b/crates/oxc_angular_compiler/src/schema/dom_security_schema.rs @@ -897,15 +897,11 @@ fn collect_namespaced_contexts( let mut contexts = Vec::new(); for css in CssSelector::parse(base_selector) { let excluded = not_element_names(&css); + // A literal `*` element (only produced for a `:not(...)`-only selector) + // is looked up as the `*|attr` key, matching upstream: upstream expands + // `element === null` over all known elements but keeps `*` verbatim. let element_names: Vec = if let Some(element) = &css.element { - if element == "*" && !excluded.is_empty() { - // `*` on a `:not(...)` selector means "any element". Expanding it - // lets the exclusions apply; a literal `*|attr` lookup would - // silently drop the sanitizer. - KNOWN_ELEMENT_NAMES.iter().map(|name| (*name).to_string()).collect() - } else { - resolve_concrete_element(element) - } + if element == "*" { vec![element.clone()] } else { resolve_concrete_element(element) } } else { KNOWN_ELEMENT_NAMES.iter().map(|name| (*name).to_string()).collect() }; @@ -966,7 +962,7 @@ fn collect_bare_contexts( /// A bare selector element that is not in the DOM schema is rewritten to /// `:svg:name` or `:math:name` when that namespaced element exists. fn resolve_concrete_element(element: &str) -> Vec { - if element == "*" || is_known_element(element) { + if is_known_element(element) { return vec![element.to_string()]; } let lower = element.to_ascii_lowercase(); @@ -1191,12 +1187,43 @@ mod tests { ); } + #[test] + fn test_host_universal_selector_expands_to_known_elements() { + // `*` never produces an element name upstream, so `*` / `*[x]` behave + // like an attribute-only selector and scan every known element. + for selector in ["*", "*[x]", "[x]"] { + assert_eq!( + host_binding_security_context(selector, "src"), + SecurityContext::UrlOrResourceUrl, + "{selector}" + ); + assert_eq!( + host_binding_security_context(selector, "formAction"), + SecurityContext::Url, + "{selector}" + ); + } + // Same expansion on the pre-namespaced lookup path. + let version = Some(crate::AngularVersion::new(21, 0, 1)); + assert_eq!( + host_binding_security_context_for("*[x]", "src", version), + SecurityContext::UrlOrResourceUrl + ); + // A `:not(...)`-only selector parses to a literal `*` element upstream + // and is looked up as the `*|attr` key, which has no `src` entry. + assert_eq!(host_binding_security_context("*:not(img)", "src"), SecurityContext::None); + assert_eq!( + host_binding_security_context(":not(img):not(video)", "src"), + SecurityContext::None + ); + } + #[test] fn test_host_selector_leading_not_is_not_a_namespace() { // `:not(...)` is a pseudo-class, not a `:ns:` prefix. assert_eq!( host_binding_security_context(":not(img):not(video)", "src"), - SecurityContext::ResourceUrl + SecurityContext::None ); // A real `:svg:` prefix still namespaces the lookup. assert_eq!(