/**
* Input validation and sanitization utilities for Chart Block
*/
import type { ChartConfig, ChartType } from './types';
/**
* Sanitize HTML to prevent XSS attacks
*/
export function sanitizeHtml(html: string): string {
if (!html) return '';
return html
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/\//g, '/');
}
/**
* Sanitize text for safe display
*/
export function sanitizeText(text: string): string {
if (!text) return '';
return sanitizeHtml(text);
}
/**
* Validate chart type
*/
export function isValidChartType(type: string): type is ChartType {
const validTypes: ChartType[] = ['bar', 'line', 'pie', 'area', 'scatter', 'funnel', 'radar'];
return validTypes.includes(type as ChartType);
}
/**
* Validate app token format
*/
export function isValidAppToken(token: string): boolean {
if (!token || typeof token !== 'string') return false;
// Lark app tokens are typically alphanumeric with specific length
return /^[a-zA-Z0-9_-]{10,100}$/.test(token);
}
/**
* Validate table ID format
*/
export function isValidTableId(tableId: string): boolean {
if (!tableId || typeof tableId !== 'string') return false;
// Lark table IDs follow specific patterns
return /^tbl[a-zA-Z0-9]{10,50}$/.test(tableId);
}
/**
* Validate view ID format
*/
export function isValidViewId(viewId: string): boolean {
if (!viewId || typeof viewId !== 'string') return false;
// Lark view IDs follow specific patterns
return /^vew[a-zA-Z0-9]{10,50}$/.test(viewId);
}
/**
* Validate field name
*/
export function isValidFieldName(fieldName: string): boolean {
if (!fieldName || typeof fieldName !== 'string') return false;
// Field names should be reasonable length and safe characters
return /^[a-zA-Z0-9_\s\u4e00-\u9fa5-]{1,100}$/.test(fieldName);
}
/**
* Validate data source configuration
*/
export function validateDataSource(dataSource: any): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!dataSource) {
errors.push('Data source is required');
return { valid: false, errors };
}
if (!isValidAppToken(dataSource.appToken)) {
errors.push('Invalid app token format');
}
if (!isValidTableId(dataSource.tableId)) {
errors.push('Invalid table ID format');
}
if (dataSource.viewId && !isValidViewId(dataSource.viewId)) {
errors.push('Invalid view ID format');
}
if (!dataSource.fields) {
errors.push('Fields configuration is required');
return { valid: errors.length === 0, errors };
}
if (!isValidFieldName(dataSource.fields.xAxis)) {
errors.push('Invalid X-axis field name');
}
const yAxis = dataSource.fields.yAxis;
if (Array.isArray(yAxis)) {
yAxis.forEach((field, index) => {
if (!isValidFieldName(field)) {
errors.push(`Invalid Y-axis field name at index ${index}`);
}
});
} else if (!isValidFieldName(yAxis)) {
errors.push('Invalid Y-axis field name');
}
if (dataSource.fields.series && !isValidFieldName(dataSource.fields.series)) {
errors.push('Invalid series field name');
}
return { valid: errors.length === 0, errors };
}
/**
* Validate chart configuration
*/
export function validateChartConfig(config: any): { valid: boolean; errors: string[] } {
const errors: string[] = [];
if (!config) {
errors.push('Chart configuration is required');
return { valid: false, errors };
}
if (!isValidChartType(config.chartType)) {
errors.push(`Invalid chart type: ${config.chartType}`);
}
if (config.title && typeof config.title !== 'string') {
errors.push('Chart title must be a string');
}
if (config.title && config.title.length > 200) {
errors.push('Chart title too long (max 200 characters)');
}
const dataSourceValidation = validateDataSource(config.dataSource);
if (!dataSourceValidation.valid) {
errors.push(...dataSourceValidation.errors);
}
if (config.options) {
if (config.options.colors && !Array.isArray(config.options.colors)) {
errors.push('Colors must be an array');
}
if (config.options.colors && config.options.colors.length > 20) {
errors.push('Too many colors (max 20)');
}
if (config.options.colors) {
config.options.colors.forEach((color: any, index: number) => {
if (!isValidColor(color)) {
errors.push(`Invalid color at index ${index}: ${color}`);
}
});
}
}
return { valid: errors.length === 0, errors };
}
/**
* Validate color format (hex, rgb, rgba)
*/
export function isValidColor(color: string): boolean {
if (!color || typeof color !== 'string') return false;
// Hex color
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color)) return true;
// RGB or RGBA
if (/^rgba?\((\d{1,3},\s*){2}\d{1,3}(,\s*[0-1](\.\d+)?)?\)$/.test(color)) return true;
return false;
}
/**
* Sanitize chart configuration
*/
export function sanitizeChartConfig(config: ChartConfig): ChartConfig {
return {
chartType: config.chartType,
title: config.title ? sanitizeText(config.title) : undefined,
dataSource: {
appToken: config.dataSource.appToken,
tableId: config.dataSource.tableId,
viewId: config.dataSource.viewId,
fields: {
xAxis: sanitizeText(config.dataSource.fields.xAxis),
yAxis: Array.isArray(config.dataSource.fields.yAxis)
? config.dataSource.fields.yAxis.map(sanitizeText)
: sanitizeText(config.dataSource.fields.yAxis),
series: config.dataSource.fields.series
? sanitizeText(config.dataSource.fields.series)
: undefined,
},
filters: config.dataSource.filters,
},
options: config.options,
};
}
/**
* Validate and sanitize field value from Lark API
*/
export function sanitizeFieldValue(value: any): any {
if (value === null || value === undefined) {
return null;
}
// Handle primitive types
if (typeof value === 'string') {
// Limit string length to prevent DoS
return value.substring(0, 10000);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
// Handle arrays
if (Array.isArray(value)) {
return value.slice(0, 100).map(sanitizeFieldValue);
}
// Handle objects
if (typeof value === 'object') {
const sanitized: any = {};
const keys = Object.keys(value).slice(0, 50); // Limit object keys
for (const key of keys) {
sanitized[key] = sanitizeFieldValue(value[key]);
}
return sanitized;
}
return null;
}
/**
* Rate limiting helper
*/
export class RateLimiter {
private requests: Map<string, number[]> = new Map();
constructor(
private maxRequests: number = 10,
private windowMs: number = 60000
) {}
/**
* Check if request is allowed
*/
isAllowed(key: string): boolean {
const now = Date.now();
const requests = this.requests.get(key) || [];
// Remove old requests outside window
const recentRequests = requests.filter(time => now - time < this.windowMs);
if (recentRequests.length >= this.maxRequests) {
return false;
}
recentRequests.push(now);
this.requests.set(key, recentRequests);
return true;
}
/**
* Clear all rate limit data
*/
clear(): void {
this.requests.clear();
}
}