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?
answer
- sanitizer lives in compiled bindings
- nativeElement is the raw DOM node
- Renderer2 does not sanitize either
- host binding or explicit sanitize
- SecurityContext.HTML
basics
~20 sAngular'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 sWhen 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 linesimport {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
Recall that ElementRef.nativeElement is the raw DOM element and that writing its innerHTML skips Angular's sanitizer.
Explain that sanitization is compiled into template and host bindings, which is why Renderer2 and DOM APIs do not get it.
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.
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.