/**
* Security utilities for path validation, network requests, and URL validation
*/
import * as path from 'path';
import { ToolError } from './errors.js';
/**
* Validates that a file path stays within the allowed directory (prevents path traversal)
*/
export function validatePathWithinDirectory(filePath: string, allowedDir: string): string {
const resolvedPath = path.resolve(filePath);
const resolvedAllowedDir = path.resolve(allowedDir);
if (!resolvedPath.startsWith(resolvedAllowedDir + path.sep) && resolvedPath !== resolvedAllowedDir) {
throw new ToolError(
'Path traversal detected: file path escapes allowed directory',
'VALIDATION_ERROR',
{ filePath, allowedDir }
);
}
return resolvedPath;
}
/**
* Sanitizes a filename to prevent path traversal and invalid characters
*/
export function sanitizeFilename(filename: string, maxLength: number = 200): string {
return filename
.replace(/\.\./g, '') // Remove parent directory references
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '-') // Remove invalid characters
.replace(/\s+/g, '-') // Replace whitespace with dashes
.replace(/-+/g, '-') // Collapse multiple dashes
.replace(/^-|-$/g, '') // Remove leading/trailing dashes
.slice(0, maxLength);
}
/**
* Validates webhook URL for Slack or Discord (SSRF prevention)
*/
export function validateWebhookUrl(url: string, allowedHosts: string[]): void {
try {
const parsed = new URL(url);
// Must be HTTPS
if (parsed.protocol !== 'https:') {
throw new ToolError(
'Webhook URL must use HTTPS protocol',
'VALIDATION_ERROR',
{ url: url.slice(0, 50) }
);
}
// Must match allowed hosts
const isAllowed = allowedHosts.some(host =>
parsed.hostname === host || parsed.hostname.endsWith('.' + host)
);
if (!isAllowed) {
throw new ToolError(
`Webhook URL must be from allowed hosts: ${allowedHosts.join(', ')}`,
'VALIDATION_ERROR',
{ hostname: parsed.hostname }
);
}
} catch (error) {
if (error instanceof ToolError) throw error;
throw new ToolError(
'Invalid webhook URL format',
'VALIDATION_ERROR',
{ url: url.slice(0, 50) }
);
}
}
// Allowed webhook hosts
export const SLACK_ALLOWED_HOSTS = ['hooks.slack.com'];
export const DISCORD_ALLOWED_HOSTS = ['discord.com', 'discordapp.com'];
/**
* Fetch with timeout and retry support
*/
export interface FetchWithRetryOptions {
timeout?: number; // Timeout in ms (default: 30000)
maxRetries?: number; // Max retry attempts (default: 3)
retryDelay?: number; // Initial retry delay in ms (default: 1000)
retryStatusCodes?: number[]; // Status codes to retry (default: [429, 500, 502, 503, 504])
}
export async function fetchWithRetry(
url: string,
options: RequestInit = {},
retryOptions: FetchWithRetryOptions = {}
): Promise<Response> {
const {
timeout = 30000,
maxRetries = 3,
retryDelay = 1000,
retryStatusCodes = [429, 500, 502, 503, 504]
} = retryOptions;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// Check if we should retry
if (retryStatusCodes.includes(response.status) && attempt < maxRetries) {
// Get retry-after header if available
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter
? parseInt(retryAfter) * 1000
: retryDelay * Math.pow(2, attempt); // Exponential backoff
await sleep(delay);
continue;
}
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error) {
if (error.name === 'AbortError') {
lastError = new ToolError(
`Request timed out after ${timeout}ms`,
'TIMEOUT',
{ url: url.slice(0, 100), attempt }
);
} else {
lastError = error;
}
}
// Retry on network errors
if (attempt < maxRetries) {
await sleep(retryDelay * Math.pow(2, attempt));
continue;
}
}
}
throw lastError || new ToolError('Request failed after retries', 'NETWORK_ERROR');
}
/**
* Sleep utility for retry delays
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}