Skip to content

Commit d19273c

Browse files
mrholekxidedix
authored andcommitted
fix(Icon): escape title to prevent XSS via bypassSecurityTrustHtml
The a11y `title` (documented as a user-facing label) was interpolated unescaped into <title> and passed to bypassSecurityTrustHtml, so a value like </title><img onerror=...> was injected into the DOM. HTML-escape the title before building the <title> element.
1 parent 6d87a30 commit d19273c

4 files changed

Lines changed: 55 additions & 4 deletions

File tree

projects/coreui-icons-angular/src/lib/icon/icon.component.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { DomSanitizer } from '@angular/platform-browser';
55
import { HtmlAttributesDirective } from '../shared/html-attr.directive';
66
import { IconSetService } from '../icon-set';
77
import { IconSize, IIcon, NgCssClass } from './icon.interface';
8-
import { transformName } from './icon.utils';
8+
import { escapeHtml, transformName } from './icon.utils';
99

1010
@Component({
1111
exportAs: 'cIconComponent',
@@ -62,7 +62,8 @@ export class IconComponent implements IIcon {
6262
});
6363

6464
readonly #titleCode = computed(() => {
65-
return this.title() ? `<title>${this.title()}</title>` : '';
65+
const title = this.title();
66+
return title ? `<title>${escapeHtml(title)}</title>` : '';
6667
});
6768

6869
readonly code = computed(() => {

projects/coreui-icons-angular/src/lib/icon/icon.directive.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,40 @@ describe('IconDirective', () => {
7171
expect(svgEl.nativeElement.getAttribute('xmlns')).toBe('http://www.w3.org/2000/svg');
7272
});
7373
});
74+
75+
@Component({
76+
template: '<svg [cIcon]="this.iconSet.icons.cilList" [title]="title"></svg>',
77+
imports: [IconDirective],
78+
providers: [IconSetService]
79+
})
80+
class TitleXssTestComponent {
81+
iconSet = inject(IconSetService);
82+
title = '</title><img src=x onerror="window.__xss=1">';
83+
84+
constructor() {
85+
this.iconSet.icons = { cilList };
86+
}
87+
}
88+
89+
describe('IconDirective title XSS', () => {
90+
let fixture: ComponentFixture<TitleXssTestComponent>;
91+
let svgEl: DebugElement;
92+
93+
beforeEach(() => {
94+
TestBed.configureTestingModule({
95+
providers: [IconSetService],
96+
imports: [IconDirective, TitleXssTestComponent]
97+
}).compileComponents();
98+
99+
fixture = TestBed.createComponent(TitleXssTestComponent);
100+
fixture.detectChanges();
101+
svgEl = fixture.debugElement.query(By.css('svg'));
102+
});
103+
104+
it('escapes the title and does not inject markup', () => {
105+
expect(svgEl.nativeElement.querySelector('img')).toBeNull();
106+
const title = svgEl.nativeElement.querySelector('title');
107+
expect(title).toBeTruthy();
108+
expect(title.textContent).toBe('</title><img src=x onerror="window.__xss=1">');
109+
});
110+
});

projects/coreui-icons-angular/src/lib/icon/icon.directive.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { DomSanitizer } from '@angular/platform-browser';
33

44
import { IconSetService } from '../icon-set';
55
import { IconSize, IIcon, IPointerEvents, NgCssClass } from './icon.interface';
6-
import { transformName } from './icon.utils';
6+
import { escapeHtml, transformName } from './icon.utils';
77

88
@Directive({
99
exportAs: 'cIcon',
@@ -57,7 +57,8 @@ export class IconDirective implements IIcon {
5757
});
5858

5959
readonly #titleCode = computed(() => {
60-
return this.title() ? `<title>${this.title()}</title>` : '';
60+
const title = this.title();
61+
return title ? `<title>${escapeHtml(title)}</title>` : '';
6162
});
6263

6364
readonly code = computed(() => {

projects/coreui-icons-angular/src/lib/icon/icon.utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,15 @@ export function toCamelCase(value: string) {
77
export function transformName(value: string) {
88
return value && value.includes('-') ? toCamelCase(value) : value;
99
}
10+
11+
const ESCAPE_HTML_MAP: Record<string, string> = {
12+
'&': '&amp;',
13+
'<': '&lt;',
14+
'>': '&gt;',
15+
'"': '&quot;',
16+
"'": '&#x27;'
17+
};
18+
19+
export function escapeHtml(value: string): string {
20+
return String(value).replace(/[&<>"']/g, (character) => ESCAPE_HTML_MAP[character]);
21+
}

0 commit comments

Comments
 (0)