Skip to content

Commit d80213b

Browse files
committed
fix(security): align sanitizer sinks with Angular v22
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.
1 parent b4c89c7 commit d80213b

7 files changed

Lines changed: 875 additions & 302 deletions

File tree

crates/oxc_angular_compiler/src/i18n/extractor_merger.rs

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ use crate::i18n::ast::{
1717
};
1818
use crate::i18n::parser::{I18nMessageFactory, create_i18n_message_factory};
1919
use crate::i18n::translation_bundle::TranslationBundle;
20+
use crate::schema::is_trusted_types_sink;
2021
use crate::util::{ParseSourceFile, ParseSourceSpan};
2122

2223
// ============================================================================
@@ -1083,7 +1084,7 @@ impl<'a> I18nVisitor<'a> {
10831084

10841085
/// Translates attributes for merge mode, handling i18n-* attributes.
10851086
fn translate_attributes_for_merge(
1086-
&self,
1087+
&mut self,
10871088
element_name: &str,
10881089
attrs: &[HtmlAttrRef<'_>],
10891090
) -> Vec<TranslatedAttribute> {
@@ -1095,6 +1096,15 @@ impl<'a> I18nVisitor<'a> {
10951096
for attr in attrs {
10961097
if attr.name.starts_with(I18N_ATTR_PREFIX) {
10971098
let target_name = &attr.name[I18N_ATTR_PREFIX.len()..];
1099+
if is_trusted_types_sink(element_name, target_name) {
1100+
self.report_error(
1101+
attr.span,
1102+
&format!(
1103+
"Translating attribute '{target_name}' is disallowed for security reasons."
1104+
),
1105+
);
1106+
continue;
1107+
}
10981108
explicit_attr_meta.insert(target_name.to_string(), attr.value.to_string());
10991109
}
11001110
}
@@ -1235,10 +1245,20 @@ impl<'a> I18nVisitor<'a> {
12351245
let implicit_attr_names =
12361246
self.implicit_attrs.get(element_name).cloned().unwrap_or_default();
12371247

1238-
// Collect explicit i18n-* attributes
1248+
// Collect explicit i18n-* attributes. Trusted Types sinks are rejected
1249+
// and not extracted (`i18n/meta.ts`).
12391250
for attr in attrs {
12401251
if attr.name.starts_with(I18N_ATTR_PREFIX) {
12411252
let target_name = &attr.name[I18N_ATTR_PREFIX.len()..];
1253+
if is_trusted_types_sink(element_name, target_name) {
1254+
self.report_error(
1255+
attr.span,
1256+
&format!(
1257+
"Translating attribute '{target_name}' is disallowed for security reasons."
1258+
),
1259+
);
1260+
continue;
1261+
}
12421262
explicit_attr_names.insert(target_name.to_string(), attr.value.to_string());
12431263
}
12441264
}
@@ -1794,6 +1814,61 @@ mod tests {
17941814
assert!(result.errors.is_empty());
17951815
}
17961816

1817+
#[test]
1818+
fn test_iframe_src_i18n_is_rejected() {
1819+
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
1820+
let span = Span::default();
1821+
let nodes = vec![HtmlNodeRef::Element {
1822+
name: "iframe",
1823+
attrs: vec![
1824+
HtmlAttrRef {
1825+
name: "i18n-src",
1826+
value: "translated url",
1827+
span,
1828+
is_interpolation_only: false,
1829+
},
1830+
HtmlAttrRef {
1831+
name: "src",
1832+
value: "https://example.com",
1833+
span,
1834+
is_interpolation_only: false,
1835+
},
1836+
],
1837+
children: vec![],
1838+
span,
1839+
start_span: span,
1840+
end_span: None,
1841+
}];
1842+
let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file);
1843+
assert!(result.messages.is_empty());
1844+
assert!(result.errors.iter().any(|err| err.message.contains("disallowed")));
1845+
}
1846+
1847+
#[test]
1848+
fn test_plain_title_i18n_is_still_extracted() {
1849+
let source_file = Arc::new(ParseSourceFile::new("", "<test>"));
1850+
let span = Span::default();
1851+
let nodes = vec![HtmlNodeRef::Element {
1852+
name: "div",
1853+
attrs: vec![
1854+
HtmlAttrRef {
1855+
name: "i18n-title",
1856+
value: "meaning|desc",
1857+
span,
1858+
is_interpolation_only: false,
1859+
},
1860+
HtmlAttrRef { name: "title", value: "Hello", span, is_interpolation_only: false },
1861+
],
1862+
children: vec![],
1863+
span,
1864+
start_span: span,
1865+
end_span: None,
1866+
}];
1867+
let result = extract_messages(&nodes, &[], &FxHashMap::default(), true, source_file);
1868+
assert!(result.errors.is_empty());
1869+
assert!(!result.messages.is_empty());
1870+
}
1871+
17971872
#[test]
17981873
fn test_parse_translated_text_plain() {
17991874
let nodes = parse_translated_text("Hello World", Span::default());

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 14 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4122,66 +4122,29 @@ fn ingest_host_dom_property<'a>(
41224122
name,
41234123
expression,
41244124
unit: property.unit,
4125-
security_context: property.security_context,
4125+
// Host property bindings recompute the context from the selector.
4126+
// `style` / `class` / animation ops are specialized before sanitizers run.
4127+
security_context: match binding_kind {
4128+
BindingKind::Attribute | BindingKind::Property | BindingKind::TwoWayProperty => {
4129+
crate::schema::host_binding_security_context(
4130+
job.component_selector.as_str(),
4131+
name.as_str(),
4132+
)
4133+
}
4134+
_ => SecurityContext::None,
4135+
},
41264136
i18n_message: None,
41274137
is_text_attribute: false,
41284138
});
41294139

41304140
job.root.update.push(op);
41314141
}
41324142

4133-
/// Computes the security context for an attribute binding.
4143+
/// Security context for a static host attribute.
41344144
///
4135-
/// This is a simplified implementation of Angular's `calcPossibleSecurityContexts`
4136-
/// that handles the most common cases based on element and property names.
4137-
///
4138-
/// Ported from Angular's `binding_parser.ts` and `dom_security_schema.ts`.
4145+
/// Same selector rules as host property bindings (`calcPossibleSecurityContexts`).
41394146
fn compute_security_context(selector: &str, attr_name: &str) -> SecurityContext {
4140-
use crate::schema::{calc_security_context_for_unknown_element, get_security_context};
4141-
4142-
// Extract element name from selector if present (e.g., "a[myDirective]" → "a")
4143-
let element = extract_element_from_selector(selector);
4144-
4145-
match element {
4146-
Some(element_name) => {
4147-
// Element is known - use the specific lookup
4148-
get_security_context(&element_name, attr_name)
4149-
}
4150-
None => {
4151-
// Element is unknown (e.g., attribute-only directive like [myDirective])
4152-
// Use the ambiguous lookup that checks all possible elements
4153-
calc_security_context_for_unknown_element(attr_name)
4154-
}
4155-
}
4156-
}
4157-
4158-
/// Extracts the element name from a CSS selector.
4159-
///
4160-
/// Examples:
4161-
/// - "a[myDirective]" → Some("a")
4162-
/// - "div.my-class" → Some("div")
4163-
/// - "[myDirective]" → None
4164-
/// - ".my-class" → None
4165-
fn extract_element_from_selector(selector: &str) -> Option<String> {
4166-
// Skip leading whitespace
4167-
let s = selector.trim();
4168-
4169-
// If starts with [, ., or :, there's no element
4170-
if s.starts_with('[') || s.starts_with('.') || s.starts_with(':') || s.starts_with('#') {
4171-
return None;
4172-
}
4173-
4174-
// Find the element name (alphanumeric and hyphens until a special char)
4175-
let mut element_end = 0;
4176-
for (i, c) in s.char_indices() {
4177-
if c.is_alphanumeric() || c == '-' || c == '_' {
4178-
element_end = i + c.len_utf8();
4179-
} else {
4180-
break;
4181-
}
4182-
}
4183-
4184-
if element_end > 0 { Some(s[..element_end].to_lowercase()) } else { None }
4147+
crate::schema::host_binding_security_context(selector, attr_name)
41854148
}
41864149

41874150
/// Ingests a static host attribute.

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,8 @@ fn get_sanitizer_fn(security_context: SecurityContext) -> Option<&'static str> {
2727
// selects the actual sanitizer at runtime based on the tag name.
2828
SecurityContext::UrlOrResourceUrl => Some(Identifiers::SANITIZE_URL_OR_RESOURCE_URL),
2929
SecurityContext::None => None,
30-
// AttributeNoBinding means the attribute should not be bound at all.
31-
// This should produce a compile-time error in the HTML-to-R3 transform.
32-
// For now, return None but the binding should have been rejected earlier.
33-
SecurityContext::AttributeNoBinding => None,
30+
// `resolve_sanitizers.ts` maps ATTRIBUTE_NO_BINDING to `ɵɵvalidateAttribute`.
31+
SecurityContext::AttributeNoBinding => Some(Identifiers::VALIDATE_ATTRIBUTE),
3432
}
3533
}
3634

0 commit comments

Comments
 (0)