Skip to content

Commit 4e943e5

Browse files
committed
fix: Enhance ApiClient with URL validation
1 parent 3aebfeb commit 4e943e5

File tree

3 files changed

+124
-13
lines changed

3 files changed

+124
-13
lines changed

src/lib/apiClient.ts

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ const { HttpsProxyAgent } = httpsProxyAgentPkg;
44
import * as https from "https";
55
import * as fs from "fs";
66
import config from "../config.js";
7+
import { isDataUrlPayloadTooLarge } from "../lib/utils.js";
78

89
type RequestOptions = {
910
url: string;
1011
headers?: Record<string, string>;
1112
params?: Record<string, string | number>;
1213
body?: any;
14+
timeout?: number;
1315
raise_error?: boolean; // default: true
1416
};
1517

@@ -99,11 +101,53 @@ class ApiClient {
99101
return getAxiosAgent();
100102
}
101103

104+
private validateUrl(url: string, options?: AxiosRequestConfig) {
105+
try {
106+
const parsedUrl = new URL(url);
107+
108+
// Default safe limits
109+
const maxContentLength = options?.maxContentLength ?? 20 * 1024 * 1024; // 20MB
110+
const maxBodyLength = options?.maxBodyLength ?? 20 * 1024 * 1024; // 20MB
111+
const maxUrlLength = 8000; // cutoff for URLs
112+
113+
// Check overall URL length
114+
if (url.length > maxUrlLength) {
115+
throw new Error(
116+
`URL length exceeds maxUrlLength (${maxUrlLength} chars)`,
117+
);
118+
}
119+
120+
if (parsedUrl.protocol === "data:") {
121+
// Either reject completely OR check payload size
122+
if (isDataUrlPayloadTooLarge(url, maxContentLength)) {
123+
throw new Error("data: URI payload too large or invalid");
124+
}
125+
} else if (!["http:", "https:"].includes(parsedUrl.protocol)) {
126+
throw new Error(`Unsupported URL scheme: ${parsedUrl.protocol}`);
127+
}
128+
129+
if (
130+
options?.data &&
131+
Buffer.byteLength(JSON.stringify(options.data), "utf8") > maxBodyLength
132+
) {
133+
throw new Error(
134+
`Request body exceeds maxBodyLength (${maxBodyLength} bytes)`,
135+
);
136+
}
137+
} catch (error: any) {
138+
throw new Error(`Invalid URL: ${error.message}`);
139+
}
140+
}
141+
102142
private async requestWrapper<T>(
103143
fn: (agent: AxiosRequestConfig["httpsAgent"]) => Promise<AxiosResponse<T>>,
144+
url: string,
145+
config?: AxiosRequestConfig,
104146
raise_error: boolean = true,
105147
): Promise<ApiResponse<T>> {
106148
try {
149+
this.validateUrl(url, config);
150+
107151
const res = await fn(this.axiosAgent);
108152
return new ApiResponse<T>(res);
109153
} catch (error: any) {
@@ -118,11 +162,19 @@ class ApiClient {
118162
url,
119163
headers,
120164
params,
165+
timeout,
121166
raise_error = true,
122167
}: RequestOptions): Promise<ApiResponse<T>> {
168+
const config: AxiosRequestConfig = {
169+
headers,
170+
params,
171+
timeout,
172+
httpsAgent: this.axiosAgent,
173+
};
123174
return this.requestWrapper<T>(
124-
(agent) =>
125-
this.instance.get<T>(url, { headers, params, httpsAgent: agent }),
175+
() => this.instance.get<T>(url, config),
176+
url,
177+
config,
126178
raise_error,
127179
);
128180
}
@@ -131,11 +183,19 @@ class ApiClient {
131183
url,
132184
headers,
133185
body,
186+
timeout,
134187
raise_error = true,
135188
}: RequestOptions): Promise<ApiResponse<T>> {
189+
const config: AxiosRequestConfig = {
190+
headers,
191+
timeout,
192+
httpsAgent: this.axiosAgent,
193+
data: body,
194+
};
136195
return this.requestWrapper<T>(
137-
(agent) =>
138-
this.instance.post<T>(url, body, { headers, httpsAgent: agent }),
196+
() => this.instance.post<T>(url, config.data, config),
197+
url,
198+
config,
139199
raise_error,
140200
);
141201
}
@@ -144,11 +204,19 @@ class ApiClient {
144204
url,
145205
headers,
146206
body,
207+
timeout,
147208
raise_error = true,
148209
}: RequestOptions): Promise<ApiResponse<T>> {
210+
const config: AxiosRequestConfig = {
211+
headers,
212+
timeout,
213+
httpsAgent: this.axiosAgent,
214+
data: body,
215+
};
149216
return this.requestWrapper<T>(
150-
(agent) =>
151-
this.instance.put<T>(url, body, { headers, httpsAgent: agent }),
217+
() => this.instance.put<T>(url, config.data, config),
218+
url,
219+
config,
152220
raise_error,
153221
);
154222
}
@@ -157,11 +225,19 @@ class ApiClient {
157225
url,
158226
headers,
159227
body,
228+
timeout,
160229
raise_error = true,
161230
}: RequestOptions): Promise<ApiResponse<T>> {
231+
const config: AxiosRequestConfig = {
232+
headers,
233+
timeout,
234+
httpsAgent: this.axiosAgent,
235+
data: body,
236+
};
162237
return this.requestWrapper<T>(
163-
(agent) =>
164-
this.instance.patch<T>(url, body, { headers, httpsAgent: agent }),
238+
() => this.instance.patch<T>(url, config.data, config),
239+
url,
240+
config,
165241
raise_error,
166242
);
167243
}
@@ -170,11 +246,19 @@ class ApiClient {
170246
url,
171247
headers,
172248
params,
249+
timeout,
173250
raise_error = true,
174251
}: RequestOptions): Promise<ApiResponse<T>> {
252+
const config: AxiosRequestConfig = {
253+
headers,
254+
params,
255+
timeout,
256+
httpsAgent: this.axiosAgent,
257+
};
175258
return this.requestWrapper<T>(
176-
(agent) =>
177-
this.instance.delete<T>(url, { headers, params, httpsAgent: agent }),
259+
() => this.instance.delete<T>(url, config),
260+
url,
261+
config,
178262
raise_error,
179263
);
180264
}

src/lib/instrumentation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { getBrowserStackAuth } from "./get-auth.js";
33
import { createRequire } from "module";
44
const require = createRequire(import.meta.url);
55
const packageJson = require("../../package.json");
6-
import axios from "axios";
6+
import { apiClient } from "./apiClient.js";
77
import globalConfig from "../config.js";
88

99
interface MCPEventPayload {
@@ -63,13 +63,16 @@ export function trackMCP(
6363
authHeader = `Basic ${Buffer.from(authString).toString("base64")}`;
6464
}
6565

66-
axios
67-
.post(instrumentationEndpoint, event, {
66+
apiClient
67+
.post({
68+
url: instrumentationEndpoint,
69+
body: event,
6870
headers: {
6971
"Content-Type": "application/json",
7072
...(authHeader ? { Authorization: authHeader } : {}),
7173
},
7274
timeout: 2000,
75+
raise_error: false,
7376
})
7477
.catch(() => {});
7578
}

src/lib/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,27 @@ export function handleMCPError(
8989
`Failed to ${readableToolName}: ${errorMessage}. Please open an issue on GitHub if the problem persists`,
9090
);
9191
}
92+
93+
export function isDataUrlPayloadTooLarge(
94+
dataUrl: string,
95+
maxBytes: number,
96+
): boolean {
97+
const commaIndex = dataUrl.indexOf(",");
98+
if (commaIndex === -1) return true; // malformed
99+
const meta = dataUrl.slice(0, commaIndex);
100+
const payload = dataUrl.slice(commaIndex + 1);
101+
102+
const isBase64 = /;base64$/i.test(meta);
103+
if (!isBase64) {
104+
try {
105+
const decoded = decodeURIComponent(payload);
106+
return Buffer.byteLength(decoded, "utf8") > maxBytes;
107+
} catch {
108+
return true;
109+
}
110+
}
111+
112+
const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
113+
const decodedBytes = Math.floor((payload.length * 3) / 4) - padding;
114+
return decodedBytes > maxBytes;
115+
}

0 commit comments

Comments
 (0)