autotel
Version:
Write Once, Observe Anywhere
249 lines (218 loc) • 6.25 kB
text/typescript
/**
* Input validation for events events and attributes
*
* Prevents:
* - Invalid event names
* - Oversized payloads
* - Circular references
* - Sensitive data leaks
*/
import type { EventAttributes } from './event-subscriber';
export interface ValidationConfig {
/** Max event name length (default: 100) */
maxEventNameLength: number;
/** Max attribute key length (default: 100) */
maxAttributeKeyLength: number;
/** Max attribute value length for strings (default: 1000) */
maxAttributeValueLength: number;
/** Max total attributes per event (default: 50) */
maxAttributeCount: number;
/** Max nesting depth for objects (default: 3) */
maxNestingDepth: number;
/** Sensitive field patterns to redact */
sensitivePatterns: RegExp[];
}
const DEFAULT_CONFIG: ValidationConfig = {
maxEventNameLength: 100,
maxAttributeKeyLength: 100,
maxAttributeValueLength: 1000,
maxAttributeCount: 50,
maxNestingDepth: 3,
sensitivePatterns: [
/password/i,
/secret/i,
/token/i,
/api[_-]?key/i,
/access[_-]?key/i,
/private[_-]?key/i,
/auth/i,
/credential/i,
/ssn/i,
/credit[_-]?card/i,
],
};
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
/**
* Validate and sanitize event name
* Throws ValidationError if invalid
*/
export function validateEventName(
eventName: string,
config: ValidationConfig = DEFAULT_CONFIG,
): string {
// Check type
if (typeof eventName !== 'string') {
throw new ValidationError(
`Event name must be a string, got ${typeof eventName}`,
);
}
// Check non-empty
const trimmed = eventName.trim();
if (trimmed.length === 0) {
throw new ValidationError('Event name cannot be empty');
}
// Check length
if (trimmed.length > config.maxEventNameLength) {
throw new ValidationError(
`Event name too long (${trimmed.length} chars). ` +
`Max: ${config.maxEventNameLength}`,
);
}
// Check valid characters (alphanumeric, dots, underscores, hyphens)
if (!/^[a-zA-Z0-9._-]+$/.test(trimmed)) {
throw new ValidationError(
`Event name contains invalid characters: "${trimmed}". ` +
'Use only letters, numbers, dots, underscores, and hyphens.',
);
}
return trimmed;
}
/**
* Validate and sanitize attributes
* Returns sanitized attributes (sensitive data redacted)
*/
export function validateAttributes(
attributes: EventAttributes | undefined,
config: ValidationConfig = DEFAULT_CONFIG,
): EventAttributes | undefined {
if (attributes === undefined || attributes === null) {
return undefined;
}
// Check type
if (typeof attributes !== 'object' || Array.isArray(attributes)) {
throw new ValidationError('Attributes must be an object');
}
// Count attributes
const keys = Object.keys(attributes);
if (keys.length > config.maxAttributeCount) {
throw new ValidationError(
`Too many attributes (${keys.length}). ` +
`Max: ${config.maxAttributeCount}`,
);
}
// Validate and sanitize each attribute
const sanitized: EventAttributes = {};
for (const key of keys) {
// Validate key
if (key.length > config.maxAttributeKeyLength) {
throw new ValidationError(
`Attribute key too long: "${key.slice(0, 20)}..." ` +
`(${key.length} chars). Max: ${config.maxAttributeKeyLength}`,
);
}
// Check for sensitive field
const isSensitive = config.sensitivePatterns.some((pattern) =>
pattern.test(key),
);
if (isSensitive) {
// Redact sensitive data
sanitized[key] = '[REDACTED]';
continue;
}
// Sanitize value
const value = attributes[key];
sanitized[key] = sanitizeValue(value, config, 1) as
| string
| number
| boolean;
}
return sanitized;
}
/**
* Sanitize attribute value (recursive)
*/
function sanitizeValue(
value: unknown,
config: ValidationConfig,
depth: number,
): unknown {
// Check nesting depth
if (depth > config.maxNestingDepth) {
return '[MAX_DEPTH_EXCEEDED]';
}
// Handle null/undefined
if (value === null || value === undefined) {
return value;
}
// Handle primitives
if (typeof value === 'string') {
if (value.length > config.maxAttributeValueLength) {
return value.slice(0, config.maxAttributeValueLength) + '...';
}
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
// Handle arrays
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, config, depth + 1));
}
// Handle objects
if (typeof value === 'object') {
try {
// Check for circular references
JSON.stringify(value);
const sanitized: Record<string, unknown> = {};
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
// Check for sensitive field in nested objects
const isSensitive = config.sensitivePatterns.some((pattern) =>
pattern.test(key),
);
if (isSensitive) {
sanitized[key] = '[REDACTED]';
} else {
sanitized[key] = sanitizeValue(
(value as Record<string, unknown>)[key],
config,
depth + 1,
);
}
}
}
return sanitized;
} catch {
// Circular reference detected
return '[CIRCULAR]';
}
}
// Unsupported type (function, symbol, etc.)
return `[${typeof value}]`;
}
/**
* Validate and sanitize an events event
* Returns { eventName, attributes } with sanitized values
*/
export function validateEvent(
eventName: string,
attributes?: EventAttributes,
config?: Partial<ValidationConfig>,
): { eventName: string; attributes?: EventAttributes } {
const fullConfig = { ...DEFAULT_CONFIG, ...config };
return {
eventName: validateEventName(eventName, fullConfig),
attributes: validateAttributes(attributes, fullConfig),
};
}
/**
* Get default validation config (for testing/customization)
*/
export function getDefaultValidationConfig(): ValidationConfig {
return { ...DEFAULT_CONFIG };
}