Skip to content

Commit c293699

Browse files
author
Aniket Bansal
committed
Merge tag 'v20.3.18' into main-20.3.18
2 parents d46da01 + cef3164 commit c293699

9 files changed

Lines changed: 250 additions & 33 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,42 @@
1+
<a name="20.3.18"></a>
2+
3+
# 20.3.18 (2026-03-12)
4+
5+
### compiler
6+
7+
| Commit | Type | Description |
8+
| ------------------------------------------------------------------------------------------------ | ---- | ----------------------------------- |
9+
| [02fbf08890](https://github.com/angular/angular/commit/02fbf08890ec6ac2efb6c2ec4f17e56497cb81d2) | fix | disallow translations of iframe src |
10+
11+
### core
12+
13+
| Commit | Type | Description |
14+
| ------------------------------------------------------------------------------------------------ | ---- | ---------------------------------------------------------- |
15+
| [72126f9a08](https://github.com/angular/angular/commit/72126f9a08c185a9b93461bab67841c4e84c9b17) | fix | sanitize translated attribute bindings with interpolations |
16+
| [626bc8bc20](https://github.com/angular/angular/commit/626bc8bc20e485cad2094c4a5d9417fb9a71dda8) | fix | sanitize translated form attributes |
17+
18+
<!-- CHANGELOG SPLIT MARKER -->
19+
20+
<a name="20.3.17"></a>
21+
22+
# 20.3.17 (2026-02-25)
23+
24+
## Breaking Changes
25+
26+
### core
27+
28+
- Angular now only applies known attributes from HTML in translated ICU content. Unknown attributes are dropped and not rendered.
29+
30+
(cherry picked from commit 03da204b6daa5e4583e0d0968c2107390bbd8235)
31+
32+
### core
33+
34+
| Commit | Type | Description |
35+
| ------------------------------------------------------------------------------------------------ | ---- | ------------------------------------------------------------ |
36+
| [7f9de3c118](https://github.com/angular/angular/commit/7f9de3c118383c09fa8851708c66ec94453a9680) | fix | block creation of sensitive URI attributes from ICU messages |
37+
38+
<!-- CHANGELOG SPLIT MARKER -->
39+
140
<a name="20.3.16"></a>
241

342
# 20.3.16 (2026-01-07)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "angular-srcs",
3-
"version": "20.3.16",
3+
"version": "20.3.18",
44
"private": true,
55
"description": "Angular - a web framework for modern web apps",
66
"homepage": "https://github.com/angular/angular",

packages/compiler/src/schema/trusted_types_sinks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* tags use '*'.
1212
*
1313
* Extracted from, and should be kept in sync with
14-
* https://w3c.github.io/webappsec-trusted-types/dist/spec/#integrations
14+
* https://www.w3.org/TR/trusted-types/#integrations
1515
*/
1616
const TRUSTED_TYPES_SINKS = new Set<string>([
1717
// NOTE: All strings in this set *must* be lowercase!
@@ -25,6 +25,7 @@ const TRUSTED_TYPES_SINKS = new Set<string>([
2525

2626
// TrustedScriptURL
2727
'embed|src',
28+
'iframe|src',
2829
'object|codebase',
2930
'object|data',
3031
]);

packages/compiler/test/schema/trusted_types_sinks_spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ describe('isTrustedTypesSink', () => {
1313
expect(isTrustedTypesSink('iframe', 'srcdoc')).toBeTrue();
1414
expect(isTrustedTypesSink('p', 'innerHTML')).toBeTrue();
1515
expect(isTrustedTypesSink('embed', 'src')).toBeTrue();
16+
expect(isTrustedTypesSink('iframe', 'src')).toBeTrue();
1617
expect(isTrustedTypesSink('a', 'href')).toBeFalse();
1718
expect(isTrustedTypesSink('base', 'href')).toBeFalse();
1819
expect(isTrustedTypesSink('div', 'style')).toBeFalse();

packages/core/src/render3/i18n/i18n_parse.ts

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import '../../util/ng_i18n_closure_mode';
1111
import {XSS_SECURITY_URL} from '../../error_details_base_url';
1212
import {
1313
getTemplateContent,
14-
URI_ATTRS,
14+
SENSITIVE_ATTRS,
1515
VALID_ATTRS,
1616
VALID_ELEMENTS,
1717
} from '../../sanitization/html_sanitizer';
@@ -388,7 +388,7 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str
388388
previousElementIndex,
389389
attrName,
390390
countBindings(updateOpCodes),
391-
null,
391+
SENSITIVE_ATTRS[attrName.toLowerCase()] ? _sanitizeUrl : null,
392392
);
393393
}
394394
}
@@ -808,21 +808,16 @@ function walkIcuTree(
808808
const attr = elAttrs.item(i)!;
809809
const lowerAttrName = attr.name.toLowerCase();
810810
const hasBinding = !!attr.value.match(BINDING_REGEXP);
811-
// we assume the input string is safe, unless it's using a binding
812811
if (hasBinding) {
813812
if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) {
814-
if (URI_ATTRS[lowerAttrName]) {
815-
generateBindingUpdateOpCodes(
816-
update,
817-
attr.value,
818-
newIndex,
819-
attr.name,
820-
0,
821-
_sanitizeUrl,
822-
);
823-
} else {
824-
generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name, 0, null);
825-
}
813+
generateBindingUpdateOpCodes(
814+
update,
815+
attr.value,
816+
newIndex,
817+
attr.name,
818+
0,
819+
SENSITIVE_ATTRS[lowerAttrName] ? _sanitizeUrl : null,
820+
);
826821
} else {
827822
ngDevMode &&
828823
console.warn(
@@ -831,8 +826,29 @@ function walkIcuTree(
831826
`(see ${XSS_SECURITY_URL})`,
832827
);
833828
}
829+
} else if (VALID_ATTRS[lowerAttrName]) {
830+
if (SENSITIVE_ATTRS[lowerAttrName]) {
831+
// Don't sanitize, because no value is acceptable in sensitive attributes.
832+
// Translators are not allowed to create URIs.
833+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
834+
console.warn(
835+
`WARNING: ignoring unsafe attribute ` +
836+
`${lowerAttrName} on element ${tagName} ` +
837+
`(see ${XSS_SECURITY_URL})`,
838+
);
839+
}
840+
addCreateAttribute(create, newIndex, attr.name, 'unsafe:blocked');
841+
} else {
842+
addCreateAttribute(create, newIndex, attr.name, attr.value);
843+
}
834844
} else {
835-
addCreateAttribute(create, newIndex, attr);
845+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
846+
console.warn(
847+
`WARNING: ignoring unknown attribute name ` +
848+
`${lowerAttrName} on element ${tagName} ` +
849+
`(see ${XSS_SECURITY_URL})`,
850+
);
851+
}
836852
}
837853
}
838854
const elementNode: I18nElementNode = {
@@ -945,10 +961,11 @@ function addCreateNodeAndAppend(
945961
);
946962
}
947963

948-
function addCreateAttribute(create: IcuCreateOpCodes, newIndex: number, attr: Attr) {
949-
create.push(
950-
(newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr,
951-
attr.name,
952-
attr.value,
953-
);
964+
function addCreateAttribute(
965+
create: IcuCreateOpCodes,
966+
newIndex: number,
967+
attrName: string,
968+
attrValue: string,
969+
) {
970+
create.push((newIndex << IcuCreateOpCode.SHIFT_REF) | IcuCreateOpCode.Attr, attrName, attrValue);
954971
}

packages/core/src/sanitization/html_sanitizer.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ import {trustedHTMLFromString} from '../util/security/trusted_types';
1313
import {getInertBodyHelper, InertBodyHelper} from './inert_body';
1414
import {_sanitizeUrl} from './url_sanitizer';
1515

16-
function tagSet(tags: string): {[k: string]: boolean} {
17-
const res: {[k: string]: boolean} = {};
16+
type BooleanRecord = Record<string, boolean>;
17+
18+
function tagSet(tags: string): BooleanRecord {
19+
const res: BooleanRecord = {};
1820
for (const t of tags.split(',')) res[t] = true;
1921
return res;
2022
}
2123

22-
function merge(...sets: {[k: string]: boolean}[]): {[k: string]: boolean} {
23-
const res: {[k: string]: boolean} = {};
24+
function merge(...sets: BooleanRecord[]): BooleanRecord {
25+
const res: BooleanRecord = {};
2426
for (const s of sets) {
2527
for (const v in s) {
2628
if (s.hasOwnProperty(v)) res[v] = true;
@@ -66,15 +68,15 @@ const INLINE_ELEMENTS = merge(
6668
),
6769
);
6870

69-
export const VALID_ELEMENTS: {[k: string]: boolean} = merge(
71+
export const VALID_ELEMENTS: BooleanRecord = merge(
7072
VOID_ELEMENTS,
7173
BLOCK_ELEMENTS,
7274
INLINE_ELEMENTS,
7375
OPTIONAL_END_TAG_ELEMENTS,
7476
);
7577

7678
// Attributes that have href and hence need to be sanitized
77-
export const URI_ATTRS: {[k: string]: boolean} = tagSet(
79+
const URI_ATTRS: BooleanRecord = tagSet(
7880
'background,cite,href,itemtype,longdesc,poster,src,xlink:href',
7981
);
8082

@@ -105,7 +107,7 @@ const ARIA_ATTRS = tagSet(
105107
// can be sanitized, but they increase security surface area without a legitimate use case, so they
106108
// are left out here.
107109

108-
export const VALID_ATTRS: {[k: string]: boolean} = merge(URI_ATTRS, HTML_ATTRS, ARIA_ATTRS);
110+
export const VALID_ATTRS: BooleanRecord = merge(URI_ATTRS, HTML_ATTRS, ARIA_ATTRS);
109111

110112
// Elements whose content should not be traversed/preserved, if the elements themselves are invalid.
111113
//
@@ -114,6 +116,16 @@ export const VALID_ATTRS: {[k: string]: boolean} = merge(URI_ATTRS, HTML_ATTRS,
114116
// don't want to preserve the content, if the elements themselves are going to be removed.
115117
const SKIP_TRAVERSING_CONTENT_IF_INVALID_ELEMENTS = tagSet('script,style,template');
116118

119+
/**
120+
* Attributes that are potential attach vectors and may need to be sanitized.
121+
*/
122+
export const SENSITIVE_ATTRS: BooleanRecord = merge(
123+
URI_ATTRS,
124+
// Note: we don't include these attributes in `URI_ATTRS`, because `URI_ATTRS` also
125+
// determines whether an attribute should be dropped when sanitizing an HTML string.
126+
tagSet('action,formaction,data,codebase'),
127+
);
128+
117129
/**
118130
* SanitizingHtmlSerializer serializes a DOM fragment, stripping out any unsafe elements and unsafe
119131
* attributes.

packages/core/test/acceptance/i18n_spec.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {CommonModule, DOCUMENT, registerLocaleData} from '@angular/common';
1313
import localeEs from '@angular/common/locales/es';
1414
import localeRo from '@angular/common/locales/ro';
1515
import {computeMsgId} from '@angular/compiler';
16+
import {isBrowser} from '@angular/private/testing';
1617
import {
1718
Attribute,
1819
Component,
@@ -3506,7 +3507,7 @@ describe('runtime i18n', () => {
35063507
<div i18n>{
35073508
parameters.length,
35083509
plural,
3509-
=1 {Affects parameter <span class="parameter-name" attr="should_be_present">{{parameters[0].name}}</span>}
3510+
=1 {Affects parameter <span class="parameter-name" label="should_be_present">{{parameters[0].name}}</span>}
35103511
other {Affects {{parameters.length}} parameters, including <span
35113512
class="parameter-name">{{parameters[0].name}}</span>}
35123513
}</div>
@@ -3521,7 +3522,7 @@ describe('runtime i18n', () => {
35213522
const fixture = TestBed.createComponent(MyApp);
35223523
fixture.detectChanges();
35233524
const span = (fixture.nativeElement as HTMLElement).querySelector('span')!;
3524-
expect(span.getAttribute('attr')).toEqual('should_be_present');
3525+
expect(span.getAttribute('label')).toEqual('should_be_present');
35253526
expect(span.getAttribute('class')).toEqual('parameter-name');
35263527
});
35273528

@@ -3607,6 +3608,92 @@ describe('runtime i18n', () => {
36073608
'translatedText value',
36083609
);
36093610
});
3611+
3612+
describe('attribute sanitization', () => {
3613+
@Component({template: ''})
3614+
class SanitizeAppComp {
3615+
url = 'javascript:alert("oh no")';
3616+
count = 0;
3617+
}
3618+
3619+
it('should sanitize translated attribute binding', () => {
3620+
const fixture = initWithTemplate(SanitizeAppComp, '<a [attr.href]="url" i18n-href></a>');
3621+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3622+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3623+
});
3624+
3625+
it('should sanitize translated property binding', () => {
3626+
const fixture = initWithTemplate(SanitizeAppComp, '<a [href]="url" i18n-href></a>');
3627+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3628+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3629+
});
3630+
3631+
it('should sanitize translated interpolation', () => {
3632+
const fixture = initWithTemplate(SanitizeAppComp, '<a href="{{url}}" i18n-href></a>');
3633+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3634+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3635+
});
3636+
3637+
it('should sanitize interpolation inside translated element', () => {
3638+
const fixture = initWithTemplate(SanitizeAppComp, `<div i18n><a href="{{url}}"></a></div>`);
3639+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3640+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3641+
});
3642+
3643+
it('should sanitize attribute binding inside translated element', () => {
3644+
const fixture = initWithTemplate(
3645+
SanitizeAppComp,
3646+
`<div i18n><a [attr.href]="url"></a></div>`,
3647+
);
3648+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3649+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3650+
});
3651+
3652+
it('should sanitize property binding inside translated element', () => {
3653+
const fixture = initWithTemplate(SanitizeAppComp, `<div i18n><a [href]="url"></a></div>`);
3654+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3655+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3656+
});
3657+
3658+
it('should sanitize property binding inside an ICU', () => {
3659+
const fixture = initWithTemplate(
3660+
SanitizeAppComp,
3661+
`<div i18n>{count, plural,
3662+
=0 {no <strong>link</strong> yet}
3663+
other {{{count}} Here is the <a href="{{url}}">link</a>!}
3664+
}</div>`,
3665+
);
3666+
3667+
expect(fixture.nativeElement.querySelector('a')).toBeFalsy();
3668+
3669+
fixture.componentInstance.count = 1;
3670+
fixture.detectChanges();
3671+
const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a');
3672+
expect(link).toBeTruthy();
3673+
expect(link.getAttribute('href')).toMatch(/^unsafe:/);
3674+
});
3675+
3676+
it('should sanitize action binding', () => {
3677+
const fixture = initWithTemplate(
3678+
SanitizeAppComp,
3679+
'<form action="{{url}}" i18n-action></form>',
3680+
);
3681+
const form: HTMLFormElement = fixture.nativeElement.querySelector('form');
3682+
expect(form.getAttribute('action')).toMatch(/^unsafe:/);
3683+
});
3684+
3685+
// Skip this test in Node, because Domino doesn't support `formAction`.
3686+
if (isBrowser) {
3687+
it('should sanitize formaction binding', () => {
3688+
const fixture = initWithTemplate(
3689+
SanitizeAppComp,
3690+
'<input type="text" formaction="{{url}}" i18n-formaction>',
3691+
);
3692+
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
3693+
expect(input.getAttribute('formaction')).toMatch(/^unsafe:/);
3694+
});
3695+
}
3696+
});
36103697
});
36113698

36123699
function initWithTemplate(compType: Type<any>, template: string) {

packages/core/test/acceptance/security_spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,3 +768,19 @@ describe('SVG animation processing', () => {
768768
);
769769
});
770770
});
771+
772+
describe('innerHTML processing', () => {
773+
it('should drop risky attributes from elements created with innerHTML', () => {
774+
@Component({
775+
template: '<div [innerHTML]="html"></div>',
776+
})
777+
class App {
778+
html = '<div action="abc"></div>';
779+
}
780+
781+
const fixture = TestBed.createComponent(App);
782+
fixture.detectChanges();
783+
784+
expect(fixture.nativeElement.innerHTML).not.toContain('action');
785+
});
786+
});

0 commit comments

Comments
 (0)