Skip to main content
Glama
security.tsโ€ข8.5 kB
import path from 'path'; /** * Security utilities for input validation and sanitization */ /** * Sensitive directories that should be blocked from access */ const BLOCKED_DIRECTORIES = [ '/etc', '/home', '/.ssh', '/root', '/var', '/usr', '/sys', '/proc', '/dev', '/boot', '/bin', '/sbin', '/lib', '/lib64' ]; /** * Validates a file path to prevent path traversal attacks * Blocks access to sensitive system directories * * @param userPath - User-provided file path (relative or absolute) * @param baseDir - Base directory to resolve relative paths (defaults to cwd) * @returns Validated absolute path * @throws Error if path is invalid or blocked */ export function validateFilePath(userPath: string, baseDir: string = process.cwd()): string { // Check for null bytes (common in path traversal attacks) if (userPath.includes('\0')) { throw new Error('Security: Invalid path - contains null byte'); } // Check for suspicious patterns if (userPath.includes('..')) { throw new Error('Security: Invalid path - path traversal detected'); } // Resolve to absolute path const resolvedPath = path.resolve(baseDir, userPath); const normalizedPath = path.normalize(resolvedPath); const resolvedBase = path.resolve(baseDir); // FIRST: Check if path stays within base directory // This is the primary security check - paths must stay within the working directory if (!normalizedPath.startsWith(resolvedBase + path.sep) && normalizedPath !== resolvedBase) { // Path is trying to escape base directory // NOW check if it's trying to access sensitive directories for (const blockedDir of BLOCKED_DIRECTORIES) { if (normalizedPath.startsWith(blockedDir)) { throw new Error(`Security: Access denied - path in sensitive directory: ${blockedDir}`); } } // If not in a blocked directory, still don't allow escaping base directory throw new Error('Security: Access denied - path escapes base directory'); } // Path is within base directory - this is allowed even if base directory itself // is under /home (like in CI environments: /home/runner/work/...) return normalizedPath; } /** * Masks a token for safe logging * Shows first 4 and last 4 characters * * @param token - Token to mask * @returns Masked token string */ export function maskToken(token: string): string { if (!token) { return '[empty token]'; } if (token.length <= 8) { return '****'; } const start = token.substring(0, 4); const end = token.substring(token.length - 4); return `${start}...${end}`; } /** * Masks an Authorization header value for safe logging * * @param authHeader - Authorization header value (e.g., "Bearer xyz123...") * @returns Masked header value */ export function maskAuthHeader(authHeader: string): string { if (!authHeader) { return '[no auth]'; } // Extract token from "Bearer <token>" or "Basic <token>" const parts = authHeader.split(' '); if (parts.length !== 2) { return '[invalid format]'; } const [type, token] = parts; return `${type} ${maskToken(token)}`; } /** * Configuration for URL validation */ export interface UrlValidationConfig { strictMode?: boolean; skipOnError?: boolean; } /** * Validates a file URL to prevent SSRF attacks * * @param fileUrl - URL or path to validate * @param config - Validation configuration * @returns Validated URL * @throws Error if URL is invalid (unless skipOnError is true) */ export function validateFileUrl( fileUrl: string, config: UrlValidationConfig = {} ): string { const { strictMode = true, skipOnError = false } = config; try { // Check for null bytes if (fileUrl.includes('\0')) { throw new Error('Security: Invalid URL - contains null byte'); } // Check for dangerous protocols const dangerousProtocols = /^(file|ftp|gopher|data|javascript|vbscript):/i; if (dangerousProtocols.test(fileUrl)) { throw new Error('Security: Invalid URL - dangerous protocol detected'); } // In strict mode, only allow specific patterns if (strictMode) { // Allow relative paths starting with /files/ followed by alphanumeric, dash, underscore, dot, or slash if (fileUrl.startsWith('/files/')) { const pathPart = fileUrl.substring(7); // Remove '/files/' if (!/^[a-zA-Z0-9_/.-]+$/.test(pathPart)) { throw new Error('Security: Invalid URL - contains disallowed characters'); } } // Allow relative paths starting with artifacts/ (Zebrunner video/screenshot artifacts) else if (fileUrl.startsWith('artifacts/') || fileUrl.startsWith('/artifacts/')) { const pathPart = fileUrl.startsWith('/') ? fileUrl.substring(1) : fileUrl; // Remove leading / if present // Allow alphanumeric, dash, underscore, dot, slash, question mark (for query params), equals, ampersand if (!/^artifacts\/[a-zA-Z0-9_/.?=&-]+$/.test(pathPart)) { throw new Error('Security: Invalid URL - contains disallowed characters in artifacts path'); } } // Allow full URLs only if they're HTTPS else if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) { // Parse URL to validate try { const url = new URL(fileUrl); // Only allow https in production if (url.protocol !== 'https:' && process.env.NODE_ENV === 'production') { throw new Error('Security: Invalid URL - HTTPS required in production'); } } catch (e) { throw new Error('Security: Invalid URL - malformed URL'); } } else { throw new Error('Security: Invalid URL - must start with /files/, artifacts/, or be a valid HTTP(S) URL'); } } return fileUrl; } catch (error) { if (skipOnError) { console.warn(`[Security] URL validation warning: ${error instanceof Error ? error.message : error}`); console.warn(`[Security] Proceeding with unvalidated URL (skipOnError=true): ${fileUrl}`); return fileUrl; } throw error; } } /** * Sanitizes error messages to prevent information leakage * Shows full errors in DEBUG/development, generic messages in production * * @param error - Error object * @param userMessage - User-friendly message to return in production * @param context - Optional context for logging * @returns Sanitized error message */ export function sanitizeErrorMessage( error: any, userMessage: string = 'An error occurred', context?: string ): string { const isDebug = process.env.DEBUG === 'true'; const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'; // In development or debug mode, show full errors if (isDebug || isDev) { const contextPrefix = context ? `[${context}] ` : ''; console.error(`${contextPrefix}Full error details:`, error); // Return detailed error message in dev/debug if (error instanceof Error) { return `${userMessage}: ${error.message}`; } return `${userMessage}: ${String(error)}`; } // In production, log error internally but return generic message if (context) { console.error(`[${context}] Error occurred (details hidden in production)`); } else { console.error('Error occurred (details hidden in production)'); } // Return generic message to user in production return userMessage; } /** * Sanitize error for API responses * Ensures no sensitive information leaks through error responses * * @param error - Error object * @param operation - Description of operation that failed * @returns Sanitized error object for API response */ export function sanitizeApiError(error: any, operation: string): { message: string; operation: string; timestamp: string; } { const isDebug = process.env.DEBUG === 'true'; const isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'; // Log full error internally console.error(`[API Error] ${operation}:`, error); // In development/debug, return detailed error if (isDebug || isDev) { return { message: error instanceof Error ? error.message : String(error), operation, timestamp: new Date().toISOString() }; } // In production, return generic error return { message: `Failed to ${operation}. Please try again or contact support if the problem persists.`, operation, timestamp: new Date().toISOString() }; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/maksimsarychau/mcp-zebrunner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server