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.
|
**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.
|
**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.
|
**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 { motion } from 'motion/react';
|
||||||
import styles from './Button.module.css';
|
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';
|
variant?: 'primary' | 'secondary' | 'outline';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { render, screen, cleanup } from '@testing-library/react';
|
import { render, screen, cleanup } from '@testing-library/react';
|
||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
describe('Button', () => {
|
describe('Button', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { useTypingEffect } from './useTypingEffect';
|
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...',
|
sending: 'Wird gesendet...',
|
||||||
success: 'Nachricht erfolgreich gesendet! Ich melde mich bald bei Ihnen.',
|
success: 'Nachricht erfolgreich gesendet! Ich melde mich bald bei Ihnen.',
|
||||||
error: 'Fehler beim Senden. Bitte versuchen Sie es erneut oder kontaktieren Sie mich direkt.',
|
error: 'Fehler beim Senden. Bitte versuchen Sie es erneut oder kontaktieren Sie mich direkt.',
|
||||||
|
rateLimit: 'Zu viele Anfragen. Bitte warten Sie einen Moment.',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
title: 'Kontaktdaten',
|
title: 'Kontaktdaten',
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const en: Translations = {
|
|||||||
sending: 'Sending...',
|
sending: 'Sending...',
|
||||||
success: 'Message sent successfully! I\'ll get back to you soon.',
|
success: 'Message sent successfully! I\'ll get back to you soon.',
|
||||||
error: 'Error sending message. Please try again or contact me directly.',
|
error: 'Error sending message. Please try again or contact me directly.',
|
||||||
|
rateLimit: 'Too many requests. Please wait a moment.',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
title: 'Contact Info',
|
title: 'Contact Info',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
|
|||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import emailjs from "@emailjs/browser";
|
import emailjs from "@emailjs/browser";
|
||||||
import { useTranslation } from "../i18n";
|
import { useTranslation } from "../i18n";
|
||||||
|
import { useRateLimit } from "../hooks";
|
||||||
import { config } from "../config";
|
import { config } from "../config";
|
||||||
import { Button, Input, Textarea } from "../components/ui";
|
import { Button, Input, Textarea } from "../components/ui";
|
||||||
import { sanitizeInput } from "../utils/security";
|
import { sanitizeInput } from "../utils/security";
|
||||||
@@ -38,6 +39,8 @@ export function Contact() {
|
|||||||
const [submitStatus, setSubmitStatus] = useState<
|
const [submitStatus, setSubmitStatus] = useState<
|
||||||
"idle" | "success" | "error"
|
"idle" | "success" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
const [rateLimitError, setRateLimitError] = useState(false);
|
||||||
|
const { checkRateLimit } = useRateLimit("contact-form", 60000); // 1 minute cooldown
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors: FormErrors = {};
|
const newErrors: FormErrors = {};
|
||||||
@@ -73,8 +76,14 @@ export function Contact() {
|
|||||||
const handleSubmit = async (e: FormEvent) => {
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
setRateLimitError(false);
|
||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
if (!checkRateLimit()) {
|
||||||
|
setRateLimitError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
setSubmitStatus("idle");
|
setSubmitStatus("idle");
|
||||||
|
|
||||||
@@ -215,6 +224,16 @@ export function Contact() {
|
|||||||
{t.contact.form.error}
|
{t.contact.form.error}
|
||||||
</motion.p>
|
</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>
|
</form>
|
||||||
</motion.div>
|
</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