Merge pull request #25 from ragusa-it/sentinel/add-rate-limiting-6220090788029597128
🛡️ Sentinel: [Enhancement] Add rate limiting to contact form
This commit was merged in pull request #25.
This commit is contained in:
@@ -2,3 +2,8 @@
|
||||
**Vulnerability:** The application is served without standard security headers (CSP, X-Frame-Options, etc.), leaving it vulnerable to XSS, Clickjacking, and MIME sniffing.
|
||||
**Learning:** Single Page Applications (SPAs) served via static hosting (like Firebase) rely on infrastructure configuration for security headers, which are often overlooked. Default configurations are rarely secure enough.
|
||||
**Prevention:** Always configure `firebase.json` (or equivalent) with strict security headers (CSP, X-Frame-Options, HSTS, etc.) at project setup.
|
||||
|
||||
## 2026-01-26 - Client-Side Rate Limiting for Serverless Forms
|
||||
**Vulnerability:** Contact forms using client-side services (like EmailJS) without backend middleware are vulnerable to spam and quota exhaustion.
|
||||
**Learning:** While true rate limiting requires a backend, client-side throttling via `localStorage` provides a necessary friction layer for legitimate users and simple bots, protecting external service quotas.
|
||||
**Prevention:** Implement reusable rate-limit hooks for all public-facing form submissions in static/serverless applications.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { type ReactNode, type ButtonHTMLAttributes } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: ReactNode;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Button } from '../Button';
|
||||
import React from 'react';
|
||||
|
||||
describe('Button', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { useTypingEffect } from './useTypingEffect';
|
||||
export { useRateLimit } from './useRateLimit';
|
||||
|
||||
64
src/hooks/useRateLimit.test.ts
Normal file
64
src/hooks/useRateLimit.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { useRateLimit } from './useRateLimit';
|
||||
|
||||
describe('useRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should allow first attempt', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
let allowed: boolean = false;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(result.current.remainingTime).toBe(0);
|
||||
});
|
||||
|
||||
it('should block immediate second attempt', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
act(() => {
|
||||
result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
let allowed: boolean = true;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(result.current.remainingTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should allow attempt after cooldown', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
act(() => {
|
||||
result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
let allowed: boolean = false;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(result.current.remainingTime).toBe(0);
|
||||
});
|
||||
});
|
||||
38
src/hooks/useRateLimit.ts
Normal file
38
src/hooks/useRateLimit.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseRateLimitReturn {
|
||||
checkRateLimit: () => boolean;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export function useRateLimit(key: string, cooldownMs: number): UseRateLimitReturn {
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
const checkRateLimit = useCallback(() => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastAttempt = localStorage.getItem(key);
|
||||
|
||||
if (lastAttempt) {
|
||||
const lastTime = parseInt(lastAttempt, 10);
|
||||
const timePassed = now - lastTime;
|
||||
|
||||
if (timePassed < cooldownMs) {
|
||||
const remaining = Math.ceil((cooldownMs - timePassed) / 1000);
|
||||
setRemainingTime(remaining);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, now.toString());
|
||||
setRemainingTime(0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('LocalStorage not available:', error);
|
||||
// Fail safe: allow action if storage fails
|
||||
return true;
|
||||
}
|
||||
}, [key, cooldownMs]);
|
||||
|
||||
return { checkRateLimit, remainingTime };
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export const de = {
|
||||
sending: 'Wird gesendet...',
|
||||
success: 'Nachricht erfolgreich gesendet! Ich melde mich bald bei Ihnen.',
|
||||
error: 'Fehler beim Senden. Bitte versuchen Sie es erneut oder kontaktieren Sie mich direkt.',
|
||||
rateLimit: 'Zu viele Anfragen. Bitte warten Sie einen Moment.',
|
||||
},
|
||||
info: {
|
||||
title: 'Kontaktdaten',
|
||||
|
||||
@@ -98,6 +98,7 @@ export const en: Translations = {
|
||||
sending: 'Sending...',
|
||||
success: 'Message sent successfully! I\'ll get back to you soon.',
|
||||
error: 'Error sending message. Please try again or contact me directly.',
|
||||
rateLimit: 'Too many requests. Please wait a moment.',
|
||||
},
|
||||
info: {
|
||||
title: 'Contact Info',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import emailjs from "@emailjs/browser";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { useRateLimit } from "../hooks";
|
||||
import { config } from "../config";
|
||||
import { Button, Input, Textarea } from "../components/ui";
|
||||
import { sanitizeInput } from "../utils/security";
|
||||
@@ -38,6 +39,8 @@ export function Contact() {
|
||||
const [submitStatus, setSubmitStatus] = useState<
|
||||
"idle" | "success" | "error"
|
||||
>("idle");
|
||||
const [rateLimitError, setRateLimitError] = useState(false);
|
||||
const { checkRateLimit } = useRateLimit("contact-form", 60000); // 1 minute cooldown
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
@@ -73,8 +76,14 @@ export function Contact() {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setRateLimitError(false);
|
||||
if (!validateForm()) return;
|
||||
|
||||
if (!checkRateLimit()) {
|
||||
setRateLimitError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus("idle");
|
||||
|
||||
@@ -215,6 +224,16 @@ export function Contact() {
|
||||
{t.contact.form.error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{rateLimitError && (
|
||||
<motion.p
|
||||
className={styles.error}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{t.contact.form.rateLimit}
|
||||
</motion.p>
|
||||
)}
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
|
||||
BIN
verification.png
Normal file
BIN
verification.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
60
verification/verify_rate_limit.py
Normal file
60
verification/verify_rate_limit.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
import time
|
||||
|
||||
def verify_rate_limit():
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
print("Navigating to home...")
|
||||
page.goto("http://localhost:3000")
|
||||
|
||||
print("Navigating to Contact...")
|
||||
# Try both English and German just in case
|
||||
try:
|
||||
page.get_by_role("link", name="Contact").click()
|
||||
except:
|
||||
page.get_by_role("link", name="Kontakt").click()
|
||||
|
||||
# Fill form
|
||||
print("Filling form...")
|
||||
# Use placeholders from en.ts
|
||||
page.get_by_placeholder("Your name").fill("Test User")
|
||||
page.get_by_placeholder("your@email.com").fill("test@example.com")
|
||||
page.get_by_placeholder("What is it about?").fill("Test Subject")
|
||||
page.get_by_placeholder("Your message...").fill("Test Message")
|
||||
|
||||
# Submit 1
|
||||
print("Submitting first time...")
|
||||
submit_btn = page.get_by_role("button", name="Send Message")
|
||||
submit_btn.click()
|
||||
|
||||
# Wait for result (likely error due to missing keys/network)
|
||||
# We expect either success or error message
|
||||
print("Waiting for response...")
|
||||
# Allow some time for EmailJS timeout
|
||||
try:
|
||||
expect(page.get_by_text("Error sending message").or_(page.get_by_text("Message sent successfully"))).to_be_visible(timeout=10000)
|
||||
except:
|
||||
print("Timed out waiting for first response, checking if button is enabled...")
|
||||
|
||||
# Ensure button is enabled before clicking again
|
||||
# If it's disabled, we can't click
|
||||
expect(submit_btn).not_to_be_disabled()
|
||||
|
||||
# Submit 2
|
||||
print("Submitting second time...")
|
||||
submit_btn.click()
|
||||
|
||||
# Check for rate limit message
|
||||
print("Checking for rate limit message...")
|
||||
expect(page.get_by_text("Too many requests")).to_be_visible()
|
||||
|
||||
# Screenshot
|
||||
print("Taking screenshot...")
|
||||
page.screenshot(path="verification.png")
|
||||
|
||||
browser.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
verify_rate_limit()
|
||||
Reference in New Issue
Block a user