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
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
99 changes: 99 additions & 0 deletions app/api/users/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
requireAdmin,
requireEmailVerified,
} from "@/lib/middleware/auth";
import { verifyRecaptcha } from "@/lib/recaptcha";

export const dynamic = "force-dynamic";
export const runtime = "nodejs"; // specify nodejs runtime
Expand Down Expand Up @@ -144,6 +145,41 @@ const parseForm = async (
return { fields, files };
};

// 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}`));
}

export async function GET(
req: NextRequest,
{ params }: { params: { id: string } },
Expand Down Expand Up @@ -260,7 +296,70 @@ export async function PUT(
}

const { fields, files } = await parseForm(req);

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

const updates: Record<string, any> = { ...fields };
delete updates.recaptcha_token;

// 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(updates.github_link, ["github.com"])) {
return NextResponse.json(
{
message: "Please enter a valid GitHub URL (e.g. https://github.com/username)",
status: "error",
},
{ status: 400 },
);
}
if (!isValidLinkDomain(updates.linkedin_link, ["linkedin.com"])) {
return NextResponse.json(
{
message:
"Please enter a valid LinkedIn URL (e.g. https://linkedin.com/in/username)",
status: "error",
},
{ status: 400 },
);
}
if (!isValidLinkUrl(updates.portfolio_link)) {
return NextResponse.json(
{
message: "Please enter a valid portfolio URL (https://…)",
status: "error",
},
{ status: 400 },
);
}
if (!isValidLinkUrl(updates.ctf_profile)) {
return NextResponse.json(
{
message: "Please enter a valid CTF profile URL (https://…)",
status: "error",
},
{ status: 400 },
);
}

await dbConnect();

Expand Down
6 changes: 6 additions & 0 deletions components/registration/profile-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HudFrame } from "./hud-frame";
import { useState, useEffect, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useRecaptcha } from "@/hooks/use-recaptcha";
import {
Home,
Lock,
Expand Down Expand Up @@ -42,6 +43,7 @@ import { DISCORD_USERNAME_REGEX, FILE_SIZE } from "@/lib/constants";

export function ProfileContainer() {
const { user, isAuthenticated, refreshUser, getToken } = useAuth();
const { executeRecaptcha } = useRecaptcha();
const { toast } = useToast();
const router = useRouter();
const [profileData, setProfileData] = useState({
Expand Down Expand Up @@ -297,6 +299,9 @@ export function ProfileContainer() {
photoBase64 = await toBase64(profilePhoto);
}

// reCAPTCHA v3 background token — scored server-side, no user interaction.
const recaptchaToken = await executeRecaptcha("update_profile");

const payload = {
name: profileData.name,
email: profileData.email,
Expand All @@ -312,6 +317,7 @@ export function ProfileContainer() {
isLooking: profileData.isLooking,
resume: resumeBase64,
profile_picture: photoBase64,
recaptcha_token: recaptchaToken,
};

// Call Next.js API route
Expand Down
3 changes: 0 additions & 3 deletions lib/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ import RateLimit from "@/models/RateLimit";
* Resolve the real client IP.
*/
export function getClientIp(request: Request): string {
const netlify = request.headers.get("x-nf-client-connection-ip");
if (netlify) return netlify.trim();

const headers = Object.fromEntries(request.headers);
return requestIp.getClientIp({ headers })?.trim() || "unknown";
}
Expand Down
Loading