A DOM XSS Sink Inventory for Angular: ElementRef, Renderer2, Dynamic URLs, HTML-Producing Libraries and a Code Review Checklist
Angular only sanitizes its own template bindings; ElementRef.nativeElement, Renderer2.setProperty/setAttribute, insertAdjacentHTML, dynamic script and iframe sources and HTML-producing libraries are unprotected. In this post we show these sinks in headless Chrome, fix them with the template and DomSanitizer, compare marked, DOMPurify and setHTML, and build a grep and ESLint based code review checklist.
Version note: The examples in this post were run with Angular 22.2, Node.js 24, Chrome 156, marked 18.1, DOMPurify 3.4 and ESLint 10.14.
Guess First
The Bookstore book page shows reader reviews. Reviews arrive from the API (Application Programming Interface) as JSON (JavaScript Object Notation), and one of them is Mallory's: Great book <img src=x onerror="...">. A single component writes that value into the DOM (Document Object Model) in four different ways: an [innerHTML] binding in the template, an ElementRef.nativeElement.innerHTML assignment, an insertAdjacentHTML call and Renderer2.setProperty(el, 'innerHTML', ...). Angular is a "secure by default" framework. In how many of these four paths does onerror run?
The answer is three. Angular only sanitizes its own template bindings; the other three paths go straight to the browser's raw DOM APIs and Angular never sees the write. In this post we take inventory of those paths: the sinks Angular does not protect, HTML (HyperText Markup Language) producing dependencies, the risk of generating templates on the server, and a grep and ESLint based checklist your team can use in every code review. The three kinds of XSS (Cross-Site Scripting), the innerHTML trap and the basics of the bypassSecurityTrust* methods were covered in the Web Security (OWASP Top 10) series, in the post "OWASP A05: Injection (2)"; we will not repeat them here and focus instead on the blind spots specific to Angular code. OWASP stands for Open Worldwide Application Security Project.
Thinking from Source to Sink
The shortest way to understand DOM XSS is to ask two questions together: where does the value come from (the source) and where is it written (the sink)? A source can be an API response, location.hash, the query string, data received through postMessage or localStorage. A sink is any API that interprets a value as HTML or script: innerHTML, outerHTML, insertAdjacentHTML, document.write, script.src, iframe.srcdoc, eval, a string passed to setTimeout. The OWASP DOM based XSS Prevention Cheat Sheet starts from the same split: untrusted data must be handled in a way that fits the sink's context before it reaches that sink.
In Angular the answer is usually "the template". {{ }} interpolation produces a text node, and property bindings such as [innerHTML], [href] and [src] go through Angular's sanitizer. The trouble begins when code steps outside the template. The component in the lab does exactly that:
[
{ "author": "Ada", "text": "Clear examples, <b>short</b> chapters.", "website": "https://example.com/ada" },
{ "author": "Mallory", "text": "Great book <img src=x onerror=\"console.log('XSS fired in #' + this.parentElement.id)\">", "website": "javascript:console.log('XSS fired via href')" }
]
The payload is harmless: if it runs, it only logs which container it ran in. The component:
@Component({
selector: 'app-review-sinks',
template: `
@for (review of reviews(); track review.author) {
<article>
<p>{{ review.text }}</p>
<p [id]="'binding-' + review.author" [innerHTML]="review.text"></p>
<a [id]="'href-' + review.author" [href]="review.website">Website</a>
</article>
}
<div id="native" #native></div>
<div id="adjacent" #adjacent></div>
<div id="renderer" #rendered></div>
<a id="renderer-link" #link>Author site</a>
`,
})
export class ReviewSinks {
private readonly http = inject(HttpClient);
private readonly renderer = inject(Renderer2);
protected readonly reviews = toSignal(this.http.get<Review[]>('/api/reviews.json'), { initialValue: [] });
private readonly native = viewChild.required<ElementRef<HTMLElement>>('native');
private readonly adjacent = viewChild.required<ElementRef<HTMLElement>>('adjacent');
private readonly rendered = viewChild.required<ElementRef<HTMLElement>>('rendered');
private readonly link = viewChild.required<ElementRef<HTMLAnchorElement>>('link');
constructor() {
effect(() => {
const mallory = this.reviews().find(r => r.author === 'Mallory');
if (!mallory) return;
// UNSAFE: Angular never sees these writes, nothing is sanitized
this.native().nativeElement.innerHTML = mallory.text;
this.adjacent().nativeElement.insertAdjacentHTML('beforeend', mallory.text);
this.renderer.setProperty(this.rendered().nativeElement, 'innerHTML', mallory.text);
this.renderer.setAttribute(this.link().nativeElement, 'href', mallory.website);
});
}
}
To open the page in headless Chrome and read the console, I wrote a small script that uses Playwright's playwright-core package with the system Chrome. Its core is two listeners: one prints console messages, the other prints CSP (Content Security Policy) violation events.
const browser = await chromium.launch({ executablePath: process.env.CHROME, headless: true });
const page = await browser.newPage();
page.on('console', m => console.log(`[console.${m.type()}] ${m.text()}`));
page.on('pageerror', e => console.log(`[pageerror] ${e.message}`));
await page.addInitScript(() => {
document.addEventListener('securitypolicyviolation', e => {
console.log(`[csp-violation] directive=${e.violatedDirective} blocked=${e.blockedURI} disposition=${e.disposition} sample=${e.sample}`);
});
});
Output against the development build:
[console.log] Angular is running in development mode.
[console.warning] WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss
[console.warning] WARNING: sanitizing unsafe URL value javascript:console.log('XSS fired via href') (see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss)
[console.log] XSS fired in #renderer
[console.log] XSS fired in #native
[console.log] XSS fired in #adjacent
The two template bindings went through Angular's sanitizer: after [innerHTML] the DOM held Great book <img src="x">, and the [href] value became unsafe:javascript:.... The other three paths ran as is. The link written with Renderer2.setAttribute is unprotected too: its href attribute stayed javascript:console.log('XSS fired via href'), and when I clicked it the console printed XSS fired via href.
The Paths Angular Does Not Protect
Angular's sanitization depends on the bindings the compiler sees in the template. When the compiler sees an [innerHTML] binding it emits a call that handles the value in the HTML context, and for [href] in the URL (Uniform Resource Locator) context. ElementRef.nativeElement, on the other hand, hands you the real DOM element; everything you do with it is the browser's own API. The security guide on angular.dev says it plainly: unless you enforce Trusted Types, the browser's built-in DOM APIs do not protect you from security vulnerabilities on their own.
Many teams think of Renderer2 as "Angular's safe way"; it is not. Renderer2 is an abstraction for platform-independent DOM work; setProperty and setAttribute write the value directly to the element without calling the sanitizer. In the lab, the setProperty(el, 'innerHTML', ...) line behaved exactly like nativeElement.innerHTML.
The other paths in the inventory:
document.writeanddocument.writeln: there is almost no legitimate use in an Angular app; if you see one, ask.- Dynamic script: writing user data to the
srcof an element created withdocument.createElement('script')lets an attacker load their own script. - Dynamic iframe:
iframe.srcand especiallyiframe.srcdoc, which takes HTML directly. eval,new Function,setTimeout('...'): anything that runs a string as code.- Attributes that carry a user URL:
href,src,action,formaction; thejavascript:scheme runs here.
Angular's template side has a limit as well: the resource URL context. If you pass a plain string to a binding that loads code, such as <iframe [src]> or <script [src]>, Angular does not try to sanitize it; it throws. In the lab, binding previewUrl = 'https://example.com/preview/42' to <iframe [src]> gave:
[console.error] ERROR RuntimeError: NG0904: unsafe value used in a resource URL context (see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss). Find more at https://v22.angular.dev/errors/NG0904
at ɵɵsanitizeResourceUrl (http://localhost:19420/main.js:8289:9)
That behavior is correct, but it has a side effect: the first thing a developer reaches for to silence the error is wrapping the value in bypassSecurityTrustResourceUrl. If the resource URL comes from the user, that means switching protection off on purpose. The right way is to pick the URL from a fixed allowlist and bypass only for that fixed value, in a single place in the code.
The Fixed Version: Template First, Then Sanitize
The first rule is simple: write values to the DOM through the template. Interpolation for text, an [innerHTML] binding for HTML, [href] for URLs. If direct DOM access really is unavoidable (a third-party widget's container, a canvas, measurements), angular.dev recommends sanitizing the value yourself with DomSanitizer.sanitize for the right SecurityContext. The fixed effect in the lab:
effect(() => {
const mallory = this.reviews().find(r => r.author === 'Mallory');
if (!mallory) return;
// SAFE: HTML is needed, so run Angular's sanitizer explicitly
// eslint-disable-next-line no-unsanitized/property -- reviewed: DomSanitizer output
this.native().nativeElement.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, mallory.text) ?? '';
// SAFE: plain text never reaches the HTML parser
this.renderer.setProperty(this.rendered().nativeElement, 'textContent', mallory.text);
// SAFE: only https: and mailto: URLs are written
const url = safeUrl(mallory.website);
// eslint-disable-next-line no-restricted-syntax -- reviewed: safeUrl allowlist
if (url) this.renderer.setAttribute(this.link().nativeElement, 'href', url);
});
The URL check is an allowlist; instead of banning schemes like javascript: one by one, it lists the ones it allows:
const allowedProtocols = new Set(['https:', 'mailto:']);
export function safeUrl(value: string): string | null {
try {
const url = new URL(value, document.baseURI);
return allowedProtocols.has(url.protocol) ? url.href : null;
} catch {
return null;
}
}
Result with the same payload:
[console.warning] WARNING: sanitizing HTML stripped some content, see https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss
[status] 200
[eval] {"native":"Great book <img src=\"x\">","renderer":"Great book <img src=x onerror=\"console.log('XSS fired in #' + this.parentElement.id)\">","link":null}
onerror was stripped from the first container, the payload shows up as text in the second, and no href was written to the link. There is no XSS fired line. One note: sanitize returns a string. On a page with Trusted Types enforced, that assignment is still a violation; we will see this in post 04 of the series.
Dependencies That Produce HTML
Cleaning your own code is half the job. Markdown parsers, rich text editors and chart libraries whose tooltip or label formatters return HTML strings all produce HTML, and they often write it into the DOM themselves. Angular never sees that write. When you evaluate one, ask three questions: does the library sanitize its output, if not does its documentation say so, and does it write the HTML to the DOM itself or hand it to you?
marked is a good example: it does not sanitize its output and its documentation says so clearly. In the lab I ran Markdown through plain marked first, then through DOMPurify:
@Component({
selector: 'app-review-markdown',
template: `<div id="markdown" [innerHTML]="html()"></div>`,
})
export class ReviewMarkdown {
readonly source = input.required<string>();
// marked does not sanitize: its output is untrusted HTML
protected readonly html = computed(() =>
DOMPurify.sanitize(marked.parse(this.source(), { async: false })));
}
Source: **Great** book <img src=x onerror="..."> [site](javascript:alert(1)). Output:
[eval] {"md":"<p><strong>Great</strong> book <img src=\"x\"> <a>site</a></p>\n","raw":"<p><strong>Great</strong> book <img src=x onerror=\"console.log('XSS fired in markdown')\"> <a href=\"javascript:alert(1)\">site</a></p>\n"}
raw is marked's raw output: both onerror and the javascript: link are still there. DOMPurify removed both. There are two layers here: DOMPurify, then Angular's own sanitizer through the [innerHTML] binding. Had you written the library output to nativeElement.innerHTML, DOMPurify would have been the only layer.
The browser now has its own sanitizer too. According to version 8.1.8 of MDN's (MDN Web Docs) browser-compat-data package, the HTML Sanitizer API's Element.setHTML() method is supported in Chrome and Edge 146 and in Firefox 148; Safari does not have it, and the feature is not Baseline. In Chrome 156 I wrote the same Markdown output with setHTML:
[eval] {"hasSetHTML":true,"out":"<p><strong>Great</strong> book <a>site</a></p>"}
The striking detail: the <img> disappeared entirely. Chrome 156's default sanitizer configuration does not allow the img element at all (it is missing from the new Sanitizer().get() list); for a review field that shows a cover image you need to pass your own configuration. As of November 2026 it makes sense to use setHTML behind a feature check and fall back to DOMPurify in browsers without it. TypeScript 6.0's DOM types include setHTMLUnsafe but not setHTML, so you need a small type declaration if you use it. setHTMLUnsafe does what its name says: without a sanitizer it writes everything.
Template Injection: Do Not Generate Templates on the Server
An Angular template is code: the compiler turns it into JavaScript. That is why angular.dev states two rules firmly: do not generate Angular templates on the server side with a templating language, and do not build templates by concatenating user input with template syntax. It also explicitly calls JIT (Just-in-Time) compilation with user data an anti-pattern. A sample scenario: on the ASP.NET Core side, Razor writes a fragment like <app-banner title="@Model.Title"> into index.html and leaves the user's {{ }} in the title. If Angular interprets that title as a template, data becomes code. The default AOT (Ahead-of-Time) compilation and templates produced at build time remove the problem; give data to bindings, not to templates.
The same principle holds for SSR (Server-Side Rendering): the rendered HTML goes through Angular's own escaping rules, but if server code builds HTML by concatenating strings, or puts user data into TransferState and writes it as HTML somewhere else, the sink opens again. We will cover SSR-specific risks separately in post 07 of the series.
A Sink Checklist for Code Review
The way to make the inventory permanent is to turn it into a check that runs automatically on every pull request. I use two layers. The first is a rough but fast grep; its job is to say "a human should look at this line":
#!/bin/sh
# Lists DOM XSS sink candidates in an Angular project for code review.
grep -rnE \
-e '\.(innerHTML|outerHTML)[[:space:]]*=' \
-e 'insertAdjacentHTML|document\.write|setHTMLUnsafe' \
-e "setProperty\([^,]+,[[:space:]]*'(innerHTML|outerHTML)'" \
-e 'bypassSecurityTrust[A-Za-z]+' \
-e "createElement\('(script|iframe)'\)" \
-e '\[(innerHTML|outerHTML|srcdoc)\]=' \
-e 'new Function\(|[^.]eval\(' \
--include='*.ts' --include='*.html' "${1:-src}"
Its output on the lab project:
src/app/reviews/review-markdown.ts:7: template: `<div id="markdown" [innerHTML]="html()"></div>`,
src/app/reviews/review-safe.ts:31: this.native().nativeElement.innerHTML = this.sanitizer.sanitize(SecurityContext.HTML, mallory.text) ?? '';
src/app/reviews/review-sinks.ts:12: <p [id]="'binding-' + review.author" [innerHTML]="review.text"></p>
src/app/reviews/review-sinks.ts:37: this.native().nativeElement.innerHTML = mallory.text;
src/app/reviews/review-sinks.ts:38: this.adjacent().nativeElement.insertAdjacentHTML('beforeend', mallory.text);
src/app/reviews/review-sinks.ts:39: this.renderer.setProperty(this.rendered().nativeElement, 'innerHTML', mallory.text);
Grep cannot tell a safe line from an unsafe one; [innerHTML] bindings end up on the list as well, because they also mean "we write HTML here" and you want to know their source. The second layer is ESLint: Mozilla's eslint-plugin-no-unsanitized package catches innerHTML assignments and calls like insertAdjacentHTML, and with no-restricted-syntax we describe the Angular-specific shapes with AST (Abstract Syntax Tree) selectors:
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import noUnsanitized from 'eslint-plugin-no-unsanitized';
export default defineConfig({
files: ['src/**/*.ts'],
ignores: ['src/**/*.spec.ts'],
languageOptions: { parser: tseslint.parser },
plugins: { 'no-unsanitized': noUnsanitized },
rules: {
'no-unsanitized/property': 'error',
'no-unsanitized/method': 'error',
'no-restricted-syntax': ['error',
{
selector: 'CallExpression[callee.property.name=/^bypassSecurityTrust/]',
message: 'bypassSecurityTrust* disables Angular sanitization; needs security review.',
},
{
selector: "CallExpression[callee.property.name='setProperty'][arguments.1.value=/^(innerHTML|outerHTML)$/]",
message: 'Renderer2.setProperty with innerHTML/outerHTML is not sanitized.',
},
{
selector: "CallExpression[callee.property.name='setAttribute'][arguments.1.value=/^(href|src|srcdoc|action|formaction)$/]",
message: 'URL attribute written outside Angular bindings; validate the scheme.',
},
],
},
});
The output of npx eslint src:
src/app/reviews/review-sinks.ts
37:7 error Unsafe assignment to innerHTML no-unsanitized/property
38:7 error Unsafe call to this.adjacent().nativeElement.insertAdjacentHTML for argument 1 no-unsanitized/method
39:7 error Renderer2.setProperty with innerHTML/outerHTML is not sanitized no-restricted-syntax
40:7 error URL attribute written outside Angular bindings; validate the scheme no-restricted-syntax
✖ 4 problems (4 errors, 0 warnings)
All four lines in the unsafe component were caught. The fixed component is not on the list because, after reviewing its two lines, I marked them with an eslint-disable-next-line comment that carries a reason. On the first run those two lines failed as well, and that is a good thing: every exception stays visible in the code together with its reason and can be questioned in review.
The checklist itself:
| Sink | Does Angular protect it? | What to do |
|---|---|---|
{{ }} interpolation | Yes, text node | The default path |
[innerHTML], [href], [src] binding | Yes, sanitizer | Know the source, avoid bypass |
nativeElement.innerHTML, insertAdjacentHTML | No | Move to the template or use DomSanitizer.sanitize |
Renderer2.setProperty / setAttribute | No | textContent, URL allowlist |
document.write, eval, new Function | No | Remove |
script.src, iframe.src, srcdoc | No | Fixed allowlist |
bypassSecurityTrust* | Turns protection off | Fixed values only, security review |
| HTML-producing library | No | DOMPurify or setHTML, then a binding |
In Production
Run the ESLint rules as errors in the CI (Continuous Integration) pipeline, not only for new code; security rules left at warning level turn into hundreds of lines nobody reads after a few sprints. In an existing large project the first run will probably produce dozens of findings. Rather than silencing them in a single pull request, go file by file and write eslint-disable reasons; leave no exception without a reason.
Angular's "sanitizing HTML stripped some content" warning does not appear in production builds; it only shows up in development mode. So in production you cannot see the sanitizer quietly trimming something. Watch for these warnings in development and in end-to-end tests to notice when HTML users expect is being stripped.
Dependencies change. An editor that sanitizes its output today may leave that to configuration in a new major version. Keep every HTML-producing package on a list, and when you upgrade, search its changelog for sanitize, XSS and HTML. The next step is to limit the consequences of these bugs in the browser: strict CSP and Trusted Types. Together they form the second line of defense that catches a missed sink in production.
Common Pitfalls
First, thinking Renderer2 is safe. Renderer2 exists for portability between server and browser platforms; it is not a sanitization layer. In the lab, the setProperty(el, 'innerHTML', ...) and setAttribute(a, 'href', ...) lines were as open as the bare DOM APIs.
Second, silencing NG0904 with a bypass. The error tells you that Angular does not trust a plain string in a context that loads code. Applying bypassSecurityTrustResourceUrl to a user-supplied value removes the protection, not the warning.
Third, saying "the API data comes from our own database, so it is safe". That is exactly what stored XSS is: a review one user wrote yesterday runs on another user's screen today.
Fourth, using an HTML-producing library without checking its defaults. marked's raw output kept onerror and the javascript: link intact. If the library writes HTML to the DOM itself, Angular's sanitizer never even gets involved.
Fifth, treating grep as the only check. Grep works with regex; it misses something like el['inner' + 'HTML'] or an assignment hidden inside a helper function. Grep guides the human eye, while the ESLint rule works on the AST and is harder to fool; neither is as definitive as Trusted Types in the browser.
A Small Experiment
★☆☆ Add the line this.native().nativeElement.outerHTML = mallory.text to the lab component. Confirm that both grep and ESLint catch it, then read the console in headless Chrome.
★★☆ Replace DOMPurify with setHTML in the Markdown component: use setHTML when it is supported and fall back to DOMPurify when it is not. To make the cover image (<img src="cover.png">) appear in Chrome, add the img element and the src and alt attributes to the Sanitizer configuration and compare the output.
★★★ Add a selector to the no-restricted-syntax rules that catches code assigning .src after document.createElement('script'). Test the rule with a small loader function you add to the lab and check whether it produces false positives.
The inventory is ready; the next question is how to limit the consequences of a missed sink in the browser. In the next post we apply a strict CSP to Angular: nonces and 'strict-dynamic', a hash-based policy with autoCsp, nonce-based serving from ASP.NET Core and a non-breaking rollout with Report-Only.