DEV Community

Armaan Saxena
Armaan Saxena

Posted on

Title: Hardening Open Source Apps: Preventing Stored XSS in User-Injected Code

The Challenge: Flexibility vs. Security

In the world of monitoring tools and status pages, branding is everything. Users want the ability to inject custom CSS for their brand colors, custom HTML for their footers, and JavaScript for third-party analytics like Google Tag Manager or Intercom.

However, as a developer, this creates a massive security headache: Stored Cross-Site Scripting (XSS). If an admin account is compromised, or if a platform is multi-tenant, allowing a user to save raw <script> tags or unsanitized HTML can lead to:

  • Session Hijacking: Stealing login cookies.
  • Data Exfiltration: Sending sensitive user data to an attacker's server.
  • Phishing: Injecting fake login forms over the legitimate UI.

The Solution: A Layered Defense

While working on a major feature for the open-source project Checkmate, I implemented a three-layered defense to allow customization while maintaining a high security bar.

1. HTML Sanitization with DOMPurify

React’s dangerouslySetInnerHTML is the primary way to render raw HTML, but it is aptly named. To allow custom headers and footers safely, I integrated DOMPurify.

Before rendering any user-provided HTML, the string is passed through a sanitizer that strips out dangerous event handlers (like onclick or onerror) and malicious tags, while preserving safe layout elements like <div>, <span>, and <img>.

import DOMPurify from "dompurify";

// Sanitizing before rendering
<div 
  dangerouslySetInnerHTML={{ 
    __html: DOMPurify.sanitize(statusPage.headerHTML) 
  }} 
/>
Enter fullscreen mode Exit fullscreen mode

2. Hardening CSS Injection

CSS is often overlooked as a security vector. Malicious CSS can use @import to load external scripts or url() with complex selectors to exfiltrate sensitive data via background images (CSS-based keylogging).

I implemented a Regex-based CSS Hardener to block these specific patterns before the style tag is injected into the document head.

const safeCSS = customCSS
    .replace(/url\s*\(/gi, 'blocked(')
    .replace(/@import/gi, '/* blocked */')
    .replace(/javascript\s*:/gi, 'blocked:')
    .replace(/-moz-binding\s*:/gi, 'blocked:');

// Injecting the hardened CSS
styleElement.textContent = safeCSS;
Enter fullscreen mode Exit fullscreen mode

3. The "Risk Acceptance" UI Gate

Security is as much about the UI as it is about the code. To prevent accidental or unauthorized script execution, I built a "Gatekeeper" component.

The JavaScript input field remains disabled by default. To unlock it, the Admin must strictly check a "Risk Acceptance" checkbox. This ensures that the user is making a conscious, informed decision to execute custom logic on their public page.

Why it Matters

In Open Source, we often prioritize features to grow the user base. But features that compromise security eventually hurt the community. By implementing these guardrails, we give users the branding power they need without leaving the door wide open for attackers.

Top comments (0)