skip to content

Your Angular app renders user-authored rich-text comments through [innerHTML] and their inline styles vanish; a teammate proposes bypassSecurityTrustHtml. What goes wrong, and what do you do instead?

level: seniorimportance: should knowfreq 40%

answer

  1. who wrote the markup
  2. stored, then shown to everyone
  3. style is off the allow-list
  4. class survives sanitization
  5. sanitize, then trust in one place

basics

~20 s

Bypassing switches off sanitization for attacker-written markup, so one saved comment with an onerror handler runs in every reader's browser: stored XSS. Keep Angular's sanitizer and carry formatting as classes, or bypass only right after a dedicated sanitizer.

solid answer

~40 s

The styles vanish because Angular's HTML sanitizer keeps an allow-list, and `style` is not on it. `bypassSecurityTrustHtml` would bring them back by switching sanitization off for the whole comment, and comments are written by users. One author saving `<img src=x onerror=...>` then gets script running for every reader, a **stored XSS**. Instead, keep binding the raw string to `[innerHTML]` and let Angular sanitize it on every render. Make the editor emit formatting Angular keeps, such as a fixed set of `class` names styled by a global stylesheet, or store a restricted format like markdown and render it to HTML that is still sanitized. If the product truly needs richer HTML, run a dedicated allow-list sanitizer and call the bypass immediately after it, in one small audited function.

code

ts · 15 lines
ts
import {Component, computed, inject, input} from '@angular/core';
import {DomSanitizer} from '@angular/platform-browser';

@Component({
  selector: 'app-comment-body',
  template: `<div class="comment-body" [innerHTML]="body()"></div>`,
})
export class CommentBody {
  private sanitizer = inject(DomSanitizer);
  html = input.required<string>();

  // Anti-pattern: a stored comment containing <img src=x onerror=...>
  // now runs its handler for every reader.
  body = computed(() => this.sanitizer.bypassSecurityTrustHtml(this.html()));
}

go deeper

for a junior

Recall that Angular's sanitizer strips style attributes from [innerHTML] content and that bypassSecurityTrustHtml turns sanitization off.

for a middle

Explain why the allow-list drops style but keeps class, and why a bypass on user-authored markup lets event-handler attributes through.

for a senior

Diagnose the proposal as stored XSS, then offer a fix that keeps a sanitizer on the render path: class-based formatting, a restricted format, or sanitize-then-bypass in one audited function.

for a principal

Decide the content model for user rich text across the product: how much formatting is worth owning a sanitizer configuration, and where trust decisions are allowed to live.

## Why the styles disappear Binding a string to `[innerHTML]` puts it in Angular's **HTML security context**. The built-in sanitizer parses the value into an inert document and re-serializes only **allow-listed** elements and attributes. The attribute allow-list covers things like `class`, `title`, `alt`, `href`, `src` and `aria-*`; it does **not** include `style` or `id`. So `<span style="color:red">` comes out as a bare `<span>`, and in development mode the console shows `WARNING: sanitizing HTML stripped some content`. That is the sanitizer working, not a bug. ## Why bypassSecurityTrustHtml is the wrong fix `DomSanitizer.bypassSecurityTrustHtml(value)` wraps the string in a `SafeHtml`, and the `[innerHTML]` binding then inserts it **without any sanitization**. That is a statement that the markup is known to be safe. For user-authored comments it cannot be: 1. Any author can submit arbitrary markup, whatever the editor's toolbar allows, by calling the API directly. 2. The comment is **stored** and shown to other users, so one malicious comment attacks everyone who opens the page, possibly including administrators. 3. Script does not need a `<script>` tag: an `<img src=x onerror=...>`, an `<a href="javascript:...">` or a `<svg>` event handler is enough, and the bypass keeps all of them. The outcome is **stored cross-site scripting**: the attacker's code runs with the reader's session in the application's origin. ## Better options, in order of preference | Option | Sanitization | What the team gives up | | :--- | :--- | :--- | | Keep Angular's sanitizer, move formatting to allowed `class` names | Angular, on every render | arbitrary inline CSS | | Store a restricted format (markdown, a JSON document model) and render to HTML | Angular, on the rendered output | features the format cannot express | | A dedicated allow-list sanitizer, then an immediate bypass | the library, audited by you | Angular's guarantee; you now own the sanitizer's config | **Option 1** is usually enough. The editor emits `<span class="hl-warning">` instead of a colour picker's inline style, and a global stylesheet defines the classes. `class` survives Angular's sanitizer, so nothing is bypassed. **Option 2** narrows what can be stored at all. The renderer produces HTML from a small grammar, and that HTML is still bound through `[innerHTML]` so Angular sanitizes it as a second layer. **Option 3** is for products that genuinely need more than Angular keeps, such as tables with inline widths or embedded media. The safe shape is: - run a dedicated, configurable allow-list sanitizer over the comment, - call `bypassSecurityTrustHtml` on **its output only**, on the next line, - keep both calls in one small named function that code review watches, - sanitize **at render time**, not only when the comment is saved, because stored data outlives today's write path. ## What the user actually loses It is worth being honest with the product side about the trade. With option 1 authors lose free-form colours and fonts but keep bold, italics, lists, links, quotes, headings and tables, which covers most comment use. With option 2 they may also lose raw HTML paste. With option 3 they keep more, and the team takes on a dependency whose configuration becomes security-critical: every attribute added to its allow-list to satisfy a feature request is a potential new vector, so changes to it deserve the same review as a bypass. ## Things to check in review - **Where** is the bypass called? A generic `safeHtml` pipe used in the comment template means every string reaching that pipe is trusted, now and in the future. - **What** reaches the bypass? If any path skips the sanitizer, such as imported legacy comments or an admin API, the guarantee is gone. - **Direct DOM writes**: the same stripped-style complaint sometimes gets fixed with `nativeElement.innerHTML = body`, which skips Angular's sanitizer just as completely as a bypass. - **Defence in depth**: a Content-Security-Policy and Trusted Types enforcement limit the damage if a bypass slips through, but they are an extra layer, not a reason to trust user HTML. ## How to answer in the interview Name the root cause (the allow-list drops `style`), name the vulnerability class the proposal creates (stored XSS through a bypass on attacker-controlled data), and offer a fix that keeps a sanitizer on the path to the DOM. Interviewers are listening for the realisation that the markup's **author**, not its content today, decides whether a bypass is acceptable.

  • The comments are sanitized on the server when saved. Does that make bypassSecurityTrustHtml on the client acceptable?
    Only if every write path, now and later, goes through that sanitizer and its configuration matches what the browser will parse. Imports, admin tools and old rows break that easily. Keeping Angular's sanitizer on the render path costs little and holds even when a write path is missed, so the bypass still needs a strong reason.
  • Would writing the comment with ElementRef.nativeElement.innerHTML avoid the bypass problem?
    No. Direct DOM writes skip Angular's sanitizer entirely, so it is the same vulnerability without the word bypass in the code, which makes it harder to find in review. If a DOM API write is unavoidable, pass the value through `DomSanitizer.sanitize(SecurityContext.HTML, value)` first.

saying these in an interview costs you the question

  • User comments are safe to trust because our editor only produces formatting.
  • Browsers never run script from innerHTML, so bypassing HTML is harmless.
  • Angular still strips script tags from values wrapped as SafeHtml.
  • Sanitizing once when the comment is saved makes client trust always safe.
  • Setting nativeElement.innerHTML is a safer alternative to bypassSecurityTrustHtml.