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/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, + `