@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
203 lines (168 loc) • 4.65 kB
text/typescript
/**
* Input validation and sanitization utilities
*/
import { ValidationError } from '../errors/index.js';
// Maximum allowed string lengths
const MAX_DESCRIPTION_LENGTH = 5000;
const MAX_ARRAY_LENGTH = 100;
const MAX_URL_LENGTH = 2048;
const MAX_EMAIL_LENGTH = 254;
// Regex patterns
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_REGEX = /^https?:\/\/.+/;
/**
* Sanitize a string input
*/
export function sanitizeString(
value: unknown,
field: string,
maxLength: number = MAX_DESCRIPTION_LENGTH
): string {
if (typeof value !== 'string') {
throw new ValidationError(`${field} must be a string`, field);
}
const trimmed = value.trim();
if (trimmed.length === 0) {
throw new ValidationError(`${field} cannot be empty`, field);
}
if (trimmed.length > maxLength) {
throw new ValidationError(
`${field} exceeds maximum length of ${maxLength}`,
field
);
}
// Remove potential SQL injection characters
const sanitized = trimmed
.replace(/[<>]/g, '') // Remove HTML tags
.replace(/\0/g, ''); // Remove null bytes
return sanitized;
}
/**
* Validate and sanitize email
*/
export function validateEmail(value: unknown, field: string = 'email'): string {
const email = sanitizeString(value, field, MAX_EMAIL_LENGTH);
if (!EMAIL_REGEX.test(email)) {
throw new ValidationError(`Invalid email format`, field);
}
return email.toLowerCase();
}
/**
* Validate and sanitize URL
*/
export function validateUrl(value: unknown, field: string = 'url'): string {
const url = sanitizeString(value, field, MAX_URL_LENGTH);
if (!URL_REGEX.test(url)) {
throw new ValidationError(`Invalid URL format`, field);
}
try {
new URL(url); // Additional validation
return url;
} catch {
throw new ValidationError(`Invalid URL format`, field);
}
}
/**
* Validate array input
*/
export function validateArray<T>(
value: unknown,
field: string,
validator?: (item: unknown, index: number) => T
): T[] {
if (!Array.isArray(value)) {
throw new ValidationError(`${field} must be an array`, field);
}
if (value.length > MAX_ARRAY_LENGTH) {
throw new ValidationError(
`${field} exceeds maximum length of ${MAX_ARRAY_LENGTH}`,
field
);
}
if (validator) {
return value.map((item, index) => validator(item, index));
}
return value as T[];
}
/**
* Validate object has required fields
*/
export function validateRequired<T extends Record<string, unknown>>(
obj: T,
requiredFields: (keyof T)[]
): void {
for (const field of requiredFields) {
if (obj[field] === undefined || obj[field] === null) {
throw new ValidationError(`${String(field)} is required`, String(field));
}
}
}
/**
* Validate pagination parameters
*/
export interface PaginationParams {
limit: number;
offset: number;
}
export function validatePagination(params: any): PaginationParams {
const limit = params.limit !== undefined ? Number(params.limit) : 50;
const offset = params.offset !== undefined ? Number(params.offset) : 0;
if (isNaN(limit) || limit < 1 || limit > 100) {
throw new ValidationError('Limit must be between 1 and 100', 'limit');
}
if (isNaN(offset) || offset < 0) {
throw new ValidationError('Offset must be non-negative', 'offset');
}
return { limit, offset };
}
/**
* Validate date string
*/
export function validateDate(value: unknown, field: string): string {
const dateStr = sanitizeString(value, field, 30);
const date = new Date(dateStr);
if (isNaN(date.getTime())) {
throw new ValidationError(`Invalid date format`, field);
}
return date.toISOString();
}
/**
* Validate enum value
*/
export function validateEnum<T extends string>(
value: unknown,
validValues: readonly T[],
field: string
): T {
if (!validValues.includes(value as T)) {
throw new ValidationError(
`Invalid ${field}. Must be one of: ${validValues.join(', ')}`,
field
);
}
return value as T;
}
/**
* Sanitize object by removing undefined/null values
*/
export function sanitizeObject<T extends Record<string, unknown>>(
obj: T
): Partial<T> {
const cleaned: Partial<T> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined && value !== null) {
cleaned[key as keyof T] = value as T[keyof T];
}
}
return cleaned;
}
/**
* Validate request size
*/
export function validateRequestSize(data: unknown): void {
const size = JSON.stringify(data).length;
const maxSize = 1024 * 1024; // 1MB
if (size > maxSize) {
throw new ValidationError('Request payload too large');
}
}