diff --git a/public/_redirects b/public/_redirects index f28ae0b..c998f1e 100644 --- a/public/_redirects +++ b/public/_redirects @@ -1,2 +1,7 @@ /* /index.html 200 + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + X-XSS-Protection: 1; mode=block + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: geolocation=(), microphone=(), camera=() diff --git a/src/App.tsx b/src/App.tsx index 788d6d3..12cd6e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { Toaster } from "@/components/ui/toaster"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import ErrorBoundary from "@/components/ErrorBoundary"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import Index from "./pages/Index"; import InteractiveIndex from "./components/InteractiveIndex"; @@ -37,11 +38,12 @@ import NotFound from "./pages/NotFound"; const queryClient = new QueryClient(); const App = () => ( - - - - - + + + + + + } /> } /> @@ -80,9 +82,10 @@ const App = () => ( {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> - - - + + + + ); export default App; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..d35fa41 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,76 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle, RotateCcw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + // Update state so the next render will show the fallback UI + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Log error to console in development + if (process.env.NODE_ENV === 'development') { + console.error('Error caught by boundary:', error, errorInfo); + } + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + + + + + + Something went wrong + + + + We encountered an unexpected error. Please try refreshing the page or contact support if the problem persists. + + + + + Try Again + + window.location.reload()}> + Refresh Page + + + + + + ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/src/components/SocialShare.tsx b/src/components/SocialShare.tsx index f48e6ff..a559e0a 100644 --- a/src/components/SocialShare.tsx +++ b/src/components/SocialShare.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Share2, Copy, QrCode, Twitter, Facebook, Linkedin } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import QRCode from 'qrcode'; +import { validateShareData, rateLimiter } from '@/utils/security'; interface SocialShareProps { title: string; @@ -21,20 +22,38 @@ const SocialShare: React.FC = ({ const [isOpen, setIsOpen] = useState(false); const { toast } = useToast(); + // Validate and sanitize share data + const shareData = validateShareData(title, description, url); + useEffect(() => { if (isOpen) { - QRCode.toDataURL(url, { + QRCode.toDataURL(shareData.url, { width: 200, margin: 2, color: { dark: '#000000', light: '#FFFFFF' } - }).then(setQrCodeDataUrl); + }).then(setQrCodeDataUrl).catch(() => { + toast({ + title: "Error", + description: "Failed to generate QR code", + variant: "destructive", + }); + }); } - }, [url, isOpen]); + }, [shareData.url, isOpen, toast]); const copyToClipboard = async (text: string) => { + if (!rateLimiter.isAllowed('clipboard', 10, 60000)) { + toast({ + title: "Too many attempts", + description: "Please wait before copying again", + variant: "destructive", + }); + return; + } + try { await navigator.clipboard.writeText(text); toast({ @@ -51,6 +70,15 @@ const SocialShare: React.FC = ({ }; const copyQRCode = async () => { + if (!rateLimiter.isAllowed('qr-copy', 5, 60000)) { + toast({ + title: "Too many attempts", + description: "Please wait before copying again", + variant: "destructive", + }); + return; + } + try { const response = await fetch(qrCodeDataUrl); const blob = await response.blob(); @@ -71,9 +99,22 @@ const SocialShare: React.FC = ({ }; const shareLinks = { - twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`, - facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, - linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}` + twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareData.title)}&url=${encodeURIComponent(shareData.url)}`, + facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareData.url)}`, + linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareData.url)}` + }; + + const handleSocialShare = (platform: string, url: string) => { + if (!rateLimiter.isAllowed('social-share', 10, 60000)) { + toast({ + title: "Too many attempts", + description: "Please wait before sharing again", + variant: "destructive", + }); + return; + } + + window.open(url, '_blank', 'noopener,noreferrer'); }; return ( @@ -89,7 +130,7 @@ const SocialShare: React.FC = ({ window.open(shareLinks.twitter, '_blank')} + onClick={() => handleSocialShare('twitter', shareLinks.twitter)} className="flex items-center gap-2" > @@ -99,7 +140,7 @@ const SocialShare: React.FC = ({ window.open(shareLinks.facebook, '_blank')} + onClick={() => handleSocialShare('facebook', shareLinks.facebook)} className="flex items-center gap-2" > @@ -109,7 +150,7 @@ const SocialShare: React.FC = ({ window.open(shareLinks.linkedin, '_blank')} + onClick={() => handleSocialShare('linkedin', shareLinks.linkedin)} className="flex items-center gap-2" > @@ -120,7 +161,7 @@ const SocialShare: React.FC = ({ copyToClipboard(url)} + onClick={() => copyToClipboard(shareData.url)} className="flex items-center gap-2" > diff --git a/src/utils/security.ts b/src/utils/security.ts new file mode 100644 index 0000000..a8bb895 --- /dev/null +++ b/src/utils/security.ts @@ -0,0 +1,76 @@ +/** + * Security utilities for input validation and sanitization + */ + +// URL validation for social sharing +export const isValidUrl = (url: string): boolean => { + try { + const urlObj = new URL(url); + // Only allow http and https protocols + return ['http:', 'https:'].includes(urlObj.protocol); + } catch { + return false; + } +}; + +// Sanitize text input by removing potentially harmful characters +export const sanitizeText = (text: string): string => { + return text + .replace(/[<>]/g, '') // Remove angle brackets to prevent XSS + .replace(/javascript:/gi, '') // Remove javascript: protocol + .replace(/data:/gi, '') // Remove data: protocol + .trim(); +}; + +// Validate and sanitize URL for sharing +export const sanitizeUrl = (url: string): string => { + if (!isValidUrl(url)) { + return window.location.href; // Fallback to current page + } + + try { + const urlObj = new URL(url); + // Remove any potentially harmful query parameters + const cleanUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`; + return cleanUrl; + } catch { + return window.location.href; + } +}; + +// Validate social share data +export const validateShareData = (title: string, description: string, url?: string) => { + return { + title: sanitizeText(title).slice(0, 100), // Limit title length + description: sanitizeText(description).slice(0, 200), // Limit description length + url: url ? sanitizeUrl(url) : window.location.href + }; +}; + +// Rate limiting for actions (simple client-side implementation) +class RateLimiter { + private actions: Map = new Map(); + + isAllowed(action: string, maxAttempts: number = 5, windowMs: number = 60000): boolean { + const now = Date.now(); + const windowStart = now - windowMs; + + if (!this.actions.has(action)) { + this.actions.set(action, []); + } + + const attempts = this.actions.get(action)!; + // Remove old attempts outside the window + const recentAttempts = attempts.filter(time => time > windowStart); + + if (recentAttempts.length >= maxAttempts) { + return false; + } + + recentAttempts.push(now); + this.actions.set(action, recentAttempts); + return true; + } +} + +export const rateLimiter = new RateLimiter(); \ No newline at end of file
+ We encountered an unexpected error. Please try refreshing the page or contact support if the problem persists. +