From a357091bad288f93d43fd2bf5b8316bba4954a2c Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 10 Mar 2026 10:22:47 +0100 Subject: [PATCH 1/2] build: include localize in dev app Includes `$localize` in the dev app so it's easier to test some code paths. --- dev-app/angular.json | 1 + dev-app/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+) diff --git a/dev-app/angular.json b/dev-app/angular.json index 7447a8105f3e..7f2cac9d90e3 100644 --- a/dev-app/angular.json +++ b/dev-app/angular.json @@ -22,6 +22,7 @@ "externalDependencies": ["xhr2"], "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", + "polyfills": ["@angular/localize/init"], "assets": [ { "glob": "**/*", diff --git a/dev-app/package.json b/dev-app/package.json index 9ef24e7fc6e2..8e1ea0f1cc4f 100644 --- a/dev-app/package.json +++ b/dev-app/package.json @@ -13,6 +13,7 @@ "@angular/forms": "workspace:*", "@angular/platform-browser": "workspace:*", "@angular/platform-server": "workspace:*", + "@angular/localize": "workspace:*", "@angular/router": "workspace:*", "@angular/ssr": "22.0.0-next.0", "rxjs": "~7.8.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f127ce61a003..5b5f9c63ed4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -829,6 +829,9 @@ importers: '@angular/forms': specifier: workspace:* version: link:../packages/forms + '@angular/localize': + specifier: workspace:* + version: link:../packages/localize '@angular/platform-browser': specifier: workspace:* version: link:../packages/platform-browser From 1d5744f026949cc44af00f7fe7715a098da8e809 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 10 Mar 2026 11:24:07 +0100 Subject: [PATCH 2/2] fix(core): sanitize translated attribute bindings with interpolations Fixes that we weren't sanitizing attribute bindings with interpolations if they're marked for translation, for example: ``. Also adds a bit more test coverage for our sanitization. --- packages/core/src/render3/i18n/i18n_parse.ts | 22 +++---- packages/core/test/acceptance/i18n_spec.ts | 65 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/packages/core/src/render3/i18n/i18n_parse.ts b/packages/core/src/render3/i18n/i18n_parse.ts index b31aad7e2a93..d2ebb0b92bb9 100644 --- a/packages/core/src/render3/i18n/i18n_parse.ts +++ b/packages/core/src/render3/i18n/i18n_parse.ts @@ -388,7 +388,7 @@ export function i18nAttributesFirstPass(tView: TView, index: number, values: str previousElementIndex, attrName, countBindings(updateOpCodes), - null, + URI_ATTRS[attrName.toLowerCase()] ? _sanitizeUrl : null, ); } } @@ -810,18 +810,14 @@ function walkIcuTree( const hasBinding = !!attr.value.match(BINDING_REGEXP); if (hasBinding) { if (VALID_ATTRS.hasOwnProperty(lowerAttrName)) { - if (URI_ATTRS[lowerAttrName]) { - generateBindingUpdateOpCodes( - update, - attr.value, - newIndex, - attr.name, - 0, - _sanitizeUrl, - ); - } else { - generateBindingUpdateOpCodes(update, attr.value, newIndex, attr.name, 0, null); - } + generateBindingUpdateOpCodes( + update, + attr.value, + newIndex, + attr.name, + 0, + URI_ATTRS[lowerAttrName] ? _sanitizeUrl : null, + ); } else { ngDevMode && console.warn( diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index e7f8aaab19d3..ff9583b31d1d 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -3534,6 +3534,71 @@ describe('runtime i18n', () => { 'translatedText value', ); }); + + describe('attribute sanitization', () => { + @Component({template: ''}) + class SanitizeAppComp { + url = 'javascript:alert("oh no")'; + count = 0; + } + + it('should sanitize translated attribute binding', () => { + const fixture = initWithTemplate(SanitizeAppComp, ''); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize translated property binding', () => { + const fixture = initWithTemplate(SanitizeAppComp, ''); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize translated interpolation', () => { + const fixture = initWithTemplate(SanitizeAppComp, ''); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize interpolation inside translated element', () => { + const fixture = initWithTemplate(SanitizeAppComp, `
`); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize attribute binding inside translated element', () => { + const fixture = initWithTemplate( + SanitizeAppComp, + `
`, + ); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize property binding inside translated element', () => { + const fixture = initWithTemplate(SanitizeAppComp, `
`); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + + it('should sanitize property binding inside an ICU', () => { + const fixture = initWithTemplate( + SanitizeAppComp, + `
{count, plural, + =0 {no link yet} + other {{{count}} Here is the link!} + }
`, + ); + + expect(fixture.nativeElement.querySelector('a')).toBeFalsy(); + + fixture.componentInstance.count = 1; + fixture.detectChanges(); + const link: HTMLAnchorElement = fixture.nativeElement.querySelector('a'); + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toMatch(/^unsafe:/); + }); + }); }); function initWithTemplate(compType: Type, template: string) {