XSS Security Handling with NextJS and DOMPurify

Introduction

Cross-Site Scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts, typically JavaScript, into web pages viewed by other users. When a user's browser executes this code, attackers can steal cookies, session tokens and alter the website interface.

To prevent XSS, the following primary methods are used:

  • Validation: Accepting only desired data formats. This simple approach only addresses surface-level issues, so we focus on the methods below for better effectiveness.

  • Sanitization: Removing or disabling dangerous HTML tags and attributes such as <script>, onerror and onclick from user input before storage or display.

    • Using Framework Automations: React and NextJS automatically escape data in text strings by default.
    • When rendering HTML directly via dangerouslySetInnerHTML, using a library like DOMPurify is mandatory to ensure safety.
  • Content Security Policy (CSP): Configuring browser policies to restrict script execution sources.

The attack workflow is as follows: if the frontend does not sanitize data before submission, a hacker can send data containing malicious scripts to be stored in the database (assuming the Backend also fails to sanitize). Subsequently, when a user accesses and loads this content (with no frontend checks), the malicious script will execute on the browsers of those users viewing the content.

In this article, I will guide you on using isomorphic-dompurify to sanitize HTML data upon receiving it from an API and before submitting it for database storage.

Detail

Create file app/xss/hook.ts

import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {message} from 'antd'
import DOMPurify from 'isomorphic-dompurify'
const fetchMaliciousData = async () => {
  return new Promise<{data: string}>(resolve => {
    setTimeout(() => {
      resolve({
        data: `
          <p style="color: #2563eb;">Data loaded from API successfully!</p>
          <img src="x" onerror="alert('XSS via IMG tag!')" />
          <script>alert('XSS via SCRIPT tag!')</script>
          <button onclick="alert('XSS via ONCLICK!')" style="background: red; color: white;">Click Me</button>
        `,
      })
    }, 1000)
  })
}
const mockSaveDataApi = async (data: {content: string}) => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('Sanitized payload to server:', data)
      resolve({success: true})
    }, 800)
  })
}
export const useSanitizedData = () => {
  const queryClient = useQueryClient()
  const query = useQuery({
    queryKey: ['maliciousData'],
    queryFn: fetchMaliciousData,
  })
  const getCleanHtml = (rawHtml: string) => {
    return DOMPurify.sanitize(rawHtml)
  }
  const mutation = useMutation({
    mutationFn: mockSaveDataApi,
    onSuccess: () => {
      message.success('Data sanitized and submitted!')
      queryClient.invalidateQueries({queryKey: ['maliciousData']})
    },
    onError: () => {
      message.error('Submission failed.')
    },
  })
  const sanitizeAndSend = (rawText: string) => {
    if (!rawText.trim()) {
      message.warning('Input cannot be empty!')
      return
    }
    const cleanHTML = DOMPurify.sanitize(rawText)
    mutation.mutate({content: cleanHTML})
  }
  return {
    rawApiData: query.data?.data || '',
    cleanHtml: query.data ? getCleanHtml(query.data.data) : '',
    sendData: sanitizeAndSend,
  }
}
  • Using isomorphic-dompurify, which is a version of DOMPurify that works effectively on both Server-side and Client-side.
  • Sanitization at both ends (Input & Output). When fetching data or before submitting HTML content, use DOMPurify.sanitize to filter out dangerous attributes like onerror or tags before rendering via dangerouslySetInnerHTML on the UI and saving to the database.

Create file app/xss/InputSection.tsx to sanitize data before submission

'use client'
import {Button, Input} from 'antd'
import React, {useState} from 'react'
interface InputSectionProps {
  onSend: (value: string) => void
}
export const InputSection = React.memo(({onSend}: InputSectionProps) => {
  const [inputValue, setInputValue] = useState('<img src=x onerror=alert(1)>')
  return (
    <div className="flex flex-col gap-4">
      <Input.TextArea
        rows={3}
        value={inputValue}
        onChange={e => setInputValue(e.target.value)}
        placeholder="Type something..."
      />
      <Button type="primary" onClick={() => onSend(inputValue)}>
        Sanitize & Submit
      </Button>
    </div>
  )
})

Create file app/xss/page.tsx

'use client'
import {Card, Divider, Typography} from 'antd'
import {useCallback} from 'react'
import {useSanitizedData} from './hook'
import {InputSection} from './InputSection'
const {Text} = Typography
const SanitizeForm = () => {
  const {rawApiData, cleanHtml, sendData} = useSanitizedData()
  const handleSend = useCallback(
    (value: string) => {
      sendData(value)
    },
    [sendData]
  )
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 p-6 gap-6">
      <Card className="w-full max-w-2xl shadow-lg">
        <InputSection onSend={handleSend} />
        <Divider />
        <div className="bg-white p-4 rounded border">
          <Text strong className="text-xs text-gray-400">
            rawApiData
          </Text>
          <div className="mt-4">
            <div>{rawApiData}</div>
          </div>
        </div>
        <div className="bg-white p-4 rounded border mt-4">
          <Text strong className="text-xs text-gray-400">
            dangerouslySetInnerHTML rawApiData
          </Text>
          <div className="mt-4">
            {/* <div dangerouslySetInnerHTML={{__html: rawApiData}} /> */}
          </div>
        </div>
        <div className="bg-white p-4 rounded border mt-4">
          <Text strong className="text-xs text-gray-400">
            dangerouslySetInnerHTML cleanHtml
          </Text>
          <div className="mt-4">
            <div dangerouslySetInnerHTML={{__html: cleanHtml}} />
          </div>
        </div>
      </Card>
    </div>
  )
}
export default SanitizeForm


Verify the results as follows:


You can uncomment the line showing __html: rawApiData to see how HTML data containing scripts executes directly on the UI.


HTML data will be sanitized to remove malicious scripts before submission.

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Kubernetes Deployment for Zero Downtime

Deploying a NodeJS Server on Google Kubernetes Engine

Setting up Kubernetes Dashboard with Kind

Monitoring with cAdvisor, Prometheus and Grafana on Docker

Using Kafka with Docker and NodeJS

Sitemap

React Practice Series

Practicing with Google Cloud Platform - Google Kubernetes Engine to deploy nginx

Kubernetes Practice Series