Skip to content

Commit ae133cf

Browse files
committed
fix: prevent path traversal in style and tileset tool URL construction
Five tools (RetrieveStyle, DeleteStyle, UpdateStyle, PreviewStyle, TilequeryTool) concatenated user-supplied path parameters directly into Mapbox API URLs without validation or encoding. Because Node.js fetch uses the WHATWG URL parser, `../` sequences were normalized before sending, allowing requests to reach unintended API endpoints. Changes: - Add shared `styleIdSchema` with allowlist regex that rejects path separators, dots, percent-encoded sequences, and null bytes - Apply `styleIdSchema` to all four style tools via a shared module (src/tools/shared/styleId.schema.ts) - Add format validation to TilequeryTool tilesetId (owner.name format) - Wrap both username and styleId/tilesetId in `encodeURIComponent` at every URL construction site (defense-in-depth) - Replace silent fallback in output schema validation with explicit `isError: true` responses across all API tools, preventing unintended API responses from being forwarded to callers - Remove now-unused BaseTool.validateOutput() method - Add test/security/path-traversal.test.ts with 52 tests covering schema rejection, valid ID acceptance, URL encoding, and response schema mismatch behavior
1 parent 759dbfc commit ae133cf

23 files changed

Lines changed: 498 additions & 168 deletions

File tree

src/tools/BaseTool.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -135,26 +135,4 @@ export abstract class BaseTool<
135135
this.server.server.sendLoggingMessage({ level, data });
136136
}
137137
}
138-
139-
/**
140-
* Validates output data against the output schema.
141-
* If validation fails, logs a warning and returns the raw data instead of throwing an error.
142-
* This allows tools to continue functioning when API responses deviate from expected schemas.
143-
*/
144-
protected validateOutput<T>(
145-
schema: ZodTypeAny,
146-
rawData: unknown,
147-
toolName: string
148-
): T {
149-
try {
150-
return schema.parse(rawData) as T;
151-
} catch (validationError) {
152-
this.log(
153-
'warning',
154-
`${toolName}: Output schema validation failed - ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
155-
);
156-
// Graceful fallback to raw data
157-
return rawData as T;
158-
}
159-
}
160138
}

src/tools/create-token-tool/CreateTokenTool.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,20 @@ export class CreateTokenTool extends MapboxApiBasedTool<
9292

9393
const rawData = await response.json();
9494

95-
// Validate response against schema with graceful fallback
96-
const data = this.validateOutput<Record<string, unknown>>(
97-
CreateTokenOutputSchema,
98-
rawData,
99-
'CreateTokenTool'
100-
);
95+
let data;
96+
try {
97+
data = CreateTokenOutputSchema.parse(rawData);
98+
} catch {
99+
return {
100+
isError: true,
101+
content: [
102+
{
103+
type: 'text',
104+
text: 'Unexpected API response format from Mapbox API'
105+
}
106+
]
107+
};
108+
}
101109

102110
this.log('info', `CreateTokenTool: Successfully created token`);
103111

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { z } from 'zod';
2+
import { styleIdSchema } from '../shared/styleId.schema.js';
23

34
export const DeleteStyleSchema = z.object({
4-
styleId: z.string().describe('Style ID to delete')
5+
styleId: styleIdSchema.describe('Style ID to delete')
56
});
67

78
export type DeleteStyleInput = z.infer<typeof DeleteStyleSchema>;

src/tools/delete-style-tool/DeleteStyleTool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class DeleteStyleTool extends MapboxApiBasedTool<
3434
_context: ToolExecutionContext
3535
): Promise<CallToolResult> {
3636
const username = getUserNameFromToken(accessToken);
37-
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`;
37+
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`;
3838

3939
const response = await this.httpRequest(url, {
4040
method: 'DELETE'

src/tools/list-styles-tool/ListStylesTool.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,20 @@ export class ListStylesTool extends MapboxApiBasedTool<
7070

7171
const rawData = await response.json();
7272

73-
// Validate the API response (which is an array) with graceful fallback
74-
const validatedData = this.validateOutput(
75-
StylesArraySchema,
76-
rawData,
77-
'ListStylesTool'
78-
);
73+
let validatedData;
74+
try {
75+
validatedData = StylesArraySchema.parse(rawData);
76+
} catch {
77+
return {
78+
isError: true,
79+
content: [
80+
{
81+
type: 'text',
82+
text: 'Unexpected API response format from Mapbox API'
83+
}
84+
]
85+
};
86+
}
7987

8088
this.log('info', `ListStylesTool: Successfully listed styles`);
8189

src/tools/list-tokens-tool/ListTokensTool.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,20 @@ export class ListTokensTool extends MapboxApiBasedTool<
130130
? data
131131
: (data as { tokens?: unknown[] }).tokens || [];
132132

133-
// Validate tokens array against TokenObjectSchema with graceful fallback
134-
const validatedTokens = this.validateOutput<unknown[]>(
135-
TokenObjectSchema.array(),
136-
tokens,
137-
'ListTokensTool'
138-
);
133+
let validatedTokens;
134+
try {
135+
validatedTokens = TokenObjectSchema.array().parse(tokens);
136+
} catch {
137+
return {
138+
isError: true,
139+
content: [
140+
{
141+
type: 'text',
142+
text: 'Unexpected API response format from Mapbox API'
143+
}
144+
]
145+
};
146+
}
139147

140148
allTokens.push(...validatedTokens);
141149
this.log(

src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { z } from 'zod';
2+
import { styleIdSchema } from '../shared/styleId.schema.js';
23

34
export const PreviewStyleSchema = z.object({
4-
styleId: z.string().describe('Style ID to preview'),
5+
styleId: styleIdSchema.describe('Style ID to preview'),
56
accessToken: z
67
.string()
78
.startsWith(

src/tools/preview-style-tool/PreviewStyleTool.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
7474
const hashFragment =
7575
hashParams.length > 0 ? `#${hashParams.join('/')}` : '';
7676

77-
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`;
77+
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}.html?${params.toString()}${hashFragment}`;
7878

7979
// Build content array with URL
8080
const content: CallToolResult['content'] = [
@@ -86,7 +86,7 @@ export class PreviewStyleTool extends BaseTool<typeof PreviewStyleSchema> {
8686

8787
// Add MCP-UI resource (for legacy MCP-UI clients)
8888
const uiResource = createUIResource({
89-
uri: `ui://mapbox/preview-style/${userName}/${input.styleId}`,
89+
uri: `ui://mapbox/preview-style/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}`,
9090
content: {
9191
type: 'externalUrl',
9292
iframeUrl: url
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { z } from 'zod';
2+
import { styleIdSchema } from '../shared/styleId.schema.js';
23

34
export const RetrieveStyleSchema = z.object({
4-
styleId: z.string().describe('Style ID to retrieve')
5+
styleId: styleIdSchema.describe('Style ID to retrieve')
56
});
67

78
export type RetrieveStyleInput = z.infer<typeof RetrieveStyleSchema>;

src/tools/retrieve-style-tool/RetrieveStyleTool.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class RetrieveStyleTool extends MapboxApiBasedTool<
4444
_context: ToolExecutionContext
4545
): Promise<CallToolResult> {
4646
const username = getUserNameFromToken(accessToken);
47-
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`;
47+
const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`;
4848

4949
const response = await this.httpRequest(url);
5050

@@ -57,16 +57,22 @@ export class RetrieveStyleTool extends MapboxApiBasedTool<
5757
let data: MapboxStyleOutput;
5858
try {
5959
data = MapboxStyleOutputSchema.parse(rawData);
60-
} catch (validationError) {
61-
this.log(
62-
'warning',
63-
`Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}`
64-
);
65-
// Graceful fallback to raw data
66-
data = rawData as MapboxStyleOutput;
60+
} catch {
61+
return {
62+
isError: true,
63+
content: [
64+
{
65+
type: 'text',
66+
text: 'Unexpected API response format from Mapbox API'
67+
}
68+
]
69+
};
6770
}
6871

69-
this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`);
72+
this.log(
73+
'info',
74+
`RetrieveStyleTool: Successfully retrieved style ${data.id}`
75+
);
7076

7177
return {
7278
content: [

0 commit comments

Comments
 (0)