Skip to content
Merged
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
61 changes: 43 additions & 18 deletions app/api/registration/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,33 @@ const validateAge = (age: string) => {
const ageNum = parseInt(age);
return !isNaN(ageNum) && ageNum > 0 && ageNum < 120;
};
const validateURL = (url: string) => {

const isValidLinkUrl = (value: unknown): boolean => {
if (value === undefined || value === null) return true;
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return true;
let parsed: URL;
try {
const parsedUrl = new URL(url);
// Check for valid hostname (at least one dot and valid characters)
if (
!parsedUrl.hostname.includes(".") ||
!/^[a-zA-Z0-9.-]+$/.test(parsedUrl.hostname)
) {
return false;
}
return true;
parsed = new URL(trimmed);
} catch {
return false;
}
return (
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
parsed.hostname.includes(".") &&
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
);
};

const isValidLinkDomain = (value: unknown, domains: string[]): boolean => {
if (value === undefined || value === null) return true;
if (typeof value !== "string" || !value.trim()) return true;
if (!isValidLinkUrl(value)) return false;
const host = new URL(value.trim()).hostname
.toLowerCase()
.replace(/^www\./, "");
return domains.some((d) => host === d || host.endsWith(`.${d}`));
};
const validateReferralCode = (code: string) => {
const referralCodesEnv = process.env.VALID_REFERRAL_CODES || "";
Expand Down Expand Up @@ -216,7 +229,7 @@ export async function POST(request: Request) {
message: "Too many requests. Please try again later.",
error: "Rate limit exceeded",
},
{ status: 429 }
{ status: 429 },
);
}

Expand All @@ -239,7 +252,11 @@ export async function POST(request: Request) {
// the score + action, before any Firebase/Cloudinary/DB writes.
const captcha = await verifyRecaptcha(recaptcha_token, "register");
if (!captcha.ok) {
console.warn("[registration] reCAPTCHA rejected:", captcha.reason, captcha.score);
console.warn(
"[registration] reCAPTCHA rejected:",
captcha.reason,
captcha.score,
);
return NextResponse.json(
{
message: "reCAPTCHA validation failed",
Expand Down Expand Up @@ -390,18 +407,23 @@ export async function POST(request: Request) {
}

// Validate password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
const passwordRegex =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
if (!passwordRegex.test(password)) {
return NextResponse.json(
{
message: "Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
message:
"Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
error: "Weak password",
},
{ status: 400 },
);
}

if (data.github_link && !validateURL(data.github_link)) {
if (
data.github_link &&
!isValidLinkDomain(data.github_link, ["github.com"])
) {
return NextResponse.json(
{
message: "Invalid GitHub profile URL format.",
Expand All @@ -411,7 +433,10 @@ export async function POST(request: Request) {
);
}

if (data.linkedin_link && !validateURL(data.linkedin_link)) {
if (
data.linkedin_link &&
!isValidLinkDomain(data.linkedin_link, ["linkedin.com"])
) {
return NextResponse.json(
{
message: "Invalid LinkedIn profile URL format.",
Expand All @@ -421,7 +446,7 @@ export async function POST(request: Request) {
);
}

if (data.ctf_profile && !validateURL(data.ctf_profile)) {
if (data.ctf_profile && !isValidLinkUrl(data.ctf_profile)) {
return NextResponse.json(
{
message: "Invalid CTF profile URL format.",
Expand All @@ -431,7 +456,7 @@ export async function POST(request: Request) {
);
}

if (data.portfolio_link && !validateURL(data.portfolio_link)) {
if (data.portfolio_link && !isValidLinkUrl(data.portfolio_link)) {
return NextResponse.json(
{
message: "Invalid Portfolio URL format.",
Expand Down
90 changes: 90 additions & 0 deletions app/api/user/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { cloudinaryV2 } from "@/c";
import dbConnect from "@/lib/db";
import User from "@/models/User";
import Team from "@/models/Team";
import { verifyRecaptcha } from "@/lib/recaptcha";

// Configure route
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -98,6 +99,41 @@ async function deleteFromCloudinary(
}
}

// Validate an optional profile link. Empty/undefined is allowed (the field can
// be cleared); a provided value must be a well-formed absolute http(s) URL with
// a real hostname — non-web schemes (javascript:, data:, mailto:, …) are
// rejected so we never store a value that breaks or could be used for injection.
function isValidLinkUrl(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return true;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return false;
}
return (
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
parsed.hostname.includes(".") &&
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
);
}

// Same as isValidLinkUrl, but the host must be (a subdomain of) one of
// `domains`. Used for fields that must point at a specific site (GitHub /
// LinkedIn). A leading `www.` is ignored.
function isValidLinkDomain(value: unknown, domains: string[]): boolean {
if (value === undefined || value === null) return true;
if (typeof value !== "string" || !value.trim()) return true;
if (!isValidLinkUrl(value)) return false;
const host = new URL(value.trim())
.hostname.toLowerCase()
.replace(/^www\./, "");
return domains.some((d) => host === d || host.endsWith(`.${d}`));
}

/**
* GET /api/user/profile
* Get authenticated user's profile
Expand Down Expand Up @@ -183,6 +219,29 @@ export async function PUT(request: NextRequest) {

const body = await request.json();

// reCAPTCHA v3 background score check — reject likely-bot/scripted uploads.
const captcha = await verifyRecaptcha(
body.recaptcha_token,
"update_profile",
);
if (!captcha.ok) {
console.warn(
"[user/profile] reCAPTCHA rejected:",
captcha.reason,
captcha.score,
);
return NextResponse.json(
{
message: "reCAPTCHA verification failed",
error: {
code: "recaptcha_failed",
message: "reCAPTCHA verification failed",
},
},
{ status: 400 },
);
}

// Fields that can be updated
const {
name,
Expand All @@ -200,6 +259,37 @@ export async function PUT(request: NextRequest) {
isLooking,
} = body;

// Validate profile links. Empty clears the field; a provided value must be
// a well-formed http(s) URL (and the correct site for GitHub / LinkedIn).
if (!isValidLinkDomain(github_link, ["github.com"])) {
return createErrorResponse(
"Please enter a valid GitHub URL (e.g. https://github.com/username)",
"invalid_url",
400,
);
}
if (!isValidLinkDomain(linkedin_link, ["linkedin.com"])) {
return createErrorResponse(
"Please enter a valid LinkedIn URL (e.g. https://linkedin.com/in/username)",
"invalid_url",
400,
);
}
if (!isValidLinkUrl(portfolio_link)) {
return createErrorResponse(
"Please enter a valid portfolio URL (https://…)",
"invalid_url",
400,
);
}
if (!isValidLinkUrl(ctf_profile)) {
return createErrorResponse(
"Please enter a valid CTF profile URL (https://…)",
"invalid_url",
400,
);
}

await dbConnect();
const user = await User.findOne({ uid: authResult.user.uid });

Expand Down
34 changes: 27 additions & 7 deletions app/api/user/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,33 @@ const validatePassword = (password: string) => {
if (!/[^A-Za-z0-9]/.test(password)) return false;
return true;
};
const validateURL = (url: string) => {

const isValidLinkUrl = (value: unknown): boolean => {
if (value === undefined || value === null) return true;
if (typeof value !== "string") return false;
const trimmed = value.trim();
if (!trimmed) return true;
let parsed: URL;
try {
new URL(url);
return true;
parsed = new URL(trimmed);
} catch {
return false;
}
return (
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
parsed.hostname.includes(".") &&
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
);
};

const isValidLinkDomain = (value: unknown, domains: string[]): boolean => {
if (value === undefined || value === null) return true;
if (typeof value !== "string" || !value.trim()) return true;
if (!isValidLinkUrl(value)) return false;
const host = new URL(value.trim()).hostname
.toLowerCase()
.replace(/^www\./, "");
return domains.some((d) => host === d || host.endsWith(`.${d}`));
};

// Upload base64 file to Cloudinary
Expand Down Expand Up @@ -269,16 +289,16 @@ export async function POST(request: Request) {
}

// Optional URL validations
if (github_link && !validateURL(github_link)) {
if (github_link && !isValidLinkDomain(github_link, ["github.com"])) {
errors.github_link = "Invalid GitHub URL";
}
if (linkedin_link && !validateURL(linkedin_link)) {
if (linkedin_link && !isValidLinkDomain(linkedin_link, ["linkedin.com"])) {
errors.linkedin_link = "Invalid LinkedIn URL";
}
if (portfolio_link && !validateURL(portfolio_link)) {
if (portfolio_link && !isValidLinkUrl(portfolio_link)) {
errors.portfolio_link = "Invalid portfolio URL";
}
if (ctf_profile && !validateURL(ctf_profile)) {
if (ctf_profile && !isValidLinkUrl(ctf_profile)) {
errors.ctf_profile = "Invalid CTF profile URL";
}

Expand Down
Loading
Loading