Skip to content

Commit f5150f3

Browse files
committed
fix(security): emit validateIframeAttribute on legacy targets
Upstream resolve_sanitizers.ts falls back to ɵɵvalidateIframeAttribute when a Property/Attribute/DomProperty op on an iframe got no sanitizer from its security context. The path exists on versions without the iframe attributeNoBinding schema keys (removed upstream in 19.2.17 / 20.3.15 / 21.0.2), so gate it to the legacy schema. Also pass angular_version into TransformOptions in the integration test helper so version-targeted tests compile templates against the matching security schema.
1 parent 82c7bea commit f5150f3

3 files changed

Lines changed: 169 additions & 4 deletions

File tree

crates/oxc_angular_compiler/src/pipeline/phases/resolve_sanitizers.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
//! Ported from Angular's `template/pipeline/src/phases/resolve_sanitizers.ts`.
88
99
use oxc_str::Ident;
10+
use rustc_hash::FxHashMap;
1011

1112
use crate::ast::r3::SecurityContext;
12-
use crate::ir::ops::{CreateOp, UpdateOp};
13+
use crate::ir::ops::{CreateOp, UpdateOp, XrefId};
1314
use crate::pipeline::compilation::{ComponentCompilationJob, HostBindingCompilationJob};
1415
use crate::r3::Identifiers;
16+
use crate::schema::{is_iframe_security_sensitive_attr, uses_iframe_attr_validation};
1517

1618
/// Map a security context to its sanitizer function name.
1719
fn get_sanitizer_fn(security_context: SecurityContext) -> Option<&'static str> {
@@ -45,12 +47,74 @@ fn get_trusted_value_fn(security_context: SecurityContext) -> Option<&'static st
4547
}
4648
}
4749

50+
/// The element-or-container create ops upstream indexes with
51+
/// `createOpXrefMap`, reduced to what the iframe fallback reads: whether the
52+
/// owner op is an `elementStart` whose tag is `iframe`.
53+
///
54+
/// Upstream's `isIframeElement` checks `op.kind === OpKind.ElementStart`, so
55+
/// self-closing `element()` ops are intentionally not matched.
56+
fn create_op_xref_map<'a>(ops: impl Iterator<Item = &'a CreateOp<'a>>) -> FxHashMap<XrefId, bool> {
57+
let mut elements = FxHashMap::default();
58+
for op in ops {
59+
let (xref, is_iframe) = match op {
60+
CreateOp::ElementStart(op) => (op.xref, op.tag.as_str().eq_ignore_ascii_case("iframe")),
61+
CreateOp::Element(op) => (op.xref, false),
62+
CreateOp::ContainerStart(op) => (op.xref, false),
63+
CreateOp::Container(op) => (op.xref, false),
64+
CreateOp::Template(op) => (op.xref, false),
65+
CreateOp::Conditional(op) => (op.xref, false),
66+
CreateOp::ConditionalBranch(op) => (op.xref, false),
67+
CreateOp::RepeaterCreate(op) => {
68+
// Upstream indexes the `@empty` view under the same repeater op.
69+
if let Some(empty_view) = op.empty_view {
70+
elements.insert(empty_view, false);
71+
}
72+
(op.xref, false)
73+
}
74+
_ => continue,
75+
};
76+
elements.insert(xref, is_iframe);
77+
}
78+
elements
79+
}
80+
81+
/// Apply upstream's legacy `ɵɵvalidateIframeAttribute` fallback: when a
82+
/// `Property` / `Attribute` / `DomProperty` op got no sanitizer from its
83+
/// security context, a security-sensitive iframe attribute gets the runtime
84+
/// validator. Removed upstream once the schema's `attributeNoBinding` iframe
85+
/// keys covered the same attributes (19.2.17 / 20.3.15 / 21.0.2).
86+
fn resolve_iframe_sanitizer(
87+
assume_iframe: bool,
88+
elements: &FxHashMap<XrefId, bool>,
89+
target: XrefId,
90+
name: &str,
91+
sanitizer: &mut Option<Ident<'_>>,
92+
) {
93+
if sanitizer.is_some() {
94+
return;
95+
}
96+
// For host bindings and `DomProperty` ops the element is not known at
97+
// compile time, so upstream assumes it may be an iframe; the emitted
98+
// validator checks the real tag at runtime.
99+
let is_iframe = if assume_iframe {
100+
true
101+
} else {
102+
*elements
103+
.get(&target)
104+
.unwrap_or_else(|| panic!("Property should have an element-like owner"))
105+
};
106+
if is_iframe && is_iframe_security_sensitive_attr(name) {
107+
*sanitizer = Some(Ident::from(Identifiers::VALIDATE_IFRAME_ATTRIBUTE));
108+
}
109+
}
110+
48111
/// Resolves security sanitizers for property bindings.
49112
///
50113
/// This phase:
51114
/// 1. For ExtractedAttribute ops (constant attributes), sets the trusted value function
52115
/// 2. For Property, Attribute, and DomProperty ops, sets the sanitizer function
53116
pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) {
117+
let iframe_validation = uses_iframe_attr_validation(job.angular_version);
54118
// Collect view xrefs to avoid borrow issues
55119
let view_xrefs: Vec<_> = job.all_views().map(|v| v.xref).collect();
56120

@@ -65,23 +129,52 @@ pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) {
65129
}
66130
}
67131

132+
let elements = iframe_validation.then(|| create_op_xref_map(view.create.iter()));
133+
68134
// Process update ops - set sanitizers for property/attribute bindings
69135
for op in view.update.iter_mut() {
70136
match op {
71137
UpdateOp::Property(prop) => {
72138
if let Some(fn_name) = get_sanitizer_fn(prop.security_context) {
73139
prop.sanitizer = Some(Ident::from(fn_name));
74140
}
141+
if let Some(elements) = &elements {
142+
resolve_iframe_sanitizer(
143+
false,
144+
elements,
145+
prop.target,
146+
prop.name.as_str(),
147+
&mut prop.sanitizer,
148+
);
149+
}
75150
}
76151
UpdateOp::Attribute(attr) => {
77152
if let Some(fn_name) = get_sanitizer_fn(attr.security_context) {
78153
attr.sanitizer = Some(Ident::from(fn_name));
79154
}
155+
if let Some(elements) = &elements {
156+
resolve_iframe_sanitizer(
157+
false,
158+
elements,
159+
attr.target,
160+
attr.name.as_str(),
161+
&mut attr.sanitizer,
162+
);
163+
}
80164
}
81165
UpdateOp::DomProperty(dom_prop) => {
82166
if let Some(fn_name) = get_sanitizer_fn(dom_prop.security_context) {
83167
dom_prop.sanitizer = Some(Ident::from(fn_name));
84168
}
169+
if let Some(elements) = &elements {
170+
resolve_iframe_sanitizer(
171+
true,
172+
elements,
173+
dom_prop.target,
174+
dom_prop.name.as_str(),
175+
&mut dom_prop.sanitizer,
176+
);
177+
}
85178
}
86179
_ => {}
87180
}
@@ -94,6 +187,8 @@ pub fn resolve_sanitizers(job: &mut ComponentCompilationJob<'_>) {
94187
///
95188
/// Host version - only processes the root unit (no embedded views).
96189
pub fn resolve_sanitizers_for_host(job: &mut HostBindingCompilationJob<'_>) {
190+
let iframe_validation = uses_iframe_attr_validation(job.angular_version);
191+
97192
// Process create ops - set trusted value functions for extracted attributes
98193
for op in job.root.create.iter_mut() {
99194
if let CreateOp::ExtractedAttribute(attr) = op {
@@ -105,23 +200,31 @@ pub fn resolve_sanitizers_for_host(job: &mut HostBindingCompilationJob<'_>) {
105200

106201
// Process update ops - set sanitizers for property/attribute bindings
107202
for op in job.root.update.iter_mut() {
108-
match op {
203+
let (name, sanitizer) = match op {
109204
UpdateOp::Property(prop) => {
110205
if let Some(fn_name) = get_sanitizer_fn(prop.security_context) {
111206
prop.sanitizer = Some(Ident::from(fn_name));
112207
}
208+
(prop.name.as_str(), &mut prop.sanitizer)
113209
}
114210
UpdateOp::Attribute(attr) => {
115211
if let Some(fn_name) = get_sanitizer_fn(attr.security_context) {
116212
attr.sanitizer = Some(Ident::from(fn_name));
117213
}
214+
(attr.name.as_str(), &mut attr.sanitizer)
118215
}
119216
UpdateOp::DomProperty(dom_prop) => {
120217
if let Some(fn_name) = get_sanitizer_fn(dom_prop.security_context) {
121218
dom_prop.sanitizer = Some(Ident::from(fn_name));
122219
}
220+
(dom_prop.name.as_str(), &mut dom_prop.sanitizer)
123221
}
124-
_ => {}
222+
_ => continue,
223+
};
224+
// A host job cannot know its host element at compile time, so upstream
225+
// assumes iframe and defers the tag check to the runtime validator.
226+
if iframe_validation && sanitizer.is_none() && is_iframe_security_sensitive_attr(name) {
227+
*sanitizer = Some(Ident::from(Identifiers::VALIDATE_IFRAME_ATTRIBUTE));
125228
}
126229
}
127230
}

crates/oxc_angular_compiler/src/r3/identifiers.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,9 @@ impl Identifiers {
912912
/// Validate attribute.
913913
pub const VALIDATE_ATTRIBUTE: &'static str = "ɵɵvalidateAttribute";
914914

915+
/// Validate iframe attribute.
916+
pub const VALIDATE_IFRAME_ATTRIBUTE: &'static str = "ɵɵvalidateIframeAttribute";
917+
915918
/// Sanitize resource URL.
916919
pub const SANITIZE_RESOURCE_URL: &'static str = "ɵɵsanitizeResourceUrl";
917920

crates/oxc_angular_compiler/tests/integration_test.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ fn compile_template_to_js_with_version(
4141
}
4242

4343
// Stage 2: Transform HTML AST to R3 AST
44-
let transformer = HtmlToR3Transform::new(&allocator, template, TransformOptions::default());
44+
let transformer = HtmlToR3Transform::new(
45+
&allocator,
46+
template,
47+
TransformOptions { angular_version, ..TransformOptions::default() },
48+
);
4549
let r3_result = transformer.transform(&html_result.nodes);
4650

4751
// Check for transform errors
@@ -10337,6 +10341,61 @@ fn test_property_singleton_interpolation_with_sanitizer_angular_v19() {
1033710341
insta::assert_snapshot!("property_singleton_interpolation_with_sanitizer_v19", js);
1033810342
}
1033910343

10344+
#[test]
10345+
fn test_iframe_sensitive_attr_validation_legacy_versions() {
10346+
// Upstream resolve_sanitizers.ts falls back to ɵɵvalidateIframeAttribute for
10347+
// security-sensitive iframe attributes that got no sanitizer. It existed on
10348+
// versions before the iframe attributeNoBinding schema keys (removed in
10349+
// 19.2.17 / 20.3.15 / 21.0.2).
10350+
for version in [AngularVersion::new(19, 0, 0), AngularVersion::new(21, 0, 1)] {
10351+
let js = compile_template_to_js_with_version(
10352+
r#"<iframe [sandbox]="expr"></iframe>"#,
10353+
"TestComponent",
10354+
Some(version),
10355+
);
10356+
assert!(
10357+
js.contains("ɵɵvalidateIframeAttribute"),
10358+
"v{version:?} should emit ɵɵvalidateIframeAttribute for iframe [sandbox]. Got:\n{js}"
10359+
);
10360+
}
10361+
// Once the schema covers iframe|sandbox as attributeNoBinding, the generic
10362+
// ɵɵvalidateAttribute is used instead.
10363+
for version in [AngularVersion::new(19, 2, 17), AngularVersion::new(21, 0, 2)] {
10364+
let js = compile_template_to_js_with_version(
10365+
r#"<iframe [sandbox]="expr"></iframe>"#,
10366+
"TestComponent",
10367+
Some(version),
10368+
);
10369+
assert!(
10370+
js.contains("ɵɵvalidateAttribute"),
10371+
"v{version:?} should emit ɵɵvalidateAttribute for iframe [sandbox]. Got:\n{js}"
10372+
);
10373+
assert!(
10374+
!js.contains("ɵɵvalidateIframeAttribute"),
10375+
"v{version:?} should not emit the legacy iframe validator. Got:\n{js}"
10376+
);
10377+
}
10378+
// The validator only applies to iframes and only to the sensitive attrs.
10379+
let js = compile_template_to_js_with_version(
10380+
r#"<div [sandbox]="expr"></div>"#,
10381+
"TestComponent",
10382+
Some(AngularVersion::new(21, 0, 1)),
10383+
);
10384+
assert!(
10385+
!js.contains("ɵɵvalidateIframeAttribute"),
10386+
"Non-iframe host should not get the iframe validator. Got:\n{js}"
10387+
);
10388+
let js = compile_template_to_js_with_version(
10389+
r#"<iframe [title]="expr"></iframe>"#,
10390+
"TestComponent",
10391+
Some(AngularVersion::new(21, 0, 1)),
10392+
);
10393+
assert!(
10394+
!js.contains("ɵɵvalidateIframeAttribute"),
10395+
"Non-sensitive attribute should not get the iframe validator. Got:\n{js}"
10396+
);
10397+
}
10398+
1034010399
// ============================================================================
1034110400
// Host Directive Alias Tests
1034210401
// ============================================================================

0 commit comments

Comments
 (0)