Skip to content

Commit e0b94c6

Browse files
nabramovitznorman-abramovitz
authored andcommitted
Render tooltip text via textContent, not innerHTML
Tooltip text can carry untrusted values (e.g. CF usernames) and Renderer2.setProperty(innerHTML) bypasses Angular's sanitizer. No tooltip relies on HTML markup, so this is behaviour-neutral.
1 parent 98edbe1 commit e0b94c6

2 files changed

Lines changed: 52 additions & 1 deletion

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Component, provideZonelessChangeDetection } from '@angular/core';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4+
import { By } from '@angular/platform-browser';
5+
import { CustomTooltipDirective } from './custom-tooltip.directive';
6+
7+
@Component({
8+
imports: [CustomTooltipDirective],
9+
template: `<button matTooltip="placeholder">host</button>`
10+
})
11+
class TestHostComponent { }
12+
13+
describe('CustomTooltipDirective', () => {
14+
let fixture: ComponentFixture<TestHostComponent>;
15+
let directive: CustomTooltipDirective;
16+
17+
beforeEach(() => {
18+
vi.useFakeTimers();
19+
TestBed.configureTestingModule({
20+
providers: [provideZonelessChangeDetection()],
21+
imports: [TestHostComponent],
22+
}).compileComponents();
23+
fixture = TestBed.createComponent(TestHostComponent);
24+
fixture.detectChanges();
25+
const el = fixture.debugElement.query(By.directive(CustomTooltipDirective));
26+
directive = el.injector.get(CustomTooltipDirective);
27+
});
28+
29+
afterEach(() => {
30+
vi.clearAllTimers();
31+
vi.useRealTimers();
32+
document.querySelectorAll('.custom-tooltip').forEach(n => n.remove());
33+
});
34+
35+
// Tooltip text can carry untrusted values (e.g. CF usernames). The directive
36+
// must render it as text, never parse it as HTML.
37+
it('renders tooltip text as plain text, not parsed HTML (XSS-safe)', () => {
38+
const payload = '<img src=x onerror="alert(1)">alice';
39+
directive.tooltipText = payload;
40+
directive.onMouseEnter(new MouseEvent('mouseenter'));
41+
vi.advanceTimersByTime(300);
42+
43+
const tip = document.body.querySelector('.custom-tooltip');
44+
expect(tip).toBeTruthy();
45+
expect(tip?.textContent).toBe(payload);
46+
expect(tip?.querySelector('img')).toBeNull();
47+
});
48+
});

src/frontend/packages/core/src/shared/components/custom-tooltip/custom-tooltip.directive.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ export class CustomTooltipDirective implements OnDestroy {
4848
if (this.tooltipClass) {
4949
this.renderer.addClass(el, this.tooltipClass);
5050
}
51-
this.renderer.setProperty(el, 'innerHTML', this.tooltipText);
51+
// Use textContent, not innerHTML: tooltip text can carry untrusted values
52+
// (e.g. CF usernames) and Renderer2.setProperty(innerHTML) bypasses Angular's
53+
// sanitizer. No tooltip relies on HTML markup, so this is a behaviour-neutral fix.
54+
this.renderer.setProperty(el, 'textContent', this.tooltipText);
5255

5356
// Apply visual styles BEFORE positioning so getBoundingClientRect()
5457
// measures the content-sized box. A bare `<div>` with no styling is

0 commit comments

Comments
 (0)