Skip to main content
Glama
security.ts10.5 kB
/** * Security utilities for PartnerCore Proxy * * Provides path sanitization, input validation, and secret masking * to prevent common security vulnerabilities. */ import * as path from 'path'; import * as fs from 'fs'; /** * Sensitive patterns that should never be logged * Expanded to catch more credential types */ const SENSITIVE_KEY_PATTERNS = [ /api[_-]?key/i, /password/i, /secret/i, /token/i, /bearer/i, /authorization/i, /credential/i, /access[_-]?key/i, // AWS access keys /private[_-]?key/i, // Private keys /connection[_-]?string/i, // Connection strings /^auth$/i, // Simple 'auth' key /session[_-]?id/i, // Session identifiers /refresh[_-]?token/i, // Refresh tokens /client[_-]?secret/i, // OAuth client secrets /signing[_-]?key/i, // Signing keys /encryption[_-]?key/i, // Encryption keys /cert(?:ificate)?/i, // Certificates ]; /** * Patterns that indicate a value is likely a secret * (regardless of key name) */ const SECRET_VALUE_PATTERNS = [ /^-----BEGIN.*KEY-----/, // PEM format keys /^sk_live_/, // Stripe live keys /^sk_test_/, // Stripe test keys /^AKIA[A-Z0-9]{16}$/, // AWS access key IDs /^ghp_[a-zA-Z0-9]{36}$/, // GitHub personal access tokens /^gho_[a-zA-Z0-9]{36}$/, // GitHub OAuth tokens /^github_pat_/, // GitHub PATs (new format) /^xox[bpras]-/, // Slack tokens /^eyJ[a-zA-Z0-9_-]*\./, // JWT tokens ]; /** * Mask sensitive values in objects for safe logging */ export function maskSensitiveData(obj: unknown, depth = 0): unknown { if (depth > 10) return '[MAX_DEPTH]'; if (obj === null || obj === undefined) return obj; if (typeof obj === 'string') { // Check if the value looks like a known secret format for (const pattern of SECRET_VALUE_PATTERNS) { if (pattern.test(obj)) { return '[REDACTED]'; } } // Mask strings that look like keys/tokens (long alphanumeric strings) if (obj.length > 20 && /^[a-zA-Z0-9+/=_-]+$/.test(obj)) { return `[MASKED:${obj.slice(0, 4)}...${obj.slice(-4)}]`; } return obj; } if (Array.isArray(obj)) { return obj.map(item => maskSensitiveData(item, depth + 1)); } if (typeof obj === 'object') { const masked: Record<string, unknown> = {}; for (const [key, value] of Object.entries(obj)) { // Skip prototype pollution vectors if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } const isSensitiveKey = SENSITIVE_KEY_PATTERNS.some(pattern => pattern.test(key)); if (isSensitiveKey && typeof value === 'string' && value.length > 0) { masked[key] = '[REDACTED]'; } else { masked[key] = maskSensitiveData(value, depth + 1); } } return masked; } return obj; } /** * Decode URL-encoded strings recursively to catch bypass attempts */ function decodeUrlRecursive(input: string, maxIterations = 3): string { let decoded = input; let prev = ''; let iterations = 0; // Keep decoding until no change or max iterations while (decoded !== prev && iterations < maxIterations) { prev = decoded; try { decoded = decodeURIComponent(decoded); } catch { // Invalid encoding, return as-is break; } iterations++; } return decoded; } /** * Check for path traversal patterns in a string */ function containsTraversalPatterns(input: string): boolean { const patterns = [ /\.\.[/\\]/, // ../ or ..\ /[/\\]\.\./, // /.. or \.. /^\.\./, // starts with .. /\.\.$/, // ends with .. /^\.\.$/, // just .. ]; return patterns.some(p => p.test(input)); } /** * Validate and sanitize a file path to prevent directory traversal * * @param filePath - The path to validate (can be absolute if within workspace, or relative) * @param workspaceRoot - The allowed root directory * @returns The sanitized absolute path * @throws SecurityError if path is outside workspace or contains suspicious patterns */ export function sanitizePath(filePath: string, workspaceRoot: string): string { // Check for null bytes first (before any other processing) if (filePath.includes('\0')) { throw new SecurityError( 'Null byte detected in path', 'NULL_BYTE_INJECTION' ); } // Decode URL encoding recursively to catch bypass attempts const decoded = decodeUrlRecursive(filePath); // Check for traversal in both original and decoded versions if (containsTraversalPatterns(filePath) || containsTraversalPatterns(decoded)) { throw new SecurityError( 'Path traversal pattern detected', 'PATH_TRAVERSAL' ); } // Additional suspicious patterns that might indicate attack attempts const suspiciousPatterns = [ /%2e/i, // Any remaining URL-encoded dots /%5c/i, // URL-encoded backslash /%2f/i, // URL-encoded forward slash /%00/i, // URL-encoded null /\uff0e/, // Full-width period /\u2024/, // One dot leader /\u2025/, // Two dot leader /\u2026/, // Horizontal ellipsis /\ufe52/, // Small full stop /\uff0f/, // Full-width solidus /\uff3c/, // Full-width reverse solidus /%c0%ae/i, // Overlong UTF-8 encoding of . /%c1%1c/i, // Overlong UTF-8 encoding of / /%c0%af/i, // Overlong UTF-8 encoding of / ]; for (const pattern of suspiciousPatterns) { if (pattern.test(filePath)) { throw new SecurityError( 'Suspicious encoding pattern detected', 'SUSPICIOUS_ENCODING' ); } } // Normalize the workspace root const normalizedRoot = path.resolve(workspaceRoot); // Handle absolute paths - if the path is absolute and within workspace, allow it let normalizedPath: string; if (path.isAbsolute(decoded) || /^[a-zA-Z]:/.test(decoded)) { // Absolute path provided - resolve it directly normalizedPath = path.resolve(decoded); } else { // Relative path - resolve from workspace root normalizedPath = path.resolve(workspaceRoot, decoded); } // Check for path traversal after resolution - path must be within workspace if (!normalizedPath.startsWith(normalizedRoot)) { throw new SecurityError( 'Path resolves outside workspace', 'PATH_TRAVERSAL' ); } return normalizedPath; } /** * Validate that a path exists and is within the workspace */ export function validatePathExists( filePath: string, workspaceRoot: string, type: 'file' | 'directory' | 'any' = 'any' ): string { const sanitized = sanitizePath(filePath, workspaceRoot); if (!fs.existsSync(sanitized)) { throw new SecurityError( 'Path does not exist', 'PATH_NOT_FOUND' ); } const stat = fs.statSync(sanitized); if (type === 'file' && !stat.isFile()) { throw new SecurityError( 'Expected file but found directory', 'NOT_A_FILE' ); } if (type === 'directory' && !stat.isDirectory()) { throw new SecurityError( 'Expected directory but found file', 'NOT_A_DIRECTORY' ); } return sanitized; } /** * Validate tool arguments against expected schema */ export function validateToolArgs( args: Record<string, unknown>, required: string[], types: Record<string, 'string' | 'number' | 'boolean' | 'object' | 'array'> ): void { // Check required fields for (const field of required) { if (!(field in args) || args[field] === undefined || args[field] === null) { throw new ValidationError(`Missing required argument: ${field}`); } } // Check types for (const [field, expectedType] of Object.entries(types)) { if (!(field in args)) continue; const value = args[field]; let actualType: string; if (Array.isArray(value)) { actualType = 'array'; } else { actualType = typeof value; } if (actualType !== expectedType) { throw new ValidationError( `Invalid type for ${field}: expected ${expectedType}, got ${actualType}` ); } } } /** * Sanitize string input to prevent injection */ export function sanitizeString(input: string, maxLength = 10000): string { if (typeof input !== 'string') { throw new ValidationError('Expected string input'); } // Truncate if too long if (input.length > maxLength) { input = input.slice(0, maxLength); } // Remove null bytes input = input.replace(/\0/g, ''); return input; } /** * Security error with code for categorization */ export class SecurityError extends Error { code: string; constructor(message: string, code: string) { super(message); this.name = 'SecurityError'; this.code = code; } } /** * Validation error for invalid input */ export class ValidationError extends Error { constructor(message: string) { super(message); this.name = 'ValidationError'; } } /** * Rate limiter for API calls */ export class RateLimiter { private requests: number[] = []; private readonly maxRequests: number; private readonly windowMs: number; constructor(maxRequests: number, windowMs: number) { this.maxRequests = maxRequests; this.windowMs = windowMs; } /** * Check if a request is allowed */ isAllowed(): boolean { // Handle edge cases if (this.maxRequests <= 0) { return false; } const now = Date.now(); // Remove old requests outside the window this.requests = this.requests.filter(time => now - time < this.windowMs); if (this.requests.length >= this.maxRequests) { return false; } this.requests.push(now); return true; } /** * Get remaining requests in current window */ remaining(): number { const now = Date.now(); this.requests = this.requests.filter(time => now - time < this.windowMs); return Math.max(0, this.maxRequests - this.requests.length); } /** * Get time until window resets (ms) */ resetIn(): number { if (this.requests.length === 0) return 0; const oldest = Math.min(...this.requests); return Math.max(0, this.windowMs - (Date.now() - oldest)); } }

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/ciellosinc/partnercore-proxy'

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