form-security-validation
This repository contains a complete **Cursor (Claude AI)** infrastructure that provides **auto-activating skills**, **specialized agents**, and **powerful commands** to help you build better applications faster.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
# Form Security & Validation Guidelines
**Skill Version:** 1.0.0
**Stack:** React, TypeScript, Next.js
**Purpose:** Comprehensive form security, validation, and UX patterns
## Overview
This skill provides production-tested patterns for secure form handling, including validation, double-click prevention, number input security, XSS protection, and proper error handling.
## When to Use This Skill
Load this skill when working on:
- Form components with validation
- Number/amount inputs (preventing negative values, scrolling issues)
- Submit button security (preventing double submissions)
- User input sanitization
- Form state management
## Quick Reference
### Core Security Principles
1. **Prevent double submissions** - Disable buttons during submission
2. **Validate on client and server** - Never trust client-side only
3. **Sanitize all inputs** - Remove XSS vectors
4. **Handle number inputs properly** - Prevent wheel scroll, negative values
5. **Provide clear feedback** - Loading states, error messages
6. **Rate limit submissions** - Protect against abuse
## Core Guidelines
### 1. Form Validation Hook Pattern
```typescript
// hooks/useFormValidation.ts
import { useState, useCallback } from 'react';
export interface ValidationRule {
required?: boolean;
email?: boolean;
phone?: boolean;
amount?: AmountValidationOptions;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: any) => string | undefined;
dateNotFuture?: boolean;
}
export interface ValidationRules {
[field: string]: ValidationRule;
}
export interface FormErrors {
[field: string]: string;
}
export interface AmountValidationOptions {
allowZero?: boolean;
allowNegative?: boolean;
min?: number;
max?: number;
maxDecimals?: number;
}
export function useFormValidation<T extends Record<string, any>>(
initialData: T,
rules: ValidationRules
) {
const [formData, setFormData] = useState<T>(initialData);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
// Validate a single field
const validateField = useCallback(
(name: string, value: any): string | undefined => {
const rule = rules[name];
if (!rule) return undefined;
// Required validation
if (rule.required && !value) {
return 'This field is required';
}
// Skip other validations if empty and not required
if (!value && !rule.required) {
return undefined;
}
// Email validation
if (rule.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return 'Please enter a valid email address';
}
// Phone validation
if (rule.phone) {
const cleaned = value.replace(/[\s\-\(\)]/g, '');
if (!/^\+?[0-9]{10,15}$/.test(cleaned)) {
return 'Please enter a valid phone number';
}
}
// Amount validation
if (rule.amount) {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue) || !isFinite(numValue)) {
return 'Must be a valid number';
}
if (!rule.amount.allowZero && numValue === 0) {
return 'Amount cannot be zero';
}
if (!rule.amount.allowNegative && numValue < 0) {
return 'Amount cannot be negative';
}
if (rule.amount.min !== undefined && numValue < rule.amount.min) {
return `Amount must be at least ${rule.amount.min}`;
}
if (rule.amount.max !== undefined && numValue > rule.amount.max) {
return `Amount cannot exceed ${rule.amount.max}`;
}
const decimals = (numValue.toString().split('.')[1] || '').length;
if (decimals > (rule.amount.maxDecimals || 2)) {
return `Maximum ${rule.amount.maxDecimals || 2} decimal places allowed`;
}
}
// Min/Max length
if (rule.minLength && value.length < rule.minLength) {
return `Minimum ${rule.minLength} characters required`;
}
if (rule.maxLength && value.length > rule.maxLength) {
return `Maximum ${rule.maxLength} characters allowed`;
}
// Pattern validation
if (rule.pattern && !rule.pattern.test(value)) {
return 'Please enter a valid format';
}
// Date not future
if (rule.dateNotFuture) {
const date = new Date(value);
if (date > new Date()) {
return 'Date cannot be in the future';
}
}
// Custom validation
if (rule.custom) {
return rule.custom(value);
}
return undefined;
},
[rules]
);
// Validate all fields
const validateAll = useCallback((): boolean => {
const newErrors: FormErrors = {};
let isValid = true;
Object.keys(rules).forEach((field) => {
const error = validateField(field, formData[field]);
if (error) {
newErrors[field] = error;
isValid = false;
}
});
setErrors(newErrors);
return isValid;
}, [formData, rules, validateField]);
// Handle field change
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const fieldValue = type === 'number' ? (value === '' ? '' : parseFloat(value)) : value;
setFormData((prev) => ({
...prev,
[name]: fieldValue,
}));
// Validate on change if field was already touched
if (touched[name]) {
const error = validateField(name, fieldValue);
setErrors((prev) => ({
...prev,
[name]: error || '',
}));
}
},
[touched, validateField]
);
// Handle field blur
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setTouched((prev) => ({
...prev,
[name]: true,
}));
const error = validateField(name, value);
setErrors((prev) => ({
...prev,
[name]: error || '',
}));
},
[validateField]
);
// Reset form
const resetForm = useCallback(() => {
setFormData(initialData);
setErrors({});
setTouched({});
}, [initialData]);
// Set specific field value
const setFieldValue = useCallback((name: string, value: any) => {
setFormData((prev) => ({
...prev,
[name]: value,
}));
}, []);
return {
formData,
errors,
touched,
handleChange,
handleBlur,
validateAll,
resetForm,
setFieldValue,
setFormData,
};
}
```
### 2. Secure Number/Amount Input Component
**Key Features:**
- Prevents mouse wheel scrolling
- Blocks negative input if not allowed
- Prevents typing minus sign
- Validates decimal places
- Shows currency prefix
```typescript
// components/AmountField.tsx
import React from 'react';
interface AmountFieldProps {
label: string;
name: string;
value: string | number;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
required?: boolean;
error?: string;
helperText?: string;
disabled?: boolean;
currency?: string;
min?: number;
max?: number;
allowNegative?: boolean;
placeholder?: string;
}
export const AmountField: React.FC<AmountFieldProps> = ({
label,
name,
value,
onChange,
onBlur,
required = false,
error,
helperText,
disabled = false,
currency = '#39;,
min = 0,
max,
allowNegative = false,
placeholder = '0.00',
}) => {
// Handle input change and prevent negative if not allowed
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
// Prevent negative values if not allowed
if (!allowNegative && inputValue !== '' && parseFloat(inputValue) < 0) {
e.target.value = '0';
return;
}
onChange(e);
};
// Prevent typing minus sign if negatives not allowed
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!allowNegative && (e.key === '-' || e.key === 'Subtract')) {
e.preventDefault();
}
};
return (
<div className="mb-4">
<label htmlFor={name} className="block text-sm font-medium text-gray-700 mb-1">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
<div className="relative">
<span className="absolute left-3 top-2 text-gray-500 font-medium">
{currency}
</span>
<input
type="number"
id={name}
name={name}
value={value}
onChange={handleAmountChange}
onKeyDown={handleKeyDown}
onBlur={onBlur}
// CRITICAL: Prevents wheel scroll changing value
onWheel={(e) => e.currentTarget.blur()}
placeholder={placeholder}
required={required}
disabled={disabled}
min={allowNegative ? undefined : min}
max={max}
step="0.01"
className={`w-full pl-10 pr-3 py-2 border rounded-lg focus:outline-none focus:ring-2 ${
error
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:ring-blue-500'
} ${disabled ? 'bg-gray-100 cursor-not-allowed' : ''}`}
/>
</div>
{error && (
<p className="mt-1 text-sm text-red-600">{error}</p>
)}
{!error && helperText && (
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
)}
</div>
);
};
```
### 3. Submit Button with Double-Click Prevention
```typescript
// Always use submitting state to prevent double submissions
interface FormComponentProps {
// ... other props
}
export function OrderForm() {
const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({
amount: '',
description: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent double submission
if (submitting) return;
setSubmitting(true);
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error('Submission failed');
}
const data = await response.json();
// Success handling
alert('Order submitted successfully');
setFormData({ amount: '', description: '' });
} catch (error) {
console.error('Error submitting order:', error);
alert('Failed to submit order');
} finally {
// Always reset submitting state
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<AmountField
label="Order Amount"
name="amount"
value={formData.amount}
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
required
/>
<textarea
name="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Description"
required
/>
<div className="flex gap-3">
<button
type="button"
onClick={() => setFormData({ amount: '', description: '' })}
className="px-4 py-2 text-gray-700 hover:text-gray-900"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
{submitting ? 'Processing...' : 'Submit Order'}
</button>
</div>
</form>
);
}
```
### 4. Input Sanitization Utilities
```typescript
// lib/validation.ts
/**
* Sanitize string input (remove potential XSS)
*/
export function sanitizeString(input: string, maxLength: number = 1000): string {
if (!input || typeof input !== 'string') return '';
return input
.replace(/[<>]/g, '') // Remove angle brackets
.replace(/javascript:/gi, '') // Remove javascript: protocol
.replace(/on\w+=/gi, '') // Remove event handlers like onclick=
.replace(/<script/gi, '') // Remove escaped script tags
.replace(/<iframe/gi, '') // Remove escaped iframes
.trim()
.slice(0, maxLength);
}
/**
* Sanitize HTML to prevent XSS (more aggressive)
*/
export function sanitizeHtml(input: string): string {
if (!input || typeof input !== 'string') return '';
return input
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/')
.trim();
}
/**
* Sanitize object before sending to API
*/
export function sanitizeObject<T extends Record<string, any>>(
obj: T,
maxStringLength: number = 1000
): Partial<T> {
const sanitized: Partial<T> = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) {
continue;
}
if (typeof value === 'string') {
sanitized[key as keyof T] = sanitizeString(value, maxStringLength) as T[keyof T];
} else if (typeof value === 'object' && !Array.isArray(value)) {
sanitized[key as keyof T] = sanitizeObject(value, maxStringLength) as T[keyof T];
} else {
sanitized[key as keyof T] = value;
}
}
return sanitized;
}
/**
* Validate required fields
*/
export function isRequired(value: any): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string' && value.trim() === '') return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email) && email.length <= 255;
}
/**
* Validate phone number
*/
export function isValidPhone(phone: string): boolean {
const cleaned = phone.replace(/[\s\-\(\)]/g, '');
return /^\+?[0-9]{10,15}$/.test(cleaned);
}
```
## Complete Form Example
```typescript
'use client';
import { useState } from 'react';
import { useFormValidation } from '@/hooks/useFormValidation';
import { AmountField } from '@/components/AmountField';
import { sanitizeObject } from '@/lib/validation';
interface ProductFormData {
productCode: string;
price: string;
productName: string;
description: string;
releaseDate: string;
}
export function ProductForm() {
const [submitting, setSubmitting] = useState(false);
const {
formData,
errors,
handleChange,
handleBlur,
validateAll,
resetForm,
setFieldValue,
} = useFormValidation<ProductFormData>(
{
productCode: '',
price: '',
productName: '',
description: '',
releaseDate: '',
},
{
productCode: {
required: true,
pattern: /^PRD-\d{4,}$/,
},
price: {
required: true,
amount: {
allowZero: false,
allowNegative: false,
min: 0.01,
max: 1000000,
maxDecimals: 2,
},
},
productName: {
required: true,
minLength: 2,
maxLength: 100,
},
description: {
required: true,
maxLength: 500,
},
releaseDate: {
required: true,
dateNotFuture: false,
},
}
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Prevent double submission
if (submitting) return;
// Validate all fields
if (!validateAll()) {
return;
}
setSubmitting(true);
try {
// Sanitize data before sending
const sanitizedData = sanitizeObject(formData);
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sanitizedData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create product');
}
// Success
alert('Product created successfully');
resetForm();
} catch (error) {
console.error('Error creating product:', error);
alert(error instanceof Error ? error.message : 'Failed to create product');
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Code *
</label>
<input
type="text"
name="productCode"
value={formData.productCode}
onChange={handleChange}
onBlur={handleBlur}
placeholder="PRD-0001"
className={`w-full px-3 py-2 border rounded-lg ${
errors.productCode ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.productCode && (
<p className="mt-1 text-sm text-red-600">{errors.productCode}</p>
)}
</div>
<AmountField
label="Price"
name="price"
value={formData.price}
onChange={(e) => setFieldValue('price', e.target.value)}
onBlur={handleBlur}
required
error={errors.price}
currency="quot;
allowNegative={false}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Name *
</label>
<input
type="text"
name="productName"
value={formData.productName}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-lg ${
errors.productName ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.productName && (
<p className="mt-1 text-sm text-red-600">{errors.productName}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
onBlur={handleBlur}
rows={3}
className={`w-full px-3 py-2 border rounded-lg ${
errors.description ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Release Date *
</label>
<input
type="date"
name="releaseDate"
value={formData.releaseDate}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-lg ${
errors.releaseDate ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.releaseDate && (
<p className="mt-1 text-sm text-red-600">{errors.releaseDate}</p>
)}
</div>
<div className="flex justify-end gap-3 pt-4 border-t">
<button
type="button"
onClick={resetForm}
className="px-4 py-2 text-gray-700 hover:text-gray-900"
>
Reset
</button>
<button
type="submit"
disabled={submitting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
{submitting ? 'Creating...' : 'Create Product'}
</button>
</div>
</f