diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts index 23cdeea..97287b0 100644 --- a/src/lib/apiClient.ts +++ b/src/lib/apiClient.ts @@ -4,12 +4,14 @@ const { HttpsProxyAgent } = httpsProxyAgentPkg; import * as https from "https"; import * as fs from "fs"; import config from "../config.js"; +import { isDataUrlPayloadTooLarge } from "../lib/utils.js"; type RequestOptions = { url: string; headers?: Record; params?: Record; body?: any; + timeout?: number; raise_error?: boolean; // default: true }; @@ -99,11 +101,53 @@ class ApiClient { return getAxiosAgent(); } + private validateUrl(url: string, options?: AxiosRequestConfig) { + try { + const parsedUrl = new URL(url); + + // Default safe limits + const maxContentLength = options?.maxContentLength ?? 20 * 1024 * 1024; // 20MB + const maxBodyLength = options?.maxBodyLength ?? 20 * 1024 * 1024; // 20MB + const maxUrlLength = 8000; // cutoff for URLs + + // Check overall URL length + if (url.length > maxUrlLength) { + throw new Error( + `URL length exceeds maxUrlLength (${maxUrlLength} chars)`, + ); + } + + if (parsedUrl.protocol === "data:") { + // Either reject completely OR check payload size + if (isDataUrlPayloadTooLarge(url, maxContentLength)) { + throw new Error("data: URI payload too large or invalid"); + } + } else if (!["http:", "https:"].includes(parsedUrl.protocol)) { + throw new Error(`Unsupported URL scheme: ${parsedUrl.protocol}`); + } + + if ( + options?.data && + Buffer.byteLength(JSON.stringify(options.data), "utf8") > maxBodyLength + ) { + throw new Error( + `Request body exceeds maxBodyLength (${maxBodyLength} bytes)`, + ); + } + } catch (error: any) { + throw new Error(`Invalid URL: ${error.message}`); + } + } + private async requestWrapper( fn: (agent: AxiosRequestConfig["httpsAgent"]) => Promise>, + url: string, + config?: AxiosRequestConfig, raise_error: boolean = true, ): Promise> { try { + this.validateUrl(url, config); + const res = await fn(this.axiosAgent); return new ApiResponse(res); } catch (error: any) { @@ -118,11 +162,19 @@ class ApiClient { url, headers, params, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + params, + timeout, + httpsAgent: this.axiosAgent, + }; return this.requestWrapper( - (agent) => - this.instance.get(url, { headers, params, httpsAgent: agent }), + () => this.instance.get(url, config), + url, + config, raise_error, ); } @@ -131,11 +183,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.post(url, body, { headers, httpsAgent: agent }), + () => this.instance.post(url, config.data, config), + url, + config, raise_error, ); } @@ -144,11 +204,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.put(url, body, { headers, httpsAgent: agent }), + () => this.instance.put(url, config.data, config), + url, + config, raise_error, ); } @@ -157,11 +225,19 @@ class ApiClient { url, headers, body, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + timeout, + httpsAgent: this.axiosAgent, + data: body, + }; return this.requestWrapper( - (agent) => - this.instance.patch(url, body, { headers, httpsAgent: agent }), + () => this.instance.patch(url, config.data, config), + url, + config, raise_error, ); } @@ -170,11 +246,19 @@ class ApiClient { url, headers, params, + timeout, raise_error = true, }: RequestOptions): Promise> { + const config: AxiosRequestConfig = { + headers, + params, + timeout, + httpsAgent: this.axiosAgent, + }; return this.requestWrapper( - (agent) => - this.instance.delete(url, { headers, params, httpsAgent: agent }), + () => this.instance.delete(url, config), + url, + config, raise_error, ); } diff --git a/src/lib/instrumentation.ts b/src/lib/instrumentation.ts index 080d0dc..c9dd1a1 100644 --- a/src/lib/instrumentation.ts +++ b/src/lib/instrumentation.ts @@ -3,7 +3,7 @@ import { getBrowserStackAuth } from "./get-auth.js"; import { createRequire } from "module"; const require = createRequire(import.meta.url); const packageJson = require("../../package.json"); -import axios from "axios"; +import { apiClient } from "./apiClient.js"; import globalConfig from "../config.js"; interface MCPEventPayload { @@ -63,13 +63,16 @@ export function trackMCP( authHeader = `Basic ${Buffer.from(authString).toString("base64")}`; } - axios - .post(instrumentationEndpoint, event, { + apiClient + .post({ + url: instrumentationEndpoint, + body: event, headers: { "Content-Type": "application/json", ...(authHeader ? { Authorization: authHeader } : {}), }, timeout: 2000, + raise_error: false, }) .catch(() => {}); } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bed98a7..5b8d5af 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -89,3 +89,27 @@ export function handleMCPError( `Failed to ${readableToolName}: ${errorMessage}. Please open an issue on GitHub if the problem persists`, ); } + +export function isDataUrlPayloadTooLarge( + dataUrl: string, + maxBytes: number, +): boolean { + const commaIndex = dataUrl.indexOf(","); + if (commaIndex === -1) return true; // malformed + const meta = dataUrl.slice(0, commaIndex); + const payload = dataUrl.slice(commaIndex + 1); + + const isBase64 = /;base64$/i.test(meta); + if (!isBase64) { + try { + const decoded = decodeURIComponent(payload); + return Buffer.byteLength(decoded, "utf8") > maxBytes; + } catch { + return true; + } + } + + const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0; + const decodedBytes = Math.floor((payload.length * 3) / 4) - padding; + return decodedBytes > maxBytes; +}