diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 48a437e..a98cbd0 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -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. diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index a9498d9..43a5144 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -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 { +interface ButtonProps extends Omit, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> { variant?: 'primary' | 'secondary' | 'outline'; size?: 'sm' | 'md' | 'lg'; children: ReactNode; diff --git a/src/components/ui/__tests__/Button.test.tsx b/src/components/ui/__tests__/Button.test.tsx index d97e8a2..3fa3714 100644 --- a/src/components/ui/__tests__/Button.test.tsx +++ b/src/components/ui/__tests__/Button.test.tsx @@ -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(() => { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 2352175..a9b16e0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export { useTypingEffect } from './useTypingEffect'; +export { useRateLimit } from './useRateLimit'; diff --git a/src/hooks/useRateLimit.test.ts b/src/hooks/useRateLimit.test.ts new file mode 100644 index 0000000..ea75f2c --- /dev/null +++ b/src/hooks/useRateLimit.test.ts @@ -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); + }); +}); diff --git a/src/hooks/useRateLimit.ts b/src/hooks/useRateLimit.ts new file mode 100644 index 0000000..8648a0e --- /dev/null +++ b/src/hooks/useRateLimit.ts @@ -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 }; +} diff --git a/src/i18n/de.ts b/src/i18n/de.ts index bc345f5..0b42d51 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -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', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 27651e3..20c8a34 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/pages/Contact.tsx b/src/pages/Contact.tsx index ef7bc33..d11befb 100644 --- a/src/pages/Contact.tsx +++ b/src/pages/Contact.tsx @@ -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} )} + + {rateLimitError && ( + + {t.contact.form.rateLimit} + + )} diff --git a/verification.png b/verification.png new file mode 100644 index 0000000..c09c6c6 Binary files /dev/null and b/verification.png differ diff --git a/verification/verify_rate_limit.py b/verification/verify_rate_limit.py new file mode 100644 index 0000000..71db660 --- /dev/null +++ b/verification/verify_rate_limit.py @@ -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()