Skip to main content
Glama

MCP API Server

by fikri2992
request-validator.ts15.3 kB
import { z } from 'zod'; import { URL } from 'url'; import { APIRequest, HTTPMethod, ToolCallParams, ErrorResponse, HTTPMethodSchema, URLSchema, HeadersSchema, BodySchema, APIRequestSchema, validateToolParams, createValidationError, } from './types.js'; /** * Configuration options for request validation */ export interface RequestValidatorConfig { /** Maximum request body size in bytes (default: 10MB) */ maxBodySize?: number; /** Allowed URL schemes (default: ['http', 'https']) */ allowedSchemes?: string[]; /** Blocked IP ranges for SSRF protection */ blockedIpRanges?: string[]; /** Blocked hostnames for SSRF protection */ blockedHostnames?: string[]; /** Allow private IP addresses (default: false) */ allowPrivateIps?: boolean; /** Allow localhost addresses (default: false) */ allowLocalhost?: boolean; } /** * Request validator class for parameter validation and security checks * Validates URLs, HTTP methods, headers, request bodies, and prevents SSRF attacks */ export class RequestValidator { private config: Required<RequestValidatorConfig>; constructor(config: RequestValidatorConfig = {}) { this.config = { maxBodySize: config.maxBodySize ?? 10 * 1024 * 1024, // 10MB allowedSchemes: config.allowedSchemes ?? ['http', 'https'], blockedIpRanges: config.blockedIpRanges ?? [ // Private IP ranges (RFC 1918) '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', // Loopback '127.0.0.0/8', '::1/128', // Link-local '169.254.0.0/16', 'fe80::/10', // Multicast '224.0.0.0/4', 'ff00::/8', ], blockedHostnames: config.blockedHostnames ?? [ 'localhost', 'metadata.google.internal', '169.254.169.254', // AWS/GCP metadata ], allowPrivateIps: config.allowPrivateIps ?? false, allowLocalhost: config.allowLocalhost ?? false, }; } /** * Validates tool call parameters based on the tool name */ validateToolCall(toolName: string, params: unknown): ToolCallParams | ErrorResponse { try { // First validate that we have a known tool if (!['api_get', 'api_post', 'api_put', 'api_delete'].includes(toolName)) { return createValidationError(`Unknown tool: ${toolName}`); } // Validate parameters using Zod schemas const validatedParams = validateToolParams(toolName, params); // Perform additional security validation const urlValidation = this.validateUrl(validatedParams.url); if ('error' in urlValidation) { return urlValidation; } // Validate headers if present if (validatedParams.headers) { const headersValidation = this.validateHeaders(validatedParams.headers); if ('error' in headersValidation) { return headersValidation; } } // Validate body if present (for POST/PUT requests) if ('body' in validatedParams && validatedParams.body !== undefined) { const bodyValidation = this.validateBody(validatedParams.body); if ('error' in bodyValidation) { return bodyValidation; } } return validatedParams; } catch (error) { if (error instanceof z.ZodError) { return createValidationError( 'Parameter validation failed', { issues: error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message, code: issue.code, })), } ); } return createValidationError( `Validation error: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Validates an API request object */ validateAPIRequest(request: APIRequest): APIRequest | ErrorResponse { try { // Validate using Zod schema const validatedRequest = APIRequestSchema.parse(request); // Perform additional security validation const urlValidation = this.validateUrl(validatedRequest.url); if ('error' in urlValidation) { return urlValidation; } // Validate headers if present if (validatedRequest.headers) { const headersValidation = this.validateHeaders(validatedRequest.headers); if ('error' in headersValidation) { return headersValidation; } } // Validate body if present if (validatedRequest.body !== undefined) { const bodyValidation = this.validateBody(validatedRequest.body); if ('error' in bodyValidation) { return bodyValidation; } } return validatedRequest; } catch (error) { if (error instanceof z.ZodError) { return createValidationError( 'Request validation failed', { issues: error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message, code: issue.code, })), } ); } return createValidationError( `Request validation error: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Validates a URL and performs SSRF protection checks */ private validateUrl(url: string): { valid: true } | ErrorResponse { try { // Basic URL format validation const urlValidation = URLSchema.safeParse(url); if (!urlValidation.success) { return createValidationError('Invalid URL format', { issues: urlValidation.error.issues, }); } // Parse URL for detailed validation const parsedUrl = new URL(url); // Check allowed schemes if (!this.config.allowedSchemes.includes(parsedUrl.protocol.slice(0, -1))) { return createValidationError( `URL scheme '${parsedUrl.protocol.slice(0, -1)}' is not allowed`, { allowedSchemes: this.config.allowedSchemes, } ); } // Check blocked hostnames (but allow localhost if configured) const hostname = parsedUrl.hostname.toLowerCase(); const isLocalhostHostname = hostname === 'localhost' || hostname.startsWith('127.'); if (this.config.blockedHostnames.includes(hostname) && !(isLocalhostHostname && this.config.allowLocalhost)) { return createValidationError(`Hostname '${hostname}' is blocked for security reasons`); } // SSRF protection - check for private/local IPs if (!this.config.allowPrivateIps || !this.config.allowLocalhost) { const ipCheck = this.checkForPrivateIp(hostname); if (ipCheck.isPrivate && !this.config.allowPrivateIps) { return createValidationError( `Private IP addresses are not allowed: ${hostname}`, { ipType: 'private' } ); } if (ipCheck.isLocalhost && !this.config.allowLocalhost) { return createValidationError( `Localhost addresses are not allowed: ${hostname}`, { ipType: 'localhost' } ); } } // Additional checks for suspicious patterns (but be more permissive for allowed configurations) if (this.isSuspiciousUrl(parsedUrl, this.config.allowLocalhost, this.config.allowPrivateIps)) { return createValidationError('URL contains suspicious patterns that may indicate SSRF attempt'); } return { valid: true }; } catch (error) { return createValidationError( `URL validation error: ${error instanceof Error ? error.message : 'Invalid URL'}` ); } } /** * Validates HTTP headers */ private validateHeaders(headers: Record<string, string>): { valid: true } | ErrorResponse { try { // Validate using Zod schema const validatedHeaders = HeadersSchema.parse(headers); if (!validatedHeaders) { return { valid: true }; } // Check for suspicious headers for (const [key, value] of Object.entries(validatedHeaders)) { // Check header name if (!this.isValidHeaderName(key)) { return createValidationError(`Invalid header name: ${key}`); } // Check header value if (!this.isValidHeaderValue(value)) { return createValidationError(`Invalid header value for '${key}': ${value}`); } // Check for potentially dangerous headers if (this.isDangerousHeader(key, value)) { return createValidationError( `Header '${key}' contains potentially dangerous value`, { header: key, value } ); } } return { valid: true }; } catch (error) { if (error instanceof z.ZodError) { return createValidationError( 'Headers validation failed', { issues: error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message, })), } ); } return createValidationError( `Headers validation error: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Validates request body and handles JSON parsing */ private validateBody(body: string | object): { valid: true } | ErrorResponse { try { // Validate using Zod schema const validatedBody = BodySchema.parse(body); if (validatedBody === undefined) { return { valid: true }; } // Check body size if it's a string if (typeof validatedBody === 'string') { const bodySize = Buffer.byteLength(validatedBody, 'utf8'); if (bodySize > this.config.maxBodySize) { return createValidationError( `Request body too large: ${bodySize} bytes (max: ${this.config.maxBodySize} bytes)` ); } // Try to parse as JSON if it looks like JSON if (this.looksLikeJson(validatedBody)) { try { JSON.parse(validatedBody); } catch (jsonError) { return createValidationError( 'Request body appears to be JSON but is invalid', { parseError: jsonError instanceof Error ? jsonError.message : 'Unknown JSON error' } ); } } } // Check object body size by serializing if (typeof validatedBody === 'object') { try { const serialized = JSON.stringify(validatedBody); const bodySize = Buffer.byteLength(serialized, 'utf8'); if (bodySize > this.config.maxBodySize) { return createValidationError( `Request body too large: ${bodySize} bytes (max: ${this.config.maxBodySize} bytes)` ); } } catch (serializeError) { return createValidationError( 'Request body object cannot be serialized to JSON', { error: serializeError instanceof Error ? serializeError.message : 'Serialization error' } ); } } return { valid: true }; } catch (error) { if (error instanceof z.ZodError) { return createValidationError( 'Body validation failed', { issues: error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message, })), } ); } return createValidationError( `Body validation error: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Checks if a hostname is a private IP or localhost */ private checkForPrivateIp(hostname: string): { isPrivate: boolean; isLocalhost: boolean } { // Check for localhost patterns const isLocalhost = /^(localhost|127\.|::1$|0\.0\.0\.0$)/i.test(hostname); // Check for private IP patterns const isPrivate = /^10\./.test(hostname) || /^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) || /^192\.168\./.test(hostname) || /^169\.254\./.test(hostname) || /^fc00:/i.test(hostname) || /^fe80:/i.test(hostname); return { isPrivate, isLocalhost }; } /** * Checks for suspicious URL patterns that might indicate SSRF attempts */ private isSuspiciousUrl(url: URL, allowLocalhost: boolean = false, allowPrivateIps: boolean = false): boolean { const suspicious = [ // URL encoding attempts /%[0-9a-f]{2}/i.test(url.href), // Unicode/punycode attempts /xn--/.test(url.hostname), // Unusual ports for HTTP/HTTPS (but be more permissive for localhost/private IPs if allowed) url.port && !['80', '443', '8080', '8443', '3000', '5000', '9000'].includes(url.port) && parseInt(url.port) < 1024 && !(allowLocalhost && (url.hostname === 'localhost' || url.hostname.startsWith('127.'))) && !(allowPrivateIps && this.checkForPrivateIp(url.hostname).isPrivate), // Suspicious paths (but be more permissive for allowed configurations) /\/(admin|internal|private|secret|config|env)/i.test(url.pathname) && !(allowLocalhost && (url.hostname === 'localhost' || url.hostname.startsWith('127.'))) && !(allowPrivateIps && this.checkForPrivateIp(url.hostname).isPrivate), ]; return suspicious.some(Boolean); } /** * Validates HTTP header name format */ private isValidHeaderName(name: string): boolean { // HTTP header names should only contain token characters // RFC 7230: token = 1*tchar // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / // "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA return /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(name); } /** * Validates HTTP header value format */ private isValidHeaderValue(value: string): boolean { // HTTP header values should not contain control characters except HTAB // RFC 7230: field-value = *( field-content / obs-fold ) // field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] // field-vchar = VCHAR / obs-text return !/[\x00-\x08\x0A-\x1F\x7F]/.test(value); } /** * Checks for potentially dangerous header values */ private isDangerousHeader(name: string, value: string): boolean { const lowerName = name.toLowerCase(); const lowerValue = value.toLowerCase(); // Check for headers that might be used for SSRF or injection const dangerousPatterns = [ // Host header manipulation lowerName === 'host' && (lowerValue.includes('localhost') || lowerValue.includes('127.0.0.1')), // X-Forwarded headers that might bypass security lowerName.startsWith('x-forwarded') && (lowerValue.includes('localhost') || /\b(?:10|172|192)\b/.test(lowerValue)), // Authorization headers with suspicious patterns lowerName === 'authorization' && lowerValue.includes('..'), // Content-Type with suspicious values lowerName === 'content-type' && /script|javascript|vbscript/.test(lowerValue), ]; return dangerousPatterns.some(Boolean); } /** * Checks if a string looks like JSON */ private looksLikeJson(str: string): boolean { const trimmed = str.trim(); return (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')); } }

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/fikri2992/mcp0'

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