@@ -12,6 +12,7 @@ import {
1212 requireAdmin ,
1313 requireEmailVerified ,
1414} from "@/lib/middleware/auth" ;
15+ import { verifyRecaptcha } from "@/lib/recaptcha" ;
1516
1617export const dynamic = "force-dynamic" ;
1718export 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 - z A - Z 0 - 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 ( / ^ w w w \. / , "" ) ;
180+ return domains . some ( ( d ) => host === d || host . endsWith ( `.${ d } ` ) ) ;
181+ }
182+
147183export 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
0 commit comments