From 717f0c76c60b8acd464b14b97a3b77cc2117412e Mon Sep 17 00:00:00 2001 From: Gabriel Silva Date: Thu, 22 Oct 2026 00:35:50 -0300 Subject: [PATCH] fix(compiler): emit the sanitizers ngtsc adds since 22.0.1 Port the sanitization fixes Angular shipped from 22.0.1 on, each gated on the releases that got it upstream: - sanitize two-way property bindings (angular/angular@3c70270c96) - check host bindings against every concrete host element, so a selector without a URL sink no longer drops the sanitizer (angular/angular@d06e3748b7) - treat href / xlink:href of any MathML element as a URL (angular/angular@3927a5b271) - fall back from the :svg: / :math: keys to the HTML keys and validate iframe credentialless (angular/angular@32d7315094, angular/angular@0152e3cbdf) - look an outside up in the SVG keys (angular/angular@7168bed663) --- .../src/component/metadata.rs | 135 ++++++++++++ .../src/pipeline/phases/resolve_sanitizers.rs | 12 +- .../src/schema/dom_security_schema.rs | 196 ++++++++++++++---- .../src/transform/html_to_r3.rs | 28 ++- .../oxc_angular_compiler/tests/linker_test.rs | 22 +- .../tests/sanitizer_parity_test.rs | 173 ++++++++++++++++ .../regressions/sanitizer-parity.fixture.ts | 80 +++++++ 7 files changed, 594 insertions(+), 52 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/sanitizer_parity_test.rs create mode 100644 napi/angular-compiler/e2e/compare/fixtures/regressions/sanitizer-parity.fixture.ts diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index b97bc38d8..1c8401a6b 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -310,6 +310,99 @@ impl AngularVersion { self.at_22_2_2_cutoff_or_later() } + /// Check if this version sanitizes two-way property bindings + /// (`[(innerHTML)]`, `[(href)]`, ...). + /// + /// `angular/angular@3c70270c96` added `TwoWayProperty` to + /// `resolveSanitizers`. First released in 20.3.25, 21.2.17, 22.0.1 and + /// 22.1.0-next.0; the 19.2.x line never got it. + pub fn sanitizes_two_way_properties(&self) -> bool { + self.fixed_since(&[ + Self::new(20, 3, 25), + Self::new(21, 2, 17), + Self::new(22, 0, 1), + Self::new(22, 1, 0).with_prerelease(Prerelease::Next(0)), + ]) + } + + /// Check if this version treats `href` / `xlink:href` of every MathML + /// element as a URL sink (`:math:*`), not only a fixed element list. + /// + /// `angular/angular@3927a5b271`, first in 22.0.1 and 22.1.0-next.0. + pub fn matches_any_mathml_href(&self) -> bool { + self.fixed_since(&[ + Self::new(22, 0, 1), + Self::new(22, 1, 0).with_prerelease(Prerelease::Next(0)), + ]) + } + + /// Check if this version's security lookup falls back from the + /// `:svg:` / `:math:` table to the HTML table (`
"#), + r#"ɵɵtwoWayProperty("innerHTML",ctx.u,i0.ɵɵsanitizeHtml)"#, + r#"ɵɵtwoWayProperty("innerHTML",ctx.u)"#, + &with, + &before, + ); + assert_gated( + &component(r#""#), + r#"ɵɵtwoWayProperty("src",ctx.u,i0.ɵɵsanitizeResourceUrl)"#, + r#"ɵɵtwoWayProperty("src",ctx.u)"#, + &with, + &before, + ); + assert_gated( + &component(r#""#), + r#"ɵɵtwoWayProperty("href",ctx.u,i0.ɵɵsanitizeUrl)"#, + r#"ɵɵtwoWayProperty("href",ctx.u)"#, + &with, + &before, + ); + assert_gated( + &component(r#""#), + r#"ɵɵtwoWayProperty("innerHTML",ctx.u,i0.ɵɵsanitizeHtml)"#, + r#"ɵɵtwoWayProperty("innerHTML",ctx.u)"#, + &with, + &before, + ); +} + +#[test] +fn host_bindings_are_sanitized_for_any_concrete_host() { + // The directive can land on another element through `hostDirectives`, so + // a selector without a URL sink no longer turns the sanitizer off. + let with = ["20.3.28", "21.2.20", "22.1.0", "22.2.0-next.0"]; + let before = ["20.3.27", "21.2.19", "22.0.8", "22.1.0-rc.0"]; + assert_gated( + &directive("div[d]", r"{ '[href]': 'u', '[attr.src]': 'u' }"), + r#"ɵɵdomProperty("href",ctx.u,i0.ɵɵsanitizeUrlOrResourceUrl)"#, + r#"ɵɵdomProperty("href",ctx.u)"#, + &with, + &before, + ); + assert_gated( + &directive("div[d]", r"{ '[attr.src]': 'u' }"), + r#"ɵɵattribute("src",ctx.u,i0.ɵɵsanitizeUrlOrResourceUrl)"#, + r#"ɵɵattribute("src",ctx.u)"#, + &with, + &before, + ); + // A selector that is itself a URL sink still widens: `a[d]` may run on + // ``, whose `href` is a resource URL. + assert_gated( + &directive("a[d]", r"{ '[href]': 'u' }"), + r#"ɵɵdomProperty("href",ctx.u,i0.ɵɵsanitizeUrlOrResourceUrl)"#, + r#"ɵɵdomProperty("href",ctx.u,i0.ɵɵsanitizeUrl)"#, + &with, + &before, + ); + assert_gated( + &directive("div[d]", r"{ '[attr.srcdoc]': 'u', '[attr.sandbox]': 'u' }"), + r#"ɵɵattribute("srcdoc",ctx.u,i0.ɵɵsanitizeHtml)("sandbox",ctx.u,i0.ɵɵvalidateAttribute)"#, + r#"ɵɵattribute("srcdoc",ctx.u)("sandbox",ctx.u)"#, + &with, + &before, + ); + + // A context every element shares keeps its own sanitizer, and a name no + // element sanitizes stays bare. + let code = compile(&directive("my-el", r"{ '[innerHTML]': 'u', '[title]': 'u' }"), None); + assert!( + code.contains(r#"ɵɵdomProperty("innerHTML",ctx.u,i0.ɵɵsanitizeHtml)("title",ctx.u)"#), + "{code}" + ); +} + +#[test] +fn any_mathml_element_href_is_a_url() { + assert_gated( + &component( + r#""#, + ), + r#"ɵɵattribute("href",ctx.u,i0.ɵɵsanitizeUrl);i0.ɵɵadvance();i0.ɵɵattribute("href",ctx.u,i0.ɵɵsanitizeUrl)"#, + r#"ɵɵattribute("href",ctx.u);i0.ɵɵadvance();i0.ɵɵattribute("href",ctx.u)"#, + &["22.0.1", "22.1.0-next.0"], + &["21.2.25", "22.0.0"], + ); +} + +#[test] +fn namespaced_elements_fall_back_to_the_html_schema() { + let source = + component(r#""#); + let with = ["22.0.2", "22.1.0-next.1"]; + let before = ["22.0.1", "22.1.0-next.0"]; + assert_gated( + &source, + r#"("src",ctx.u,i0.ɵɵsanitizeResourceUrl)"#, + r#"("src",ctx.u)"#, + &with, + &before, + ); + assert_gated( + &source, + r#"("srcdoc",ctx.u,i0.ɵɵsanitizeHtml)"#, + r#"("srcdoc",ctx.u)"#, + &with, + &before, + ); +} + +#[test] +fn iframe_credentialless_is_validated() { + assert_gated( + &component(r#""#), + r#"ɵɵattribute("credentialless",ctx.u,i0.ɵɵvalidateAttribute)"#, + r#"ɵɵattribute("credentialless",ctx.u)"#, + &["22.0.2", "22.1.0-next.1"], + &["22.0.1", "22.1.0-next.0"], + ); +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/regressions/sanitizer-parity.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/regressions/sanitizer-parity.fixture.ts new file mode 100644 index 000000000..5795a9a9f --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/regressions/sanitizer-parity.fixture.ts @@ -0,0 +1,80 @@ +/** + * Regression: sanitizers ngtsc emits. + * + * Two-way bindings, host bindings checked against every concrete host, + * MathML hrefs, namespaced elements falling back to the HTML schema and + * iframe `credentialless`. + */ +import type { Fixture } from '../types.js' + +function component(name: string, template: string): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _d, c) => c.toUpperCase())}Component` + return { + name, + category: 'regressions', + description: name, + className, + type: 'full-transform', + sourceCode: ` +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-${name}', + template: \`${template}\`, +}) +export class ${className} { + u = ''; +} + `.trim(), + expectedFeatures: [], + } +} + +function directive(name: string, selector: string, host: string): Fixture { + const className = `${name.replace(/(^|-)(\w)/g, (_, _d, c) => c.toUpperCase())}Directive` + return { + name, + category: 'regressions', + description: name, + className, + type: 'full-transform', + sourceCode: ` +import { Directive } from '@angular/core'; + +@Directive({ selector: '${selector}', host: ${host} }) +export class ${className} { + u = ''; +} + `.trim(), + expectedFeatures: [], + } +} + +export const fixtures: Fixture[] = [ + component('sanitize-two-way-html', `
`), + component('sanitize-two-way-iframe', ``), + component('sanitize-two-way-href', ``), + component('sanitize-two-way-ng-template', ``), + directive( + 'sanitize-host-div', + 'div[d]', + `{ '[href]': 'u', '[attr.src]': 'u', '[attr.srcdoc]': 'u', '[attr.sandbox]': 'u' }`, + ), + directive('sanitize-host-img', 'img[d]', `{ '[src]': 'u' }`), + directive( + 'sanitize-host-element', + 'my-el', + `{ '[href]': 'u', '[innerHTML]': 'u', '[attr.action]': 'u', '[title]': 'u' }`, + ), + directive('sanitize-host-anchor', 'a[d]', `{ '[href]': 'u' }`), + directive('sanitize-host-svg', ':svg:rect', `{ '[attr.href]': 'u' }`), + component( + 'sanitize-mathml-href', + ``, + ), + component( + 'sanitize-svg-html-fallback', + `
`, + ), + component('sanitize-credentialless', ``), +]