Skip to content

Commit b4ada4d

Browse files
committed
add reCAPTCHA validation and profile link checks in user profile routes
1 parent d7616ca commit b4ada4d

4 files changed

Lines changed: 195 additions & 3 deletions

File tree

app/api/user/profile/route.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { cloudinaryV2 } from "@/c";
99
import dbConnect from "@/lib/db";
1010
import User from "@/models/User";
1111
import Team from "@/models/Team";
12+
import { verifyRecaptcha } from "@/lib/recaptcha";
1213

1314
// Configure route
1415
export const dynamic = "force-dynamic";
@@ -98,6 +99,41 @@ async function deleteFromCloudinary(
9899
}
99100
}
100101

102+
// Validate an optional profile link. Empty/undefined is allowed (the field can
103+
// be cleared); a provided value must be a well-formed absolute http(s) URL with
104+
// a real hostname — non-web schemes (javascript:, data:, mailto:, …) are
105+
// rejected so we never store a value that breaks or could be used for injection.
106+
function isValidLinkUrl(value: unknown): boolean {
107+
if (value === undefined || value === null) return true;
108+
if (typeof value !== "string") return false;
109+
const trimmed = value.trim();
110+
if (!trimmed) return true;
111+
let parsed: URL;
112+
try {
113+
parsed = new URL(trimmed);
114+
} catch {
115+
return false;
116+
}
117+
return (
118+
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
119+
parsed.hostname.includes(".") &&
120+
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
121+
);
122+
}
123+
124+
// Same as isValidLinkUrl, but the host must be (a subdomain of) one of
125+
// `domains`. Used for fields that must point at a specific site (GitHub /
126+
// LinkedIn). A leading `www.` is ignored.
127+
function isValidLinkDomain(value: unknown, domains: string[]): boolean {
128+
if (value === undefined || value === null) return true;
129+
if (typeof value !== "string" || !value.trim()) return true;
130+
if (!isValidLinkUrl(value)) return false;
131+
const host = new URL(value.trim())
132+
.hostname.toLowerCase()
133+
.replace(/^www\./, "");
134+
return domains.some((d) => host === d || host.endsWith(`.${d}`));
135+
}
136+
101137
/**
102138
* GET /api/user/profile
103139
* Get authenticated user's profile
@@ -183,6 +219,29 @@ export async function PUT(request: NextRequest) {
183219

184220
const body = await request.json();
185221

222+
// reCAPTCHA v3 background score check — reject likely-bot/scripted uploads.
223+
const captcha = await verifyRecaptcha(
224+
body.recaptcha_token,
225+
"update_profile",
226+
);
227+
if (!captcha.ok) {
228+
console.warn(
229+
"[user/profile] reCAPTCHA rejected:",
230+
captcha.reason,
231+
captcha.score,
232+
);
233+
return NextResponse.json(
234+
{
235+
message: "reCAPTCHA verification failed",
236+
error: {
237+
code: "recaptcha_failed",
238+
message: "reCAPTCHA verification failed",
239+
},
240+
},
241+
{ status: 400 },
242+
);
243+
}
244+
186245
// Fields that can be updated
187246
const {
188247
name,
@@ -200,6 +259,37 @@ export async function PUT(request: NextRequest) {
200259
isLooking,
201260
} = body;
202261

262+
// Validate profile links. Empty clears the field; a provided value must be
263+
// a well-formed http(s) URL (and the correct site for GitHub / LinkedIn).
264+
if (!isValidLinkDomain(github_link, ["github.com"])) {
265+
return createErrorResponse(
266+
"Please enter a valid GitHub URL (e.g. https://github.com/username)",
267+
"invalid_url",
268+
400,
269+
);
270+
}
271+
if (!isValidLinkDomain(linkedin_link, ["linkedin.com"])) {
272+
return createErrorResponse(
273+
"Please enter a valid LinkedIn URL (e.g. https://linkedin.com/in/username)",
274+
"invalid_url",
275+
400,
276+
);
277+
}
278+
if (!isValidLinkUrl(portfolio_link)) {
279+
return createErrorResponse(
280+
"Please enter a valid portfolio URL (https://…)",
281+
"invalid_url",
282+
400,
283+
);
284+
}
285+
if (!isValidLinkUrl(ctf_profile)) {
286+
return createErrorResponse(
287+
"Please enter a valid CTF profile URL (https://…)",
288+
"invalid_url",
289+
400,
290+
);
291+
}
292+
203293
await dbConnect();
204294
const user = await User.findOne({ uid: authResult.user.uid });
205295

app/api/users/[id]/route.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
requireAdmin,
1313
requireEmailVerified,
1414
} from "@/lib/middleware/auth";
15+
import { verifyRecaptcha } from "@/lib/recaptcha";
1516

1617
export const dynamic = "force-dynamic";
1718
export const runtime = "nodejs"; // specify nodejs runtime
@@ -144,6 +145,41 @@ const parseForm = async (
144145
return { fields, files };
145146
};
146147

148+
// Validate an optional profile link. Empty/undefined is allowed (the field can
149+
// be cleared); a provided value must be a well-formed absolute http(s) URL with
150+
// a real hostname — non-web schemes (javascript:, data:, mailto:, …) are
151+
// rejected so we never store a value that breaks or could be used for injection.
152+
function isValidLinkUrl(value: unknown): boolean {
153+
if (value === undefined || value === null) return true;
154+
if (typeof value !== "string") return false;
155+
const trimmed = value.trim();
156+
if (!trimmed) return true;
157+
let parsed: URL;
158+
try {
159+
parsed = new URL(trimmed);
160+
} catch {
161+
return false;
162+
}
163+
return (
164+
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
165+
parsed.hostname.includes(".") &&
166+
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
167+
);
168+
}
169+
170+
// Same as isValidLinkUrl, but the host must be (a subdomain of) one of
171+
// `domains`. Used for fields that must point at a specific site (GitHub /
172+
// LinkedIn). A leading `www.` is ignored.
173+
function isValidLinkDomain(value: unknown, domains: string[]): boolean {
174+
if (value === undefined || value === null) return true;
175+
if (typeof value !== "string" || !value.trim()) return true;
176+
if (!isValidLinkUrl(value)) return false;
177+
const host = new URL(value.trim())
178+
.hostname.toLowerCase()
179+
.replace(/^www\./, "");
180+
return domains.some((d) => host === d || host.endsWith(`.${d}`));
181+
}
182+
147183
export async function GET(
148184
req: NextRequest,
149185
{ params }: { params: { id: string } },
@@ -260,7 +296,70 @@ export async function PUT(
260296
}
261297

262298
const { fields, files } = await parseForm(req);
299+
300+
// reCAPTCHA v3 background score check — reject likely-bot/scripted uploads.
301+
const captcha = await verifyRecaptcha(fields.recaptcha_token, "update_user");
302+
if (!captcha.ok) {
303+
console.warn(
304+
"[users/[id]] reCAPTCHA rejected:",
305+
captcha.reason,
306+
captcha.score,
307+
);
308+
return NextResponse.json(
309+
{
310+
message: "reCAPTCHA verification failed",
311+
status: "error",
312+
error: {
313+
code: "recaptcha_failed",
314+
message: "reCAPTCHA verification failed",
315+
},
316+
},
317+
{ status: 400 },
318+
);
319+
}
320+
263321
const updates: Record<string, any> = { ...fields };
322+
delete updates.recaptcha_token;
323+
324+
// Validate profile links. Empty clears the field; a provided value must be
325+
// a well-formed http(s) URL (and the correct site for GitHub / LinkedIn).
326+
if (!isValidLinkDomain(updates.github_link, ["github.com"])) {
327+
return NextResponse.json(
328+
{
329+
message: "Please enter a valid GitHub URL (e.g. https://github.com/username)",
330+
status: "error",
331+
},
332+
{ status: 400 },
333+
);
334+
}
335+
if (!isValidLinkDomain(updates.linkedin_link, ["linkedin.com"])) {
336+
return NextResponse.json(
337+
{
338+
message:
339+
"Please enter a valid LinkedIn URL (e.g. https://linkedin.com/in/username)",
340+
status: "error",
341+
},
342+
{ status: 400 },
343+
);
344+
}
345+
if (!isValidLinkUrl(updates.portfolio_link)) {
346+
return NextResponse.json(
347+
{
348+
message: "Please enter a valid portfolio URL (https://…)",
349+
status: "error",
350+
},
351+
{ status: 400 },
352+
);
353+
}
354+
if (!isValidLinkUrl(updates.ctf_profile)) {
355+
return NextResponse.json(
356+
{
357+
message: "Please enter a valid CTF profile URL (https://…)",
358+
status: "error",
359+
},
360+
{ status: 400 },
361+
);
362+
}
264363

265364
await dbConnect();
266365

components/registration/profile-container.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { HudFrame } from "./hud-frame";
44
import { useState, useEffect, useMemo } from "react";
55
import { useRouter } from "next/navigation";
66
import { useAuth } from "@/hooks/use-auth";
7+
import { useRecaptcha } from "@/hooks/use-recaptcha";
78
import {
89
Home,
910
Lock,
@@ -42,6 +43,7 @@ import { DISCORD_USERNAME_REGEX, FILE_SIZE } from "@/lib/constants";
4243

4344
export function ProfileContainer() {
4445
const { user, isAuthenticated, refreshUser, getToken } = useAuth();
46+
const { executeRecaptcha } = useRecaptcha();
4547
const { toast } = useToast();
4648
const router = useRouter();
4749
const [profileData, setProfileData] = useState({
@@ -297,6 +299,9 @@ export function ProfileContainer() {
297299
photoBase64 = await toBase64(profilePhoto);
298300
}
299301

302+
// reCAPTCHA v3 background token — scored server-side, no user interaction.
303+
const recaptchaToken = await executeRecaptcha("update_profile");
304+
300305
const payload = {
301306
name: profileData.name,
302307
email: profileData.email,
@@ -312,6 +317,7 @@ export function ProfileContainer() {
312317
isLooking: profileData.isLooking,
313318
resume: resumeBase64,
314319
profile_picture: photoBase64,
320+
recaptcha_token: recaptchaToken,
315321
};
316322

317323
// Call Next.js API route

lib/rate-limit.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import RateLimit from "@/models/RateLimit";
66
* Resolve the real client IP.
77
*/
88
export function getClientIp(request: Request): string {
9-
const netlify = request.headers.get("x-nf-client-connection-ip");
10-
if (netlify) return netlify.trim();
11-
129
const headers = Object.fromEntries(request.headers);
1310
return requestIp.getClientIp({ headers })?.trim() || "unknown";
1411
}

0 commit comments

Comments
 (0)