From b473d5bdfb8cbae4fcc89e6c2d99cef9ca979fe4 Mon Sep 17 00:00:00 2001 From: kumudasrip Date: Fri, 7 Aug 2026 20:58:35 +0530 Subject: [PATCH] fix:Improve validation for portfolio URLs --- src/components/mentor/MentorForm.tsx | 11 ++++--- src/pages/Portfolio.tsx | 46 ++++++++++++++++++++++++++-- src/pages/PublicPortfolio.tsx | 16 +++------- src/utils/urlValidation.test.ts | 37 ++++++++++++++++++++++ src/utils/urlValidation.ts | 40 ++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 20 deletions(-) create mode 100644 src/utils/urlValidation.test.ts create mode 100644 src/utils/urlValidation.ts diff --git a/src/components/mentor/MentorForm.tsx b/src/components/mentor/MentorForm.tsx index 6f94c175..0b6ec5e5 100644 --- a/src/components/mentor/MentorForm.tsx +++ b/src/components/mentor/MentorForm.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { motion } from "framer-motion"; import { CheckCircle2, Loader2, Plus, X } from "lucide-react"; import { supabase } from "@/integrations/supabase/client"; +import { validateAndNormalizeUrl } from "@/utils/urlValidation"; const DEFAULT_SKILLS = [ "JavaScript", "TypeScript", "React", "Node.js", "Python", @@ -117,8 +118,8 @@ export default function MentorForm() { const validateExperience = () => { return ( - formData.github.trim() !== "" && - formData.linkedin.trim() !== "" + validateAndNormalizeUrl(formData.github.trim(), "github.com") !== "" && + validateAndNormalizeUrl(formData.linkedin.trim(), "linkedin.com") !== "" ); }; @@ -157,8 +158,8 @@ export default function MentorForm() { full_name: formData.full_name, college: formData.college, bio: formData.bio, - github: formData.github, - linkedin: formData.linkedin, + github: validateAndNormalizeUrl(formData.github.trim(), "github.com"), + linkedin: validateAndNormalizeUrl(formData.linkedin.trim(), "linkedin.com"), skills: formData.skills, mentorship_types: formData.mentorship_types, }, @@ -389,7 +390,7 @@ export default function MentorForm() { return; } if (step === 2 && !validateExperience()) { - setError("Please fill GitHub and LinkedIn profiles"); + setError("Please provide valid GitHub and LinkedIn URLs"); return; } setError(""); diff --git a/src/pages/Portfolio.tsx b/src/pages/Portfolio.tsx index 62010a77..7f39cc4b 100644 --- a/src/pages/Portfolio.tsx +++ b/src/pages/Portfolio.tsx @@ -21,6 +21,7 @@ import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/hooks/use-toast"; import { useAuth } from "@/contexts/useAuth"; import { supabase } from "@/integrations/supabase/client"; +import { validateAndNormalizeUrl } from "@/utils/urlValidation"; type Achievement = { title: string; @@ -281,18 +282,57 @@ const Portfolio = () => { return; } + const githubUrl = form.github_url.trim(); + const normalizedGithub = githubUrl ? validateAndNormalizeUrl(githubUrl, "github.com") : ""; + if (githubUrl && !normalizedGithub) { + setSaving(false); + toast({ + title: "Invalid GitHub URL", + description: "Please enter a valid GitHub URL.", + variant: "destructive", + }); + return; + } + + const linkedinUrl = form.linkedin_url.trim(); + const normalizedLinkedin = linkedinUrl ? validateAndNormalizeUrl(linkedinUrl, "linkedin.com") : ""; + if (linkedinUrl && !normalizedLinkedin) { + setSaving(false); + toast({ + title: "Invalid LinkedIn URL", + description: "Please enter a valid LinkedIn URL.", + variant: "destructive", + }); + return; + } + + for (const project of form.projects) { + if (project.url.trim() && !validateAndNormalizeUrl(project.url)) { + setSaving(false); + toast({ + title: "Invalid Project URL", + description: `The URL for project "${project.title || 'Untitled'}" is invalid.`, + variant: "destructive", + }); + return; + } + } + const payload = { profile_id: user.id, slug, headline: form.headline.trim(), - github_url: form.github_url.trim(), - linkedin_url: form.linkedin_url.trim(), + github_url: normalizedGithub, + linkedin_url: normalizedLinkedin, skills: form.skills .split(",") .map((skill) => skill.trim()) .filter(Boolean), achievements: form.achievements.filter((item) => item.title.trim()), - projects: form.projects.filter((item) => item.title.trim()), + projects: form.projects.filter((item) => item.title.trim()).map(p => ({ + ...p, + url: p.url.trim() ? validateAndNormalizeUrl(p.url) : "" + })), learning_progress: form.learning_progress, is_published: form.is_published, }; diff --git a/src/pages/PublicPortfolio.tsx b/src/pages/PublicPortfolio.tsx index 4f42953b..557f788d 100644 --- a/src/pages/PublicPortfolio.tsx +++ b/src/pages/PublicPortfolio.tsx @@ -66,15 +66,7 @@ const parseGithubUsername = (url: string) => { const normalizeArray = (value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []); -const sanitizeUrl = (url: string | null | undefined): string => { - if (!url) return ""; - const trimmed = url.trim(); - const lower = trimmed.toLowerCase(); - if (lower.startsWith("javascript:") || lower.startsWith("data:") || lower.startsWith("vbscript:")) { - return ""; - } - return trimmed; -}; +import { validateAndNormalizeUrl } from "@/utils/urlValidation"; const PublicPortfolio = () => { const { slug } = useParams(); @@ -153,11 +145,11 @@ const PublicPortfolio = () => { setPortfolio({ profile_id: pd.profile_id, headline: pd.headline || "", - github_url: sanitizeUrl(pd.github_url), - linkedin_url: sanitizeUrl(pd.linkedin_url), + github_url: validateAndNormalizeUrl(pd.github_url, "github.com"), + linkedin_url: validateAndNormalizeUrl(pd.linkedin_url, "linkedin.com"), skills: pd.skills || [], achievements: normalizeArray(pd.achievements), - projects: normalizeArray(pd.projects).map((p: Project) => ({ ...p, url: sanitizeUrl(p.url) })), + projects: normalizeArray(pd.projects).map((p: Project) => ({ ...p, url: validateAndNormalizeUrl(p.url) })), learning_progress: { focus: typeof progress?.focus === "string" diff --git a/src/utils/urlValidation.test.ts b/src/utils/urlValidation.test.ts new file mode 100644 index 00000000..bc29fb17 --- /dev/null +++ b/src/utils/urlValidation.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { validateAndNormalizeUrl } from './urlValidation'; + +describe('validateAndNormalizeUrl', () => { + it('returns empty string for null, undefined, or empty string', () => { + expect(validateAndNormalizeUrl(null)).toBe(''); + expect(validateAndNormalizeUrl(undefined)).toBe(''); + expect(validateAndNormalizeUrl('')).toBe(''); + expect(validateAndNormalizeUrl(' ')).toBe(''); + }); + + it('prepends https:// if protocol is missing', () => { + expect(validateAndNormalizeUrl('github.com/username')).toBe('https://github.com/username'); + expect(validateAndNormalizeUrl('linkedin.com/in/username')).toBe('https://linkedin.com/in/username'); + expect(validateAndNormalizeUrl('example.com')).toBe('https://example.com/'); + }); + + it('keeps http:// and https:// if present', () => { + expect(validateAndNormalizeUrl('https://github.com/username')).toBe('https://github.com/username'); + expect(validateAndNormalizeUrl('http://example.com')).toBe('http://example.com/'); + }); + + it('returns empty string for invalid URLs', () => { + expect(validateAndNormalizeUrl('not a url')).toBe(''); + expect(validateAndNormalizeUrl('javascript:alert(1)')).toBe(''); + expect(validateAndNormalizeUrl('data:text/html,')).toBe(''); + expect(validateAndNormalizeUrl('ftp://example.com')).toBe(''); + }); + + it('checks for required domains if provided', () => { + expect(validateAndNormalizeUrl('https://github.com/user', 'github.com')).toBe('https://github.com/user'); + expect(validateAndNormalizeUrl('github.com/user', 'github.com')).toBe('https://github.com/user'); + expect(validateAndNormalizeUrl('https://example.com', 'github.com')).toBe(''); + expect(validateAndNormalizeUrl('linkedin.com/in/user', 'linkedin.com')).toBe('https://linkedin.com/in/user'); + expect(validateAndNormalizeUrl('https://notlinkedin.com/in/user', 'linkedin.com')).toBe(''); + }); +}); diff --git a/src/utils/urlValidation.ts b/src/utils/urlValidation.ts new file mode 100644 index 00000000..7027d8b8 --- /dev/null +++ b/src/utils/urlValidation.ts @@ -0,0 +1,40 @@ +/** + * Validates and normalizes URLs to ensure they are safe and correctly formatted. + * If a URL is missing a protocol, 'https://' is prepended. + * If the URL is invalid or uses an unsafe protocol (like javascript:), it returns an empty string. + * + * @param url - The input URL to validate and normalize + * @param requiredDomain - Optional domain string that the URL must include (e.g., 'github.com') + * @returns A normalized, safe URL string, or an empty string if invalid + */ +export const validateAndNormalizeUrl = (url: string | null | undefined, requiredDomain?: string): string => { + if (!url) return ''; + + let trimmedUrl = url.trim(); + + // If the user didn't provide a protocol, assume https + if (!/^https?:\/\//i.test(trimmedUrl)) { + trimmedUrl = `https://${trimmedUrl}`; + } + + try { + const parsedUrl = new URL(trimmedUrl); + + // Only allow http and https protocols + if (!['http:', 'https:'].includes(parsedUrl.protocol)) { + return ''; + } + + // If a specific domain is required, verify it's part of the hostname + if (requiredDomain) { + if (!parsedUrl.hostname.toLowerCase().includes(requiredDomain.toLowerCase())) { + return ''; + } + } + + return parsedUrl.toString(); + } catch (e) { + // URL parsing failed, meaning it's highly malformed + return ''; + } +};