skip to content

A security review flags an Angular directive that writes an input into el.nativeElement.innerHTML; why is that exploitable when [innerHTML] was not, and how do you fix it?

level: seniorimportance: should knowfreq 36%

answer

  1. sanitizer lives in compiled bindings
  2. nativeElement is the raw DOM node
  3. Renderer2 does not sanitize either
  4. host binding or explicit sanitize
  5. SecurityContext.HTML

basics

~20 s

Angular's sanitizer runs inside compiled template and host bindings; ElementRef.nativeElement is the raw DOM node, so assigning innerHTML goes straight to the browser. Fix it with a host [innerHTML] binding, or run DomSanitizer.sanitize(SecurityContext.HTML, value) before writing.

solid answer

~40 s

When the compiler sees `[innerHTML]` in a template or a directive's `host` metadata, it emits the property instruction together with the HTML sanitizer, so the value is cleaned on every update. `ElementRef.nativeElement` is just the underlying DOM element, and assigning its `innerHTML` is a plain browser call that Angular never sees; `Renderer2.setProperty` is no better, since it only sets `el[name] = value`. So an input carrying `<img src=x onerror=...>` executes. The preferred fix is to express the write as a binding, `host: {'[innerHTML]': 'snippet()'}`, which the compiler sanitizes. If a DOM API is unavoidable, call `inject(DomSanitizer).sanitize(SecurityContext.HTML, value)` and write its result, or use `textContent` when the value is only text. Trusted Types enforcement then makes any remaining raw write fail loudly.

code

ts · 11 lines
ts
import {Directive, input} from '@angular/core';

@Directive({
  selector: '[appSnippet]',
  host: {'[innerHTML]': 'appSnippet()'},
})
export class Snippet {
  // Compiled as a host property binding, so Angular sanitizes it
  // in the HTML context on every change.
  appSnippet = input.required<string>();
}

go deeper

for a junior

Recall that ElementRef.nativeElement is the raw DOM element and that writing its innerHTML skips Angular's sanitizer.

for a middle

Explain that sanitization is compiled into template and host bindings, which is why Renderer2 and DOM APIs do not get it.

for a senior

Fix the directive with a host [innerHTML] binding or DomSanitizer.sanitize in the HTML context, then sweep the codebase for similar sinks and add Trusted Types as a backstop.

for a principal

Set a policy for direct DOM access, such as lint rules on nativeElement sinks and review ownership, and weigh Trusted Types rollout against third-party library compatibility.

## Where Angular's sanitization actually lives Angular does not patch the DOM. Its XSS protection is attached to **compiled bindings**: when the template compiler meets a binding such as `[innerHTML]`, it looks up the element and property in its security schema and emits the property instruction together with a sanitizer function. At runtime that instruction sanitizes the value before assigning it. Host bindings declared in a directive's `host` metadata go through the same compiler step, so `host: {'[innerHTML]': 'value'}` is sanitized exactly like a template binding. Anything that does not go through a compiled binding is invisible to that mechanism. ## Why nativeElement.innerHTML is exploitable `ElementRef` is a thin wrapper, and its `nativeElement` property is the real DOM element. Assigning `nativeElement.innerHTML = value` is an ordinary browser call: 1. Angular's binding instructions are not involved, so no sanitizer runs. 2. The browser parses the string as markup and creates whatever it describes. 3. A value such as `<img src=x onerror="fetch('/api/me').then(...)">` executes its handler in the application's origin. The `ElementRef` API documentation carries a security note saying that direct DOM access makes an application more vulnerable to XSS and that every use should be reviewed. The same applies to: - `Renderer2.setProperty(el, 'innerHTML', value)`: the browser renderer simply sets `el[name] = value`, so it provides abstraction, not sanitization. - `document` APIs such as `insertAdjacentHTML`, and setting `href` or `src` on elements you create yourself. - Third-party libraries that manipulate the DOM directly. ## The fixes, in order of preference | Fix | Who sanitizes | When to use it | | :--- | :--- | :--- | | A host or template `[innerHTML]` binding | Angular, on every change | almost always | | `textContent` instead of `innerHTML` | nothing needed, no markup is parsed | the value is plain text | | `DomSanitizer.sanitize(SecurityContext.HTML, value)` then a DOM write | Angular's sanitizer, called by you | a DOM API is genuinely unavoidable | **The binding** is the cleanest fix because it puts the value back on the path Angular already protects, and it keeps working as the value changes. **`textContent`** is the right answer surprisingly often: many directives that "set HTML" only ever display text. **`DomSanitizer.sanitize`** is the explicit escape hatch. With `SecurityContext.HTML` and a plain string it runs the same allow-list sanitizer as a binding. With a `SafeHtml` it returns the unwrapped string, so values deliberately trusted elsewhere still work. For `SecurityContext.URL` it applies the URL rule; for `RESOURCE_URL` or `SCRIPT` a plain string throws, because those contexts cannot be sanitized by inspection. Note what is **not** a fix: calling `bypassSecurityTrustHtml` and then writing the result. A bypass only affects bindings and `sanitize()`; written through a DOM API it does nothing except hide the vulnerability. ## Why Angular cannot simply catch these writes A common follow-up is why the framework does not intercept `innerHTML` everywhere. Doing so would mean patching DOM prototypes for the whole page, adding cost to DOM operations and changing behaviour for third-party code the framework does not own. Angular instead secures the path it owns, the compiled binding, and its documentation points to the browser feature designed for the platform-level guarantee, Trusted Types. ## Finding the rest of them A review that finds one such write should look for others: - search for `nativeElement.` followed by `innerHTML`, `outerHTML`, `insertAdjacentHTML`, `href` or `src`; - search for `Renderer2` calls that set those same properties or attributes; - check wrappers around third-party widgets, which often accept HTML strings. ## Defence in depth Angular's documentation recommends **Trusted Types** enforcement alongside its sanitizer. With it enabled, the browser refuses a plain string assigned to sinks such as `innerHTML`, so a raw `nativeElement.innerHTML` write fails loudly instead of silently executing, while Angular's own sanitized bindings keep working. It turns this class of bug from a review finding into a runtime error, but it is a second layer: the directive still needs one of the fixes above.

  • Is Renderer2.setProperty(el, 'innerHTML', value) a safe replacement for nativeElement.innerHTML?
    No. Renderer2 abstracts the platform so code can run outside a browser, but the browser renderer's `setProperty` just assigns `el[name] = value`. No sanitizer runs, so it is exactly as exploitable. Use a binding, or sanitize with `DomSanitizer.sanitize` before calling it.
  • What does DomSanitizer.sanitize return when given SecurityContext.RESOURCE_URL and a plain string?
    It throws rather than returning a value, because a resource URL, such as a script or iframe source, cannot be made safe by inspecting the string. Only a value created with `bypassSecurityTrustResourceUrl` passes, and `sanitize` returns its unwrapped string.

saying these in an interview costs you the question

  • Angular sanitizes every innerHTML assignment, including nativeElement writes.
  • Renderer2.setProperty sanitizes values because it is Angular's own API.
  • Wrapping the value with bypassSecurityTrustHtml makes the DOM write safe.
  • Host bindings skip sanitization, so only template bindings are protected.
  • ElementRef is safe to use as long as the value came from an input.