From 0e79b3b2fec1e2264bdb7a83a1dc220b3793b924 Mon Sep 17 00:00:00 2001 From: shambhushekharsinha-engg Date: Fri, 19 Jun 2026 19:44:05 +0530 Subject: [PATCH 1/2] perf(api): add defensive checks and validation for profile image URL parameters #2095 --- lib/validations.ts | 101 ++++++++++++++------------------------------- 1 file changed, 30 insertions(+), 71 deletions(-) diff --git a/lib/validations.ts b/lib/validations.ts index 9f2b75269..61af21680 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -222,14 +222,10 @@ const baseStreakParamsSchema = z.object({ bg: z .string() .optional() - .transform((val) => { - if (!val) return undefined; - const cleanVal = val.trim().replace(/^#+/, ''); - if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { - return cleanVal as HexColor; - } - return undefined; - }), + .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { + message: 'bg must be a valid hex color (with or without #)', + }) + .transform((val) => (val ? sanitizeHexColor(val, '0d1117') : undefined)), bgType: z.enum(['solid', 'linear', 'radial']).catch('solid').default('solid'), bgStart: z .string() @@ -260,34 +256,37 @@ const baseStreakParamsSchema = z.object({ text: z .string() .optional() - .transform((val) => { - if (!val) return undefined; - const cleanVal = val.trim().replace(/^#+/, ''); - if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { - return cleanVal as HexColor; - } - return undefined; - }), + .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { + message: 'text must be a valid hex color (with or without #)', + }) + .transform((val) => (val ? sanitizeHexColor(val, 'ffffff') : undefined)), accent: z .string() .optional() + .refine( + (val) => { + if (!val) return true; + const parts = val.includes(',') ? val.split(',') : [val]; + return parts.every((p) => + /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(p.trim().replace('#', '')) + ); + }, + { + message: + 'accent must be a valid hex color (with or without #), or a comma-separated list of them', + } + ) .transform((val) => { if (!val) return undefined; if (val.includes(',')) { - const parts = val + return val .split(',') - .map((c) => c.trim().replace(/^#+/, '')) + .map((c) => c.trim()) .filter((c) => c.length > 0) - .filter((c) => /^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(c)) - .map((c) => c as HexColor) - .slice(0, 4); - return parts.length > 0 ? parts : undefined; - } - const cleanVal = val.trim().replace(/^#+/, ''); - if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { - return cleanVal as HexColor; + .slice(0, 4) + .map((c) => sanitizeHexColor(c, '00ffaa')); } - return undefined; + return sanitizeHexColor(val, '00ffaa'); }), // Silently fall back to 'linear' for unknown values (matches old behavior) @@ -367,18 +366,7 @@ const baseStreakParamsSchema = z.object({ tz: timeZoneParam, // Unknown view values fall back to the default dashboard view. view: z - .enum([ - 'default', - 'monthly', - 'heatmap', - 'pulse', - 'skyline', - 'languages', - 'constellation', - 'radar', - 'doughnut', - 'pie', - ]) + .enum(['default', 'monthly', 'heatmap', 'pulse', 'skyline', 'languages', 'constellation']) .catch('default') .default('default'), // Invalid delta formats fall back to percentage mode. @@ -453,41 +441,12 @@ const baseStreakParamsSchema = z.object({ // Glow effect — on by default. Accepts 'true'/'1' (true) or 'false' (false). glow: z.string().optional().transform(toGlowFlag).default(true), opacity: z.string().optional().transform(toOpacityValue), - entrance: z - .enum(['rise', 'fade', 'slide', 'wave', 'bounce', 'none']) - .catch('rise') - .default('rise'), + entrance: z.enum(['rise', 'fade', 'slide', 'none']).catch('rise').default('rise'), badges: z.string().optional().transform(toBooleanFlag).default(false), - // Output format: 'svg' (default), 'json', or 'png' for image export. + // Output format: 'svg' (default) or 'json' for programmatic access. // Invalid values silently fall back to 'svg'. - format: z.enum(['svg', 'json', 'png']).catch('svg').default('svg'), - - theta: z - .string() - .optional() - .refine( - (val) => { - if (val === undefined || val === '') return true; - const num = Number(val); - return !isNaN(num) && num >= 0 && num <= 360; - }, - { message: 'theta must be a number between 0 and 360' } - ) - .transform((val) => (val === undefined || val === '' ? undefined : Number(val))), - - phi: z - .string() - .optional() - .refine( - (val) => { - if (val === undefined || val === '') return true; - const num = Number(val); - return !isNaN(num) && num >= 0 && num <= 90; - }, - { message: 'phi must be a number between 0 and 90' } - ) - .transform((val) => (val === undefined || val === '' ? undefined : Number(val))), + format: z.enum(['svg', 'json']).catch('svg').default('svg'), // layout parameter: strictly validated — unsupported values return a 400 Bad Request. layout: z From 3aa82157865aad6eeac83ba7c3e1652895773d6f Mon Sep 17 00:00:00 2001 From: shambhushekharsinha-engg Date: Fri, 19 Jun 2026 20:04:27 +0530 Subject: [PATCH 2/2] perf(api): add defensive checks and validation for profile image URL parameters #2095 --- lib/validations.ts | 163 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 40 deletions(-) diff --git a/lib/validations.ts b/lib/validations.ts index 61af21680..bf5a990e1 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -222,10 +222,14 @@ const baseStreakParamsSchema = z.object({ bg: z .string() .optional() - .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { - message: 'bg must be a valid hex color (with or without #)', - }) - .transform((val) => (val ? sanitizeHexColor(val, '0d1117') : undefined)), + .transform((val) => { + if (!val) return undefined; + const cleanVal = val.trim().replace(/^#+/, ''); + if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { + return cleanVal as HexColor; + } + return undefined; + }), bgType: z.enum(['solid', 'linear', 'radial']).catch('solid').default('solid'), bgStart: z .string() @@ -256,37 +260,34 @@ const baseStreakParamsSchema = z.object({ text: z .string() .optional() - .refine((val) => !val || /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(val.replace('#', '')), { - message: 'text must be a valid hex color (with or without #)', - }) - .transform((val) => (val ? sanitizeHexColor(val, 'ffffff') : undefined)), + .transform((val) => { + if (!val) return undefined; + const cleanVal = val.trim().replace(/^#+/, ''); + if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { + return cleanVal as HexColor; + } + return undefined; + }), accent: z .string() .optional() - .refine( - (val) => { - if (!val) return true; - const parts = val.includes(',') ? val.split(',') : [val]; - return parts.every((p) => - /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/.test(p.trim().replace('#', '')) - ); - }, - { - message: - 'accent must be a valid hex color (with or without #), or a comma-separated list of them', - } - ) .transform((val) => { if (!val) return undefined; if (val.includes(',')) { - return val + const parts = val .split(',') - .map((c) => c.trim()) + .map((c) => c.trim().replace(/^#+/, '')) .filter((c) => c.length > 0) - .slice(0, 4) - .map((c) => sanitizeHexColor(c, '00ffaa')); + .filter((c) => /^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(c)) + .map((c) => c as HexColor) + .slice(0, 4); + return parts.length > 0 ? parts : undefined; } - return sanitizeHexColor(val, '00ffaa'); + const cleanVal = val.trim().replace(/^#+/, ''); + if (/^([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(cleanVal)) { + return cleanVal as HexColor; + } + return undefined; }), // Silently fall back to 'linear' for unknown values (matches old behavior) @@ -366,7 +367,18 @@ const baseStreakParamsSchema = z.object({ tz: timeZoneParam, // Unknown view values fall back to the default dashboard view. view: z - .enum(['default', 'monthly', 'heatmap', 'pulse', 'skyline', 'languages', 'constellation']) + .enum([ + 'default', + 'monthly', + 'heatmap', + 'pulse', + 'skyline', + 'languages', + 'constellation', + 'radar', + 'doughnut', + 'pie', + ]) .catch('default') .default('default'), // Invalid delta formats fall back to percentage mode. @@ -441,12 +453,41 @@ const baseStreakParamsSchema = z.object({ // Glow effect — on by default. Accepts 'true'/'1' (true) or 'false' (false). glow: z.string().optional().transform(toGlowFlag).default(true), opacity: z.string().optional().transform(toOpacityValue), - entrance: z.enum(['rise', 'fade', 'slide', 'none']).catch('rise').default('rise'), + entrance: z + .enum(['rise', 'fade', 'slide', 'wave', 'bounce', 'none']) + .catch('rise') + .default('rise'), badges: z.string().optional().transform(toBooleanFlag).default(false), - // Output format: 'svg' (default) or 'json' for programmatic access. + // Output format: 'svg' (default), 'json', or 'png' for image export. // Invalid values silently fall back to 'svg'. - format: z.enum(['svg', 'json']).catch('svg').default('svg'), + format: z.enum(['svg', 'json', 'png']).catch('svg').default('svg'), + + theta: z + .string() + .optional() + .refine( + (val) => { + if (val === undefined || val === '') return true; + const num = Number(val); + return !isNaN(num) && num >= 0 && num <= 360; + }, + { message: 'theta must be a number between 0 and 360' } + ) + .transform((val) => (val === undefined || val === '' ? undefined : Number(val))), + + phi: z + .string() + .optional() + .refine( + (val) => { + if (val === undefined || val === '') return true; + const num = Number(val); + return !isNaN(num) && num >= 0 && num <= 90; + }, + { message: 'phi must be a number between 0 and 90' } + ) + .transform((val) => (val === undefined || val === '' ? undefined : Number(val))), // layout parameter: strictly validated — unsupported values return a 400 Bad Request. layout: z @@ -470,17 +511,59 @@ export const streakParamsSchema = baseStreakParamsSchema.refine( } ); +const HEX_REGEX = /^([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/; + export const githubParamsSchema = z.object({ - username: z - .string({ error: 'Missing "username" parameter' }) - .trim() - .min(1, { message: 'Username is required' }) - .max(39, { message: 'GitHub username cannot exceed 39 characters' }) - .regex(GITHUB_USERNAME_REGEX, { - message: 'Invalid GitHub username', - }), - refresh: z.string().optional().transform(toRefreshFlag), - bypassCache: z.string().optional().transform(toRefreshFlag), + // Preprocess leaves undefined untouched so we can distinguish missing vs empty string + username: z.preprocess( + (val) => (typeof val === 'string' ? val.trim() : val), + z + .custom() + .refine((val) => val !== undefined && val !== null, { + message: 'Missing "username" parameter', + }) + .transform((val) => String(val)) + .refine((val) => val.length > 0, { + message: 'Username is required', + }) + .refine((val) => val.length <= 39, { + message: 'GitHub username cannot exceed 39 characters', + }) + .refine((val) => validateGitHubUsername(val), { + message: 'Invalid GitHub username', + }) + ), + + bg: z + .string() + .optional() + .transform((val) => val?.replace('#', '') || 'ffffff') + .refine((val) => HEX_REGEX.test(val)) + .catch('ffffff'), + + accent: z + .string() + .optional() + .transform((val) => val?.replace('#', '') || 'ff6b35') + .refine((val) => HEX_REGEX.test(val)) + .catch('ff6b35'), + + width: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 400)) + .refine((val) => !isNaN(val) && val >= 100 && val <= 2000) + .catch(400), + + height: z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 150)) + .refine((val) => !isNaN(val) && val >= 100 && val <= 2000) + .catch(150), + + refresh: z.preprocess((val) => val === 'true', z.boolean()).default(false), + bypassCache: z.preprocess((val) => val === 'true', z.boolean()).default(false), }); export const compareParamsSchema = z