Back to catalog

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(/&lt;script/gi, '') // Remove escaped script tags .replace(/&lt;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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;') .replace(/\//g, '&#x2F;') .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