Securing Web forms in React
Securing Web forms in React
Practical Guidelines for Preventing XSS and Data Injection in Web Forms
Introduction
Forms are a key part of most web applications. They handle user data entry, authentication, and configuration. Because they accept external input, forms are also one of the main sources of security vulnerabilities such as:
- Cross-Site Scripting (XSS)
- Data Injection
- Phishing via injected form markup
React automatically escapes user-provided data in JSX, which prevents many common injection attacks. However, as applications scale and integrate with user-generated or external content, teams must apply consistent input sanitization and validation patterns.
This document defines standard practices for React-based projects to ensure that user input is handled safely across the application.
Core Principle: Validate Input, Encode Output
Every piece of user-provided data should be treated as untrusted.
“Never trust input that originates outside your code.”
| Stage | Action | Example |
|---|---|---|
| Input | Validate format, type, and constraints | Email validation, password length checks |
| Output | Encode or sanitize before rendering | Prevent <script> execution or inline event handlers |
React’s Built-in Escaping
React escapes HTML entities in JSX automatically.
For example:
<div>{userInput}</div>
If userInput contains <img src=x onerror=alert('xss')>, React renders it safely as:
<div><img src=x onerror=alert('xss')></div>
Clarification:
- Escaping = converting HTML to text so the browser doesn’t interpret it.
- Sanitizing = removing or neutralizing malicious HTML elements.
React escapes by default, but it does not sanitize. If you need to render HTML, you must sanitize it before rendering.
Sanitizing Inputs with DOMPurify
Use DOMPurify for cleaning untrusted text or HTML.
Installation
npm install dompurify
npm install dompurify
Example
import DOMPurify from 'dompurify';
function sanitizeInput(value) {
return DOMPurify.sanitize(value, { ALLOWED_TAGS: [] });
}
const handleChange = (e) => {
const clean = sanitizeInput(e.target.value);
setFormValue(clean);
};
import DOMPurify from 'dompurify';
function sanitizeInput(value) {
return DOMPurify.sanitize(value, { ALLOWED_TAGS: [] });
}
const handleChange = (e) => {
const clean = sanitizeInput(e.target.value);
setFormValue(clean);
};
Note:ALLOWED_TAGS: [] removes all HTML tags. This is recommended for most text inputs such as names, comments, or message bodies.
Use Schema Validation
Libraries such as Yup and Zod provide schema-based validation that can be reused on both client and server.
Example (Yup)
import * as Yup from "yup";
const schema = Yup.object({
email: Yup.string().email("Invalid email").required("Required"),
message: Yup.string().max(500, "Max 500 chars").trim()
});
import * as Yup from "yup";
const schema = Yup.object({
email: Yup.string().email("Invalid email").required("Required"),
message: Yup.string().max(500, "Max 500 chars").trim()
});
With React Hook Form:
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
const { register, handleSubmit } = useForm({ resolver: yupResolver(schema) });
Always validate input before it’s sent to the backend and revalidate again server-side.
Avoid Dangerous Rendering APIs
❌ Avoid dangerouslySetInnerHTML with untrusted data
<label dangerouslySetInnerHTML={{ __html: userInput }} />
<label dangerouslySetInnerHTML={{ __html: userInput }} />
✅ Safe alternative
<label>{userInput}</label>
<label>{userInput}</label>
If rendering HTML is unavoidable (for example, formatted text from a CMS), sanitize it first:
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userBio) }} />
Input and Form Element Guidelines
| Element | Guideline | Example |
|---|---|---|
<input> | Use the appropriate type attribute | <input type="email" /> |
<textarea> | Strip HTML tags | DOMPurify.sanitize(value, { ALLOWED_TAGS: [] }) |
<button> | Use React onClick, not inline JS | <button onClick={handleClick}> |
<form> | Validate on submit | Don’t rely solely on pattern or required |
Centralized Sanitization Hook
Create a shared hook to standardize sanitization:
// useSanitizedInput.js
import DOMPurify from "dompurify";
import { useState } from "react";
export const useSanitizedInput = (initialValue = "") => {
const [value, setValue] = useState(initialValue);
const onChange = (e) => {
const clean = DOMPurify.sanitize(e.target.value, { ALLOWED_TAGS: [] });
setValue(clean);
};
return { value, onChange };
};
Usage:
const username = useSanitizedInput();
<input {...username} placeholder="Username" />
This ensures consistent cleaning logic across all components.
Sanitize Data on Render
When displaying user-supplied data (e.g., bios or comments):
const cleanBio = DOMPurify.sanitize(user.bio);
return <p dangerouslySetInnerHTML={{ __html: cleanBio }} />;
This prevents stored XSS attacks, where malicious data from the backend is rendered later.
Apply Content Security Policy (CSP)
CSP headers are configured on the server, not the client.
They limit what scripts and resources can execute in the browser.
Example:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://trusted-cdn.com;
object-src 'none';
frame-ancestors 'none';
base-uri 'self';
Even if unsafe HTML is injected, a strong CSP prevents it from executing.
Trust nothing. Sanitize everything.
Security should be part of standard development workflows, not an afterthought.
Following these guidelines ensures:
- Reduced XSS and injection risks
- Consistent input handling
- Easier maintenance across multiple teams
Comments
Post a Comment