diff --git a/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.spec.ts b/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.spec.ts new file mode 100644 index 00000000..136c40e6 --- /dev/null +++ b/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.spec.ts @@ -0,0 +1,50 @@ +import { SecurityContext } from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { DomSanitizer } from '@angular/platform-browser' +import { PpwSanitizeHtmlPipe } from './sanitize-html.pipe' + +describe('PpwSanitizeHtmlPipe', () => { + let pipe: PpwSanitizeHtmlPipe + let sanitizer: DomSanitizer + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [PpwSanitizeHtmlPipe] + }) + + pipe = TestBed.inject(PpwSanitizeHtmlPipe) + sanitizer = TestBed.inject(DomSanitizer) + }) + + it('sanitizes HTML before trusting it', () => { + const sanitizeSpy = vi.spyOn(sanitizer, 'sanitize') + const bypassSpy = vi.spyOn(sanitizer, 'bypassSecurityTrustHtml') + const html = '

safe

' + + pipe.transform(html) + + expect(sanitizeSpy).toHaveBeenCalledWith(SecurityContext.HTML, html) + expect(bypassSpy).toHaveBeenCalledWith('

safe

') + }) + + it('strips unsafe HTML content from the result', () => { + const html = '

safe

' + + const result = pipe.transform(html) + + expect(result).toEqual({ + changingThisBreaksApplicationSecurity: '

safe

' + }) + }) + + it('returns trusted empty HTML for null input', () => { + const bypassSpy = vi.spyOn(sanitizer, 'bypassSecurityTrustHtml') + + const result = pipe.transform(null) + + expect(bypassSpy).toHaveBeenCalledWith('') + expect(result).toEqual({ + changingThisBreaksApplicationSecurity: '' + }) + }) +}) diff --git a/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.ts b/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.ts new file mode 100644 index 00000000..5f5ec20c --- /dev/null +++ b/projects/ppwcode/ng-common/src/lib/pipes/sanitize-html.pipe.ts @@ -0,0 +1,34 @@ +import { inject, Pipe, PipeTransform, SecurityContext } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' + +/** + * Sanitizes an HTML string with Angular's built-in HTML sanitizer and returns the + * sanitized result as `SafeHtml`. + * + * The pipe first removes unsafe markup such as scripts and dangerous attributes + * by calling `DomSanitizer.sanitize(SecurityContext.HTML, value)`. It then wraps + * the sanitized output with `bypassSecurityTrustHtml` so Angular can bind the + * result to `[innerHTML]` without sanitizing it a second time. + * + * Use this pipe when the source value may contain user-provided or otherwise + * untrusted HTML and you want to render the remaining safe markup in the view. + * Typical usage is: + * + * `
` + * + * Do not use this pipe to preserve unsafe markup. Any content removed by the + * sanitizer is intentionally discarded before the result is marked as trusted. + */ +@Pipe({ + name: 'ppwSanitizeHtml', + standalone: true +}) +export class PpwSanitizeHtmlPipe implements PipeTransform { + private readonly sanitizer = inject(DomSanitizer) + + /** Returns trusted HTML that has already been sanitized for safe rendering. */ + transform(value: string | null): SafeHtml { + const sanitizedValue = this.sanitizer.sanitize(SecurityContext.HTML, value) ?? '' + return this.sanitizer.bypassSecurityTrustHtml(sanitizedValue) + } +} diff --git a/projects/ppwcode/ng-common/src/public-api.ts b/projects/ppwcode/ng-common/src/public-api.ts index d4b17482..d0711c5e 100644 --- a/projects/ppwcode/ng-common/src/public-api.ts +++ b/projects/ppwcode/ng-common/src/public-api.ts @@ -14,6 +14,7 @@ export * from './lib/mixins/handle-subscriptions' export * from './lib/mixins/responsive-observers' export * from './lib/mixins/track-pending' export * from './lib/pipes/api-translate.pipe' +export * from './lib/pipes/sanitize-html.pipe' export * from './lib/rxjs-operators/truthy-first' export * from './lib/rxjs-operators/truthy-filter' export * from './lib/storage/local-storage'