Skip to content

Commit 16beec9

Browse files
committed
fix: basket validation
1 parent 8f2dc53 commit 16beec9

File tree

1 file changed

+355
-23
lines changed

1 file changed

+355
-23
lines changed

apps/basket/src/utils/validation.ts

Lines changed: 355 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,358 @@
44
* Provides reusable validation and sanitization functions for analytics data.
55
*/
66

7-
export {
8-
batchAnalyticsEventSchema,
9-
batchAnalyticsEventsSchema,
10-
filterSafeHeaders,
11-
SAFE_HEADERS,
12-
sanitizeString,
13-
validateExitIntent,
14-
validateInteractionCount,
15-
validateLanguage,
16-
validateNumeric,
17-
validatePageCount,
18-
validatePayloadSize,
19-
validatePerformanceMetric,
20-
validateProperties,
21-
validateScreenResolution,
22-
validateScrollDepth,
23-
validateSessionId,
24-
validateTimezone,
25-
validateTimezoneOffset,
26-
validateUrl,
27-
validateUtmParameter,
28-
validateViewportSize,
29-
} from '@databuddy/validation';
7+
import { z } from 'zod/v4';
8+
9+
export const VALIDATION_LIMITS = {
10+
STRING_MAX_LENGTH: 2048,
11+
SHORT_STRING_MAX_LENGTH: 255,
12+
SESSION_ID_MAX_LENGTH: 128,
13+
BATCH_MAX_SIZE: 100,
14+
PAYLOAD_MAX_SIZE: 1024 * 1024, // 1MB
15+
BATCH_PAYLOAD_MAX_SIZE: 5 * 1024 * 1024, // 5MB
16+
UTM_MAX_LENGTH: 512,
17+
LANGUAGE_MAX_LENGTH: 35, // RFC 5646 max length
18+
TIMEZONE_MAX_LENGTH: 64,
19+
} as const;
20+
21+
export const SAFE_HEADERS = new Set([
22+
'user-agent',
23+
'referer',
24+
'accept-language',
25+
'accept-encoding',
26+
'accept',
27+
'origin',
28+
'host',
29+
'content-type',
30+
'content-length',
31+
'cf-connecting-ip',
32+
'cf-ipcountry',
33+
'cf-ray',
34+
'x-forwarded-for',
35+
'x-real-ip',
36+
]);
37+
38+
/**
39+
* Sanitizes a string by removing potentially dangerous characters
40+
*/
41+
export function sanitizeString(input: unknown, maxLength?: number): string {
42+
if (typeof input !== 'string') {
43+
return '';
44+
}
45+
46+
const actualMaxLength = maxLength ?? VALIDATION_LIMITS.STRING_MAX_LENGTH;
47+
48+
return input
49+
.trim()
50+
.slice(0, actualMaxLength)
51+
.split('')
52+
.filter((char) => {
53+
const code = char.charCodeAt(0);
54+
return !(
55+
code <= 8 ||
56+
code === 11 ||
57+
code === 12 ||
58+
(code >= 14 && code <= 31) ||
59+
code === 127
60+
);
61+
})
62+
.join('')
63+
.replace(/[<>'"&]/g, '')
64+
.replace(/\s+/g, ' ');
65+
}
66+
67+
const timezoneRegex = /^[A-Za-z_/+-]{1,64}$/;
68+
const languageRegex = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/;
69+
const sessionIdRegex = /^[a-zA-Z0-9_-]+$/;
70+
const resolutionRegex = /^\d{1,5}x\d{1,5}$/;
71+
72+
/**
73+
* Validates and sanitizes timezone strings
74+
*/
75+
export function validateTimezone(timezone: unknown): string {
76+
if (typeof timezone !== 'string') {
77+
return '';
78+
}
79+
80+
const sanitized = sanitizeString(
81+
timezone,
82+
VALIDATION_LIMITS.TIMEZONE_MAX_LENGTH
83+
);
84+
85+
if (!timezoneRegex.test(sanitized)) {
86+
return '';
87+
}
88+
89+
return sanitized;
90+
}
91+
92+
/**
93+
* Validates timezone offset
94+
*/
95+
export function validateTimezoneOffset(offset: unknown): number | null {
96+
if (typeof offset === 'number') {
97+
if (offset >= -12 * 60 && offset <= 14 * 60) {
98+
return Math.round(offset);
99+
}
100+
return null;
101+
}
102+
return null;
103+
}
104+
105+
/**
106+
* Validates and sanitizes language strings
107+
*/
108+
export function validateLanguage(language: unknown): string {
109+
if (typeof language !== 'string') {
110+
return '';
111+
}
112+
113+
const sanitized = sanitizeString(
114+
language,
115+
VALIDATION_LIMITS.LANGUAGE_MAX_LENGTH
116+
);
117+
118+
if (!languageRegex.test(sanitized)) {
119+
return '';
120+
}
121+
122+
return sanitized.toLowerCase();
123+
}
124+
125+
/**
126+
* Validates session ID format
127+
*/
128+
export function validateSessionId(sessionId: unknown): string {
129+
if (typeof sessionId !== 'string') {
130+
return '';
131+
}
132+
133+
const sanitized = sanitizeString(
134+
sessionId,
135+
VALIDATION_LIMITS.SESSION_ID_MAX_LENGTH
136+
);
137+
138+
if (!sessionIdRegex.test(sanitized)) {
139+
return '';
140+
}
141+
142+
return sanitized;
143+
}
144+
145+
/**
146+
* Validates and sanitizes UTM parameters
147+
*/
148+
export function validateUtmParameter(utm: unknown): string {
149+
if (typeof utm !== 'string') {
150+
return '';
151+
}
152+
153+
return sanitizeString(utm, VALIDATION_LIMITS.UTM_MAX_LENGTH);
154+
}
155+
156+
/**
157+
* Validates numeric values with range checking
158+
*/
159+
export function validateNumeric(
160+
value: unknown,
161+
min = 0,
162+
max = Number.MAX_SAFE_INTEGER
163+
): number | null {
164+
if (
165+
typeof value === 'number' &&
166+
!Number.isNaN(value) &&
167+
Number.isFinite(value)
168+
) {
169+
const rounded = Math.round(value);
170+
return rounded >= min && rounded <= max ? rounded : null;
171+
}
172+
if (typeof value === 'string') {
173+
const parsed = Number.parseFloat(value);
174+
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
175+
const rounded = Math.round(parsed);
176+
return rounded >= min && rounded <= max ? rounded : null;
177+
}
178+
}
179+
return null;
180+
}
181+
182+
/**
183+
* Validates URL format
184+
*/
185+
export function validateUrl(url: unknown): string {
186+
if (typeof url !== 'string') {
187+
return '';
188+
}
189+
190+
const sanitized = sanitizeString(url);
191+
192+
try {
193+
const parsed = new URL(sanitized);
194+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
195+
return '';
196+
}
197+
return parsed.toString();
198+
} catch {
199+
return '';
200+
}
201+
}
202+
203+
/**
204+
* Filters and validates request headers
205+
*/
206+
export function filterSafeHeaders(
207+
headers: Record<string, string | string[] | undefined>
208+
): Record<string, string> {
209+
const safeHeaders: Record<string, string> = {};
210+
211+
for (const [key, value] of Object.entries(headers)) {
212+
const lowerKey = key.toLowerCase();
213+
if (SAFE_HEADERS.has(lowerKey) && value) {
214+
const stringValue = Array.isArray(value) ? value[0] : value;
215+
if (stringValue) {
216+
safeHeaders[lowerKey] = sanitizeString(
217+
stringValue,
218+
VALIDATION_LIMITS.SHORT_STRING_MAX_LENGTH
219+
);
220+
}
221+
}
222+
}
223+
224+
return safeHeaders;
225+
}
226+
227+
/**
228+
* Validates analytics properties object
229+
*/
230+
export function validateProperties(
231+
properties: unknown
232+
): Record<string, unknown> {
233+
if (
234+
!properties ||
235+
typeof properties !== 'object' ||
236+
Array.isArray(properties)
237+
) {
238+
return {};
239+
}
240+
241+
const validated: Record<string, unknown> = {};
242+
const props = properties as Record<string, unknown>;
243+
244+
const keys = Object.keys(props).slice(0, 100);
245+
246+
for (const key of keys) {
247+
const sanitizedKey = sanitizeString(key, 128);
248+
if (!sanitizedKey) {
249+
continue;
250+
}
251+
252+
const value = props[key];
253+
254+
if (typeof value === 'string') {
255+
validated[sanitizedKey] = sanitizeString(value);
256+
} else if (typeof value === 'number') {
257+
validated[sanitizedKey] = validateNumeric(value);
258+
} else if (typeof value === 'boolean') {
259+
validated[sanitizedKey] = value;
260+
} else if (value === null || value === undefined) {
261+
validated[sanitizedKey] = null;
262+
}
263+
}
264+
265+
return validated;
266+
}
267+
268+
/**
269+
* Comprehensive event validation schema
270+
*/
271+
export const analyticsEventSchema = z.object({
272+
type: z.enum(['track']),
273+
payload: z.object({
274+
name: z.string().max(VALIDATION_LIMITS.SHORT_STRING_MAX_LENGTH).optional(),
275+
anonymousId: z
276+
.string()
277+
.max(VALIDATION_LIMITS.SESSION_ID_MAX_LENGTH)
278+
.optional(),
279+
properties: z.record(z.string(), z.unknown()).optional(),
280+
property: z
281+
.string()
282+
.max(VALIDATION_LIMITS.SHORT_STRING_MAX_LENGTH)
283+
.optional(),
284+
value: z.number().finite().optional(),
285+
}),
286+
});
287+
288+
export const batchAnalyticsEventSchema = z
289+
.array(analyticsEventSchema)
290+
.max(VALIDATION_LIMITS.BATCH_MAX_SIZE);
291+
292+
/**
293+
* Validates payload size
294+
*/
295+
export function validatePayloadSize(
296+
data: unknown,
297+
maxSize = VALIDATION_LIMITS.PAYLOAD_MAX_SIZE
298+
): boolean {
299+
try {
300+
const serialized = JSON.stringify(data);
301+
return serialized.length <= maxSize;
302+
} catch {
303+
return false;
304+
}
305+
}
306+
307+
/**
308+
* Performance metrics validation
309+
*/
310+
export function validatePerformanceMetric(value: unknown): number | undefined {
311+
return validateNumeric(value, 0, 300_000) as number | undefined;
312+
}
313+
314+
/**
315+
* Validates screen resolution format
316+
*/
317+
export function validateScreenResolution(resolution: unknown): string {
318+
if (typeof resolution !== 'string') {
319+
return '';
320+
}
321+
322+
const sanitized = sanitizeString(resolution, 32);
323+
324+
return resolutionRegex.test(sanitized) ? sanitized : '';
325+
}
326+
327+
/**
328+
* Validates viewport size format
329+
*/
330+
export function validateViewportSize(viewport: unknown): string {
331+
return validateScreenResolution(viewport);
332+
}
333+
334+
/**
335+
* Validates scroll depth percentage
336+
*/
337+
export function validateScrollDepth(depth: unknown): number | null {
338+
return validateNumeric(depth, 0, 100);
339+
}
340+
341+
/**
342+
* Validates page count
343+
*/
344+
export function validatePageCount(count: unknown): number | null {
345+
return validateNumeric(count, 1, 10_000);
346+
}
347+
348+
/**
349+
* Validates interaction count
350+
*/
351+
export function validateInteractionCount(count: unknown): number | null {
352+
return validateNumeric(count, 0, 100_000);
353+
}
354+
355+
/**
356+
* Validates exit intent (0 or 1)
357+
*/
358+
export function validateExitIntent(intent: unknown): number {
359+
const validated = validateNumeric(intent, 0, 1);
360+
return validated !== null ? validated : 0;
361+
}

0 commit comments

Comments
 (0)