Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/components/mentor/MentorForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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") !== ""
);
};

Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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("");
Expand Down
46 changes: 43 additions & 3 deletions src/pages/Portfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
};
Expand Down
16 changes: 4 additions & 12 deletions src/pages/PublicPortfolio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,7 @@ const parseGithubUsername = (url: string) => {

const normalizeArray = <T,>(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();
Expand Down Expand Up @@ -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<Achievement>(pd.achievements),
projects: normalizeArray<Project>(pd.projects).map((p: Project) => ({ ...p, url: sanitizeUrl(p.url) })),
projects: normalizeArray<Project>(pd.projects).map((p: Project) => ({ ...p, url: validateAndNormalizeUrl(p.url) })),
Comment thread
kumudasrip marked this conversation as resolved.
learning_progress: {
focus:
typeof progress?.focus === "string"
Expand Down
37 changes: 37 additions & 0 deletions src/utils/urlValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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,<html>')).toBe('');
expect(validateAndNormalizeUrl('ftp://example.com')).toBe('');

Check failure on line 27 in src/utils/urlValidation.test.ts

View workflow job for this annotation

GitHub Actions / test

[frontend] src/utils/urlValidation.test.ts > validateAndNormalizeUrl > returns empty string for invalid URLs

AssertionError: expected 'https://ftp//example.com' to be '' // Object.is equality - Expected + Received + https://ftp//example.com ❯ src/utils/urlValidation.test.ts:27:58
});

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('');

Check failure on line 35 in src/utils/urlValidation.test.ts

View workflow job for this annotation

GitHub Actions / test

[frontend] src/utils/urlValidation.test.ts > validateAndNormalizeUrl > checks for required domains if provided

AssertionError: expected 'https://notlinkedin.com/in/user' to be '' // Object.is equality - Expected + Received + https://notlinkedin.com/in/user ❯ src/utils/urlValidation.test.ts:35:88
});
});
40 changes: 40 additions & 0 deletions src/utils/urlValidation.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Comment thread
kumudasrip marked this conversation as resolved.

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 '';
Comment thread
kumudasrip marked this conversation as resolved.
}
}

return parsedUrl.toString();
} catch (e) {
// URL parsing failed, meaning it's highly malformed
return '';
}
};
Loading