Skip to content

Commit 06e0019

Browse files
authored
Add URL Validation and Security Headers (#42)
* add reCAPTCHA validation and profile link checks in user profile routes * URL Validation * Add Security Headers * remove * add goomgle
1 parent 149bb09 commit 06e0019

3 files changed

Lines changed: 87 additions & 25 deletions

File tree

app/api/registration/route.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,33 @@ const validateAge = (age: string) => {
2323
const ageNum = parseInt(age);
2424
return !isNaN(ageNum) && ageNum > 0 && ageNum < 120;
2525
};
26-
const validateURL = (url: string) => {
26+
27+
const isValidLinkUrl = (value: unknown): boolean => {
28+
if (value === undefined || value === null) return true;
29+
if (typeof value !== "string") return false;
30+
const trimmed = value.trim();
31+
if (!trimmed) return true;
32+
let parsed: URL;
2733
try {
28-
const parsedUrl = new URL(url);
29-
// Check for valid hostname (at least one dot and valid characters)
30-
if (
31-
!parsedUrl.hostname.includes(".") ||
32-
!/^[a-zA-Z0-9.-]+$/.test(parsedUrl.hostname)
33-
) {
34-
return false;
35-
}
36-
return true;
34+
parsed = new URL(trimmed);
3735
} catch {
3836
return false;
3937
}
38+
return (
39+
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
40+
parsed.hostname.includes(".") &&
41+
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
42+
);
43+
};
44+
45+
const isValidLinkDomain = (value: unknown, domains: string[]): boolean => {
46+
if (value === undefined || value === null) return true;
47+
if (typeof value !== "string" || !value.trim()) return true;
48+
if (!isValidLinkUrl(value)) return false;
49+
const host = new URL(value.trim()).hostname
50+
.toLowerCase()
51+
.replace(/^www\./, "");
52+
return domains.some((d) => host === d || host.endsWith(`.${d}`));
4053
};
4154
const validateReferralCode = (code: string) => {
4255
const referralCodesEnv = process.env.VALID_REFERRAL_CODES || "";
@@ -216,7 +229,7 @@ export async function POST(request: Request) {
216229
message: "Too many requests. Please try again later.",
217230
error: "Rate limit exceeded",
218231
},
219-
{ status: 429 }
232+
{ status: 429 },
220233
);
221234
}
222235

@@ -239,7 +252,11 @@ export async function POST(request: Request) {
239252
// the score + action, before any Firebase/Cloudinary/DB writes.
240253
const captcha = await verifyRecaptcha(recaptcha_token, "register");
241254
if (!captcha.ok) {
242-
console.warn("[registration] reCAPTCHA rejected:", captcha.reason, captcha.score);
255+
console.warn(
256+
"[registration] reCAPTCHA rejected:",
257+
captcha.reason,
258+
captcha.score,
259+
);
243260
return NextResponse.json(
244261
{
245262
message: "reCAPTCHA validation failed",
@@ -390,18 +407,23 @@ export async function POST(request: Request) {
390407
}
391408

392409
// Validate password (min 8 chars, 1 uppercase, 1 lowercase, 1 number, 1 special char)
393-
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
410+
const passwordRegex =
411+
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/;
394412
if (!passwordRegex.test(password)) {
395413
return NextResponse.json(
396414
{
397-
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.",
415+
message:
416+
"Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
398417
error: "Weak password",
399418
},
400419
{ status: 400 },
401420
);
402421
}
403422

404-
if (data.github_link && !validateURL(data.github_link)) {
423+
if (
424+
data.github_link &&
425+
!isValidLinkDomain(data.github_link, ["github.com"])
426+
) {
405427
return NextResponse.json(
406428
{
407429
message: "Invalid GitHub profile URL format.",
@@ -411,7 +433,10 @@ export async function POST(request: Request) {
411433
);
412434
}
413435

414-
if (data.linkedin_link && !validateURL(data.linkedin_link)) {
436+
if (
437+
data.linkedin_link &&
438+
!isValidLinkDomain(data.linkedin_link, ["linkedin.com"])
439+
) {
415440
return NextResponse.json(
416441
{
417442
message: "Invalid LinkedIn profile URL format.",
@@ -421,7 +446,7 @@ export async function POST(request: Request) {
421446
);
422447
}
423448

424-
if (data.ctf_profile && !validateURL(data.ctf_profile)) {
449+
if (data.ctf_profile && !isValidLinkUrl(data.ctf_profile)) {
425450
return NextResponse.json(
426451
{
427452
message: "Invalid CTF profile URL format.",
@@ -431,7 +456,7 @@ export async function POST(request: Request) {
431456
);
432457
}
433458

434-
if (data.portfolio_link && !validateURL(data.portfolio_link)) {
459+
if (data.portfolio_link && !isValidLinkUrl(data.portfolio_link)) {
435460
return NextResponse.json(
436461
{
437462
message: "Invalid Portfolio URL format.",

app/api/user/register/route.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,33 @@ const validatePassword = (password: string) => {
3838
if (!/[^A-Za-z0-9]/.test(password)) return false;
3939
return true;
4040
};
41-
const validateURL = (url: string) => {
41+
42+
const isValidLinkUrl = (value: unknown): boolean => {
43+
if (value === undefined || value === null) return true;
44+
if (typeof value !== "string") return false;
45+
const trimmed = value.trim();
46+
if (!trimmed) return true;
47+
let parsed: URL;
4248
try {
43-
new URL(url);
44-
return true;
49+
parsed = new URL(trimmed);
4550
} catch {
4651
return false;
4752
}
53+
return (
54+
(parsed.protocol === "https:" || parsed.protocol === "http:") &&
55+
parsed.hostname.includes(".") &&
56+
/^[a-zA-Z0-9.-]+$/.test(parsed.hostname)
57+
);
58+
};
59+
60+
const isValidLinkDomain = (value: unknown, domains: string[]): boolean => {
61+
if (value === undefined || value === null) return true;
62+
if (typeof value !== "string" || !value.trim()) return true;
63+
if (!isValidLinkUrl(value)) return false;
64+
const host = new URL(value.trim()).hostname
65+
.toLowerCase()
66+
.replace(/^www\./, "");
67+
return domains.some((d) => host === d || host.endsWith(`.${d}`));
4868
};
4969

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

271291
// Optional URL validations
272-
if (github_link && !validateURL(github_link)) {
292+
if (github_link && !isValidLinkDomain(github_link, ["github.com"])) {
273293
errors.github_link = "Invalid GitHub URL";
274294
}
275-
if (linkedin_link && !validateURL(linkedin_link)) {
295+
if (linkedin_link && !isValidLinkDomain(linkedin_link, ["linkedin.com"])) {
276296
errors.linkedin_link = "Invalid LinkedIn URL";
277297
}
278-
if (portfolio_link && !validateURL(portfolio_link)) {
298+
if (portfolio_link && !isValidLinkUrl(portfolio_link)) {
279299
errors.portfolio_link = "Invalid portfolio URL";
280300
}
281-
if (ctf_profile && !validateURL(ctf_profile)) {
301+
if (ctf_profile && !isValidLinkUrl(ctf_profile)) {
282302
errors.ctf_profile = "Invalid CTF profile URL";
283303
}
284304

next.config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
const contentSecurityPolicy = [
2+
"default-src 'self'",
3+
"base-uri 'self'",
4+
"object-src 'none'",
5+
"frame-ancestors 'self'",
6+
"form-action 'self'",
7+
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.google.com https://www.gstatic.com https://apis.google.com",
8+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
9+
"font-src 'self' data: https://fonts.gstatic.com",
10+
"img-src 'self' data: blob: https://res.cloudinary.com https://firebasestorage.googleapis.com https://*.googleusercontent.com https://www.gstatic.com",
11+
"connect-src 'self' https://*.googleapis.com https://*.firebaseio.com wss://*.firebaseio.com https://*.firebaseapp.com https://apis.google.com https://accounts.google.com https://cdn.jsdelivr.net https://www.google.com",
12+
"frame-src 'self' https://www.google.com https://*.firebaseapp.com https://accounts.google.com https://apis.google.com",
13+
"worker-src 'self' blob:",
14+
].join("; ");
15+
116
/** @type {import('next').NextConfig} */
217
const nextConfig = {
318
eslint: {
@@ -32,6 +47,8 @@ const nextConfig = {
3247
source: '/:path*',
3348
headers: [
3449
{ key: 'Strict-Transport-Security', value: "max-age=63072000" },
50+
{ key: 'Content-Security-Policy', value: contentSecurityPolicy },
51+
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
3552
{ key: 'Access-Control-Allow-Origin', value: process.env.NEXT_PUBLIC_DOMAIN },
3653
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
3754
{ key: 'Access-Control-Allow-Methods', value: "GET,POST,PUT,PATCH,DELETE,OPTIONS" },

0 commit comments

Comments
 (0)