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:
Melvin Ragusa
2026-01-26 08:55:45 +01:00
committed by GitHub
11 changed files with 190 additions and 2 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -1 +1,2 @@
export { useTypingEffect } from './useTypingEffect';
export { useRateLimit } from './useRateLimit';

View 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
View 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 };
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View 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()