From ae133cf453864cdd2805afb6f765ae8ebe009b94 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 14:14:03 +0300 Subject: [PATCH 1/6] 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 --- src/tools/BaseTool.ts | 22 -- .../create-token-tool/CreateTokenTool.ts | 20 +- .../DeleteStyleTool.input.schema.ts | 3 +- .../delete-style-tool/DeleteStyleTool.ts | 2 +- src/tools/list-styles-tool/ListStylesTool.ts | 20 +- src/tools/list-tokens-tool/ListTokensTool.ts | 20 +- .../PreviewStyleTool.input.schema.ts | 3 +- .../preview-style-tool/PreviewStyleTool.ts | 4 +- .../RetrieveStyleTool.input.schema.ts | 3 +- .../retrieve-style-tool/RetrieveStyleTool.ts | 24 +- src/tools/shared/styleId.schema.ts | 12 + .../TilequeryTool.input.schema.ts | 4 + src/tools/tilequery-tool/TilequeryTool.ts | 20 +- .../UpdateStyleTool.input.schema.ts | 3 +- .../update-style-tool/UpdateStyleTool.ts | 19 +- test/security/path-traversal.test.ts | 305 ++++++++++++++++++ .../create-token-tool/CreateTokenTool.test.ts | 23 +- .../delete-style-tool/DeleteStyleTool.test.ts | 9 +- .../list-styles-tool/ListStylesTool.test.ts | 26 +- .../list-tokens-tool/ListTokensTool.test.ts | 23 +- .../PreviewStyleTool.test.ts | 37 ++- .../RetrieveStyleTool.test.ts | 43 ++- .../update-style-tool/UpdateStyleTool.test.ts | 21 +- 23 files changed, 498 insertions(+), 168 deletions(-) create mode 100644 src/tools/shared/styleId.schema.ts create mode 100644 test/security/path-traversal.test.ts diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index e91b367..5970aea 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -135,26 +135,4 @@ export abstract class BaseTool< this.server.server.sendLoggingMessage({ level, data }); } } - - /** - * Validates output data against the output schema. - * If validation fails, logs a warning and returns the raw data instead of throwing an error. - * This allows tools to continue functioning when API responses deviate from expected schemas. - */ - protected validateOutput( - schema: ZodTypeAny, - rawData: unknown, - toolName: string - ): T { - try { - return schema.parse(rawData) as T; - } catch (validationError) { - this.log( - 'warning', - `${toolName}: Output schema validation failed - ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` - ); - // Graceful fallback to raw data - return rawData as T; - } - } } diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index cfd07bb..6744221 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -92,12 +92,20 @@ export class CreateTokenTool extends MapboxApiBasedTool< const rawData = await response.json(); - // Validate response against schema with graceful fallback - const data = this.validateOutput>( - CreateTokenOutputSchema, - rawData, - 'CreateTokenTool' - ); + let data; + try { + data = CreateTokenOutputSchema.parse(rawData); + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; + } this.log('info', `CreateTokenTool: Successfully created token`); diff --git a/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts b/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts index c5a0909..ea7965c 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.input.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { styleIdSchema } from '../shared/styleId.schema.js'; export const DeleteStyleSchema = z.object({ - styleId: z.string().describe('Style ID to delete') + styleId: styleIdSchema.describe('Style ID to delete') }); export type DeleteStyleInput = z.infer; diff --git a/src/tools/delete-style-tool/DeleteStyleTool.ts b/src/tools/delete-style-tool/DeleteStyleTool.ts index 53a1686..abbd6f9 100644 --- a/src/tools/delete-style-tool/DeleteStyleTool.ts +++ b/src/tools/delete-style-tool/DeleteStyleTool.ts @@ -34,7 +34,7 @@ export class DeleteStyleTool extends MapboxApiBasedTool< _context: ToolExecutionContext ): Promise { const username = getUserNameFromToken(accessToken); - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`; const response = await this.httpRequest(url, { method: 'DELETE' diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index ffac1d9..ff91992 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -70,12 +70,20 @@ export class ListStylesTool extends MapboxApiBasedTool< const rawData = await response.json(); - // Validate the API response (which is an array) with graceful fallback - const validatedData = this.validateOutput( - StylesArraySchema, - rawData, - 'ListStylesTool' - ); + let validatedData; + try { + validatedData = StylesArraySchema.parse(rawData); + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; + } this.log('info', `ListStylesTool: Successfully listed styles`); diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 69a3221..e2eb0b6 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -130,12 +130,20 @@ export class ListTokensTool extends MapboxApiBasedTool< ? data : (data as { tokens?: unknown[] }).tokens || []; - // Validate tokens array against TokenObjectSchema with graceful fallback - const validatedTokens = this.validateOutput( - TokenObjectSchema.array(), - tokens, - 'ListTokensTool' - ); + let validatedTokens; + try { + validatedTokens = TokenObjectSchema.array().parse(tokens); + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; + } allTokens.push(...validatedTokens); this.log( diff --git a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts index eec52c3..fb9111b 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.input.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { styleIdSchema } from '../shared/styleId.schema.js'; export const PreviewStyleSchema = z.object({ - styleId: z.string().describe('Style ID to preview'), + styleId: styleIdSchema.describe('Style ID to preview'), accessToken: z .string() .startsWith( diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index 26070f3..f445161 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -74,7 +74,7 @@ export class PreviewStyleTool extends BaseTool { const hashFragment = hashParams.length > 0 ? `#${hashParams.join('/')}` : ''; - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${userName}/${input.styleId}.html?${params.toString()}${hashFragment}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}.html?${params.toString()}${hashFragment}`; // Build content array with URL const content: CallToolResult['content'] = [ @@ -86,7 +86,7 @@ export class PreviewStyleTool extends BaseTool { // Add MCP-UI resource (for legacy MCP-UI clients) const uiResource = createUIResource({ - uri: `ui://mapbox/preview-style/${userName}/${input.styleId}`, + uri: `ui://mapbox/preview-style/${encodeURIComponent(userName)}/${encodeURIComponent(input.styleId)}`, content: { type: 'externalUrl', iframeUrl: url diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts index a6e7aa3..181014c 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { styleIdSchema } from '../shared/styleId.schema.js'; export const RetrieveStyleSchema = z.object({ - styleId: z.string().describe('Style ID to retrieve') + styleId: styleIdSchema.describe('Style ID to retrieve') }); export type RetrieveStyleInput = z.infer; diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index b7c4ea1..a89cb30 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -44,7 +44,7 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< _context: ToolExecutionContext ): Promise { const username = getUserNameFromToken(accessToken); - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`; const response = await this.httpRequest(url); @@ -57,16 +57,22 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< let data: MapboxStyleOutput; try { data = MapboxStyleOutputSchema.parse(rawData); - } catch (validationError) { - this.log( - 'warning', - `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` - ); - // Graceful fallback to raw data - data = rawData as MapboxStyleOutput; + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; } - this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); + this.log( + 'info', + `RetrieveStyleTool: Successfully retrieved style ${data.id}` + ); return { content: [ diff --git a/src/tools/shared/styleId.schema.ts b/src/tools/shared/styleId.schema.ts new file mode 100644 index 0000000..5581450 --- /dev/null +++ b/src/tools/shared/styleId.schema.ts @@ -0,0 +1,12 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const styleIdSchema = z + .string() + .regex( + /^[a-z0-9][a-z0-9-]*[a-z0-9]$/, + 'Invalid Mapbox style ID (only lowercase letters, numbers, and hyphens allowed)' + ) + .describe('Mapbox style ID'); diff --git a/src/tools/tilequery-tool/TilequeryTool.input.schema.ts b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts index 1808987..dc9ae12 100644 --- a/src/tools/tilequery-tool/TilequeryTool.input.schema.ts +++ b/src/tools/tilequery-tool/TilequeryTool.input.schema.ts @@ -6,6 +6,10 @@ import { z } from 'zod'; export const TilequerySchema = z.object({ tilesetId: z .string() + .regex( + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, + 'Invalid tileset ID format (expected: owner.tileset-name)' + ) .optional() .default('mapbox.mapbox-streets-v8') .describe('Tileset ID to query (default: mapbox.mapbox-streets-v8)'), diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index a2c02a0..4eae273 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -44,7 +44,7 @@ export class TilequeryTool extends MapboxApiBasedTool< ): Promise { const { tilesetId, longitude, latitude, ...queryParams } = input; const url = new URL( - `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}/tilequery/${longitude},${latitude}.json` + `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${encodeURIComponent(tilesetId)}/tilequery/${longitude},${latitude}.json` ); if (queryParams.radius !== undefined) { @@ -81,17 +81,19 @@ export class TilequeryTool extends MapboxApiBasedTool< const rawData = await response.json(); - // Validate response against schema with graceful fallback let data: TilequeryResponse; try { data = TilequeryResponseSchema.parse(rawData); - } catch (validationError) { - this.log( - 'warning', - `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` - ); - // Graceful fallback to raw data - data = rawData as TilequeryResponse; + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; } this.log( diff --git a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts index b75dc8e..ea81ed1 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.input.schema.ts @@ -2,11 +2,12 @@ // Licensed under the MIT License. import { z } from 'zod'; +import { styleIdSchema } from '../shared/styleId.schema.js'; // INPUT Schema - Accepts a complete Mapbox Style Specification as a generic object // This avoids complex schemas with .passthrough() that break some MCP clients (Cursor + OpenAI) export const UpdateStyleInputSchema = z.object({ - styleId: z.string().describe('Style ID to update'), + styleId: styleIdSchema.describe('Style ID to update'), name: z.string().optional().describe('New name for the style'), style: z .record(z.string(), z.any()) diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index 27449a5..848c2e8 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -44,7 +44,7 @@ export class UpdateStyleTool extends MapboxApiBasedTool< _context: ToolExecutionContext ): Promise { const username = getUserNameFromToken(accessToken); - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${username}/${input.styleId}?access_token=${accessToken}`; + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodeURIComponent(username)}/${encodeURIComponent(input.styleId)}?access_token=${accessToken}`; const payload: Record = {}; if (input.name) payload.name = input.name; @@ -67,13 +67,16 @@ export class UpdateStyleTool extends MapboxApiBasedTool< let data: MapboxStyleOutput; try { data = MapboxStyleOutputSchema.parse(rawData); - } catch (validationError) { - this.log( - 'warning', - `Schema validation failed for search response: ${validationError instanceof Error ? validationError.message : 'Unknown validation error'}` - ); - // Graceful fallback to raw data - data = rawData as MapboxStyleOutput; + } catch { + return { + isError: true, + content: [ + { + type: 'text', + text: 'Unexpected API response format from Mapbox API' + } + ] + }; } this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); diff --git a/test/security/path-traversal.test.ts b/test/security/path-traversal.test.ts new file mode 100644 index 0000000..f7b27bd --- /dev/null +++ b/test/security/path-traversal.test.ts @@ -0,0 +1,305 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, beforeAll } from 'vitest'; +import { RetrieveStyleSchema } from '../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js'; +import { DeleteStyleSchema } from '../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js'; +import { UpdateStyleInputSchema } from '../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js'; +import { PreviewStyleSchema } from '../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js'; +import { TilequerySchema } from '../../src/tools/tilequery-tool/TilequeryTool.input.schema.js'; +import { RetrieveStyleTool } from '../../src/tools/retrieve-style-tool/RetrieveStyleTool.js'; +import { UpdateStyleTool } from '../../src/tools/update-style-tool/UpdateStyleTool.js'; +import { TilequeryTool } from '../../src/tools/tilequery-tool/TilequeryTool.js'; +import { DeleteStyleTool } from '../../src/tools/delete-style-tool/DeleteStyleTool.js'; +import { PreviewStyleTool } from '../../src/tools/preview-style-tool/PreviewStyleTool.js'; +import { setupHttpRequest } from '../utils/httpPipelineUtils.js'; + +// 25-char alphanumeric — a real Mapbox style ID format +const VALID_STYLE_ID = 'cmojrmkc9002t01ry96yi6h48'; +const VALID_TILESET_ID = 'mapbox.mapbox-streets-v8'; +const MOCK_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; +const PUBLIC_MOCK_TOKEN = + 'pk.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; + +function makeToken(username: string): string { + const header = 'eyJhbGciOiJIUzI1NiJ9'; + const payload = Buffer.from( + JSON.stringify({ u: username, a: 'test-api' }) + ).toString('base64'); + return `${header}.${payload}.sig`; +} + +function makePublicToken(username: string): string { + const payload = Buffer.from( + JSON.stringify({ u: username, a: 'test-api' }) + ).toString('base64'); + return `pk.${payload}.sig`; +} + +const MOCK_STYLE_RESPONSE = { + id: VALID_STYLE_ID, + name: 'Test Style', + owner: 'test-user', + version: 8, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-01T00:00:00.000Z', + visibility: 'private', + sources: {}, + layers: [] +}; + +beforeAll(() => { + process.env.MAPBOX_ACCESS_TOKEN = MOCK_TOKEN; +}); + +describe('path traversal security', () => { + const STYLE_TRAVERSAL_PAYLOADS = [ + '../../../tokens/v2/testuser', + '../testuser', + `../testuser/${VALID_STYLE_ID}`, + 'prefix/../../../escape', + '..%2F..%2Ftokens', + '%2E%2E%2F%2E%2E', + 'valid\x00inject' + ]; + + const TILESET_TRAVERSAL_PAYLOADS = [ + '../../tokens/v2/testuser', + '../mapbox/tileset', + 'mapbox/../attacker', + '/absolute/path', + 'nodothere' + ]; + + describe('styleId schema validation rejects traversal payloads', () => { + it.each(STYLE_TRAVERSAL_PAYLOADS)( + 'RetrieveStyleSchema rejects "%s"', + (styleId) => { + expect(RetrieveStyleSchema.safeParse({ styleId }).success).toBe(false); + } + ); + + it.each(STYLE_TRAVERSAL_PAYLOADS)( + 'DeleteStyleSchema rejects "%s"', + (styleId) => { + expect(DeleteStyleSchema.safeParse({ styleId }).success).toBe(false); + } + ); + + it.each(STYLE_TRAVERSAL_PAYLOADS)( + 'UpdateStyleInputSchema rejects "%s"', + (styleId) => { + expect(UpdateStyleInputSchema.safeParse({ styleId }).success).toBe( + false + ); + } + ); + + it.each(STYLE_TRAVERSAL_PAYLOADS)( + 'PreviewStyleSchema rejects "%s"', + (styleId) => { + expect( + PreviewStyleSchema.safeParse({ + styleId, + accessToken: PUBLIC_MOCK_TOKEN + }).success + ).toBe(false); + } + ); + }); + + describe('tilesetId schema validation rejects traversal payloads', () => { + it.each(TILESET_TRAVERSAL_PAYLOADS)( + 'TilequerySchema rejects "%s"', + (tilesetId) => { + expect( + TilequerySchema.safeParse({ tilesetId, longitude: 0, latitude: 0 }) + .success + ).toBe(false); + } + ); + }); + + describe('schema validation accepts valid IDs', () => { + it('RetrieveStyleSchema accepts valid alphanumeric styleId', () => { + expect( + RetrieveStyleSchema.safeParse({ styleId: VALID_STYLE_ID }).success + ).toBe(true); + }); + + it('DeleteStyleSchema accepts valid alphanumeric styleId', () => { + expect( + DeleteStyleSchema.safeParse({ styleId: VALID_STYLE_ID }).success + ).toBe(true); + }); + + it('UpdateStyleInputSchema accepts valid alphanumeric styleId', () => { + expect( + UpdateStyleInputSchema.safeParse({ styleId: VALID_STYLE_ID }).success + ).toBe(true); + }); + + it('PreviewStyleSchema accepts valid alphanumeric styleId', () => { + expect( + PreviewStyleSchema.safeParse({ + styleId: VALID_STYLE_ID, + accessToken: PUBLIC_MOCK_TOKEN + }).success + ).toBe(true); + }); + + it.each([ + 'standard', + 'standard-satellite', + 'streets-v12', + 'navigation-day-v1', + 'dark-v11' + ])('RetrieveStyleSchema accepts built-in style name "%s"', (styleId) => { + expect(RetrieveStyleSchema.safeParse({ styleId }).success).toBe(true); + }); + + it('TilequerySchema accepts valid owner.tileset-name format', () => { + expect( + TilequerySchema.safeParse({ + tilesetId: VALID_TILESET_ID, + longitude: 0, + latitude: 0 + }).success + ).toBe(true); + }); + + it('TilequerySchema uses safe default when tilesetId is omitted', () => { + const result = TilequerySchema.safeParse({ longitude: 0, latitude: 0 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tilesetId).toBe('mapbox.mapbox-streets-v8'); + } + }); + }); + + describe('URL path segments are encoded (defense-in-depth for username)', () => { + it('DeleteStyleTool encodes username containing "/" from JWT payload', async () => { + const maliciousToken = makeToken('user/attacker'); + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + status: 204 + }); + + await new DeleteStyleTool({ httpRequest }).run( + { styleId: VALID_STYLE_ID }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: maliciousToken } } as any + ); + + const calledUrl: string = mockHttpRequest.mock.calls[0][0]; + expect(calledUrl).toContain('user%2Fattacker'); + expect(calledUrl).not.toMatch(/\/styles\/v1\/user\/attacker\//); + }); + + it('PreviewStyleTool encodes username containing "/" in preview URL and resource URI', async () => { + const maliciousPublicToken = makePublicToken('user/attacker'); + + const result = await new PreviewStyleTool().run({ + styleId: VALID_STYLE_ID, + accessToken: maliciousPublicToken + }); + + const url = result.content[0].text as string; + expect(url).toContain('user%2Fattacker'); + expect(url).not.toMatch(/\/styles\/v1\/user\/attacker\//); + }); + + it('RetrieveStyleTool encodes username containing "/" from JWT payload', async () => { + const maliciousToken = makeToken('user/attacker'); + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + json: async () => MOCK_STYLE_RESPONSE + }); + + await new RetrieveStyleTool({ httpRequest }).run( + { styleId: VALID_STYLE_ID }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: maliciousToken } } as any + ); + + const calledUrl: string = mockHttpRequest.mock.calls[0][0]; + expect(calledUrl).toContain('user%2Fattacker'); + expect(calledUrl).not.toMatch(/\/styles\/v1\/user\/attacker\//); + }); + + it('UpdateStyleTool encodes username containing "/" from JWT payload', async () => { + const maliciousToken = makeToken('user/attacker'); + const { httpRequest, mockHttpRequest } = setupHttpRequest({ + ok: true, + json: async () => MOCK_STYLE_RESPONSE + }); + + await new UpdateStyleTool({ httpRequest }).run( + { styleId: VALID_STYLE_ID }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { authInfo: { token: maliciousToken } } as any + ); + + const calledUrl: string = mockHttpRequest.mock.calls[0][0]; + expect(calledUrl).toContain('user%2Fattacker'); + expect(calledUrl).not.toMatch(/\/styles\/v1\/user\/attacker\//); + }); + }); + + describe('response schema mismatch returns error instead of silently passing through', () => { + it('RetrieveStyleTool returns isError:true when API returns style array (traversal hit list endpoint)', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => [MOCK_STYLE_RESPONSE, MOCK_STYLE_RESPONSE] + }); + + const result = await new RetrieveStyleTool({ httpRequest }).run({ + styleId: VALID_STYLE_ID + }); + + expect(result.isError).toBe(true); + }); + + it('RetrieveStyleTool returns isError:true when API returns token list (traversal hit tokens endpoint)', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => ({ tokens: ['tk.abc123', 'tk.def456'], count: 2 }) + }); + + const result = await new RetrieveStyleTool({ httpRequest }).run({ + styleId: VALID_STYLE_ID + }); + + expect(result.isError).toBe(true); + }); + + it('UpdateStyleTool returns isError:true when API returns wrong format', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => ({ tokens: ['tk.abc123'], count: 1 }) + }); + + const result = await new UpdateStyleTool({ httpRequest }).run({ + styleId: VALID_STYLE_ID + }); + + expect(result.isError).toBe(true); + }); + + it('TilequeryTool returns isError:true when API returns non-FeatureCollection response', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => ({ type: 'TokenList', tokens: ['tk.abc'] }) + }); + + const result = await new TilequeryTool({ httpRequest }).run({ + tilesetId: VALID_TILESET_ID, + longitude: 0, + latitude: 0 + }); + + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 5526967..9825183 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -44,8 +44,9 @@ describe('CreateTokenTool', () => { }); it('should have correct input schema', async () => { - const { CreateTokenSchema } = - await import('../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js'); + const { CreateTokenSchema } = await import( + '../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js' + ); expect(CreateTokenSchema).toBeDefined(); }); }); @@ -377,29 +378,15 @@ describe('CreateTokenTool', () => { } as Response); const tool = createTokenTool(httpRequest); - const logSpy = vi.spyOn(tool as any, 'log'); const result = await tool.run({ note: 'Test token', scopes: ['styles:read'] }); - // Should not error - graceful fallback to raw data - expect(result.isError).toBe(false); + // Schema validation failure now returns an error response + expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); - - // Should log a warning about validation failure - expect(logSpy).toHaveBeenCalledWith( - 'warning', - expect.stringContaining( - 'CreateTokenTool: Output schema validation failed' - ) - ); - - // Should return the raw data despite validation failure - const responseData = JSON.parse((result.content[0] as TextContent).text); - expect(responseData).toEqual(invalidMockResponse); - expect(responseData).toHaveProperty('unexpectedField'); }); }); }); diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index 7c576a5..b25476f 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -29,8 +29,9 @@ describe('DeleteStyleTool', () => { }); it('should have correct input schema', async () => { - const { DeleteStyleSchema } = - await import('../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js'); + const { DeleteStyleSchema } = await import( + '../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js' + ); expect(DeleteStyleSchema).toBeDefined(); }); }); @@ -42,7 +43,7 @@ describe('DeleteStyleTool', () => { }); const result = await new DeleteStyleTool({ httpRequest }).run({ - styleId: 'style-123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); expect(result.content[0]).toEqual({ @@ -60,7 +61,7 @@ describe('DeleteStyleTool', () => { }); const result = await new DeleteStyleTool({ httpRequest }).run({ - styleId: 'style-123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); expect(result.isError).toBe(true); expect(result.content[0]).toMatchObject({ diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index de9e37c..dc56a41 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -30,8 +30,9 @@ describe('ListStylesTool', () => { }); it('should have correct input schema', async () => { - const { ListStylesSchema } = - await import('../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js'); + const { ListStylesSchema } = await import( + '../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js' + ); expect(ListStylesSchema).toBeDefined(); }); }); @@ -284,30 +285,13 @@ describe('ListStylesTool', () => { }); const tool = new ListStylesTool({ httpRequest }); - const logSpy = vi.spyOn(tool as any, 'log'); const result = await tool.run({}); - // Should not error - graceful fallback to raw data - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(1); + // Schema validation failure now returns an error response + expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); - // Should log a warning about validation failure - expect(logSpy).toHaveBeenCalledWith( - 'warning', - expect.stringContaining('ListStylesTool: Output schema validation failed') - ); - - // Should return the raw data despite validation failure - const content = result.content[0]; - if (content.type === 'text') { - const parsedResponse = JSON.parse(content.text); - expect(parsedResponse).toHaveProperty('styles'); - expect(parsedResponse.styles).toEqual(invalidMockStyles); - expect(parsedResponse.styles[0]).toHaveProperty('customField'); - } - assertHeadersSent(mockHttpRequest); }); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index 380af90..dfcd213 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -43,8 +43,9 @@ describe('ListTokensTool', () => { }); it('should have correct input schema', async () => { - const { ListTokensSchema } = - await import('../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js'); + const { ListTokensSchema } = await import( + '../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js' + ); expect(ListTokensSchema).toBeDefined(); }); }); @@ -583,26 +584,12 @@ describe('ListTokensTool', () => { } as Response); const tool = createListTokensTool(httpRequest); - const logSpy = vi.spyOn(tool as any, 'log'); const result = await tool.run({}); - // Should not error - graceful fallback to raw data - expect(result.isError).toBe(false); + // Schema validation failure now returns an error response + expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); - - // Should log a warning about validation failure - expect(logSpy).toHaveBeenCalledWith( - 'warning', - expect.stringContaining( - 'ListTokensTool: Output schema validation failed' - ) - ); - - // Should return the raw data despite validation failure - const responseData = JSON.parse((result.content[0] as TextContent).text); - expect(responseData.tokens).toEqual(invalidMockTokens); - expect(responseData.tokens[0]).toHaveProperty('unexpectedField'); }); }); }); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index 7a4eecf..c543fe1 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -21,15 +21,16 @@ describe('PreviewStyleTool', () => { }); it('should have correct input schema', async () => { - const { PreviewStyleSchema } = - await import('../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js'); + const { PreviewStyleSchema } = await import( + '../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js' + ); expect(PreviewStyleSchema).toBeDefined(); }); }); it('uses user-provided public token and returns preview URL', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, zoomwheel: false @@ -39,14 +40,14 @@ describe('PreviewStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining( - '/styles/v1/test-user/test-style.html?access_token=pk.' + '/styles/v1/test-user/cmojrmkc9002t01ry96yi6h48.html?access_token=pk.' ) }); }); it('includes styleId in URL', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'my-custom-style', + styleId: 'cmojrmkc9002t01ry96yi6h49', accessToken: TEST_ACCESS_TOKEN, title: false, zoomwheel: false @@ -54,13 +55,15 @@ describe('PreviewStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', - text: expect.stringContaining('/styles/v1/test-user/my-custom-style.html') + text: expect.stringContaining( + '/styles/v1/test-user/cmojrmkc9002t01ry96yi6h49.html' + ) }); }); it('includes title parameter when provided', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: true, zoomwheel: false @@ -74,7 +77,7 @@ describe('PreviewStyleTool', () => { it('includes zoomwheel parameter when provided', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, zoomwheel: false, title: false @@ -88,7 +91,7 @@ describe('PreviewStyleTool', () => { it('includes fresh parameter for secure access', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, zoomwheel: false @@ -102,7 +105,7 @@ describe('PreviewStyleTool', () => { it('rejects secret tokens', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: 'sk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.secret_token', title: false, @@ -120,7 +123,7 @@ describe('PreviewStyleTool', () => { it('rejects temporary tokens', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: 'tk.eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIn0.temp_token', title: false, zoomwheel: false @@ -137,7 +140,7 @@ describe('PreviewStyleTool', () => { it('returns URL and MCP-UI resource on success (default)', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, zoomwheel: false @@ -148,7 +151,7 @@ describe('PreviewStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining( - 'https://api.mapbox.com/styles/v1/test-user/test-style.html?access_token=pk.' + 'https://api.mapbox.com/styles/v1/test-user/cmojrmkc9002t01ry96yi6h48.html?access_token=pk.' ) }); @@ -163,9 +166,9 @@ describe('PreviewStyleTool', () => { type: 'resource', resource: { uri: expect.stringMatching(/^ui:\/\/mapbox\/preview-style\//), - mimeType: 'text/html;profile=mcp-app', + mimeType: 'text/uri-list', text: expect.stringContaining( - 'https://api.mapbox.com/styles/v1/test-user/test-style.html?access_token=pk.' + 'https://api.mapbox.com/styles/v1/test-user/cmojrmkc9002t01ry96yi6h48.html?access_token=pk.' ) } }); @@ -173,7 +176,7 @@ describe('PreviewStyleTool', () => { it('returns URL and MCP-UI resource for backward compatibility', async () => { const result = await new PreviewStyleTool().run({ - styleId: 'test-style', + styleId: 'cmojrmkc9002t01ry96yi6h48', accessToken: TEST_ACCESS_TOKEN, title: false, zoomwheel: false @@ -185,7 +188,7 @@ describe('PreviewStyleTool', () => { expect(result.content[0]).toMatchObject({ type: 'text', text: expect.stringContaining( - 'https://api.mapbox.com/styles/v1/test-user/test-style.html?access_token=pk.' + 'https://api.mapbox.com/styles/v1/test-user/cmojrmkc9002t01ry96yi6h48.html?access_token=pk.' ) }); // Second item is MCP-UI resource diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 540965d..f7a53e7 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -29,14 +29,25 @@ describe('RetrieveStyleTool', () => { }); it('should have correct input schema', async () => { - const { RetrieveStyleSchema } = - await import('../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js'); + const { RetrieveStyleSchema } = await import( + '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js' + ); expect(RetrieveStyleSchema).toBeDefined(); }); }); it('returns style data for successful fetch', async () => { - const styleData = { id: 'style-123', name: 'Test Style' }; + const styleData = { + id: 'cmojrmkc9002t01ry96yi6h48', + name: 'Test Style', + owner: 'test-user', + version: 8, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-01T00:00:00.000Z', + visibility: 'private' as const, + sources: {}, + layers: [] + }; const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, status: 200, @@ -44,13 +55,21 @@ describe('RetrieveStyleTool', () => { }); const result = await new RetrieveStyleTool({ httpRequest }).run({ - styleId: 'style-123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); - expect(result.content[0]).toMatchObject({ - type: 'text', - text: JSON.stringify(styleData, null, 2) - }); + expect(result.isError).toBe(false); + const content = result.content[0]; + expect(content.type).toBe('text'); + if (content.type === 'text') { + const parsed = JSON.parse(content.text); + expect(parsed).toMatchObject({ + id: 'cmojrmkc9002t01ry96yi6h48', + name: 'Test Style', + owner: 'test-user', + version: 8 + }); + } assertHeadersSent(mockHttpRequest); }); @@ -64,7 +83,7 @@ describe('RetrieveStyleTool', () => { let result; try { result = await new RetrieveStyleTool({ httpRequest }).run({ - styleId: 'style-456' + styleId: 'cmojrmkc9002t01ry96yi6h49' }); } catch (e) { if (e instanceof Error) { @@ -86,7 +105,7 @@ describe('RetrieveStyleTool', () => { it('handles styles with null terrain and other nullable fields', async () => { // Real-world API response with null values for optional fields const styleData = { - id: 'cjxyz123', + id: 'cmojrmkc9002t01ry96yi6h48', name: 'Production Style', owner: 'test-user', version: 8, @@ -118,7 +137,7 @@ describe('RetrieveStyleTool', () => { }); const result = await new RetrieveStyleTool({ httpRequest }).run({ - styleId: 'cjxyz123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); expect(result.isError).toBe(false); @@ -130,7 +149,7 @@ describe('RetrieveStyleTool', () => { expect(parsedResponse.terrain).toBeNull(); expect(parsedResponse.fog).toBeNull(); expect(parsedResponse.lights).toBeNull(); - expect(parsedResponse.id).toBe('cjxyz123'); + expect(parsedResponse.id).toBe('cmojrmkc9002t01ry96yi6h48'); } assertHeadersSent(mockHttpRequest); diff --git a/test/tools/update-style-tool/UpdateStyleTool.test.ts b/test/tools/update-style-tool/UpdateStyleTool.test.ts index f196c29..c922fd2 100644 --- a/test/tools/update-style-tool/UpdateStyleTool.test.ts +++ b/test/tools/update-style-tool/UpdateStyleTool.test.ts @@ -29,8 +29,9 @@ describe('UpdateStyleTool', () => { }); it('should have correct input schema', async () => { - const { UpdateStyleInputSchema } = - await import('../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js'); + const { UpdateStyleInputSchema } = await import( + '../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js' + ); expect(UpdateStyleInputSchema).toBeDefined(); }); }); @@ -38,11 +39,21 @@ describe('UpdateStyleTool', () => { it('sends custom header', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true, - json: async () => ({ id: 'updated-style-id', name: 'Updated Style' }) + json: async () => ({ + id: 'cmojrmkc9002t01ry96yi6h48', + name: 'Updated Style', + owner: 'test-user', + version: 8, + created: '2020-01-01T00:00:00.000Z', + modified: '2020-01-01T00:00:00.000Z', + visibility: 'private', + sources: {}, + layers: [] + }) }); await new UpdateStyleTool({ httpRequest }).run({ - styleId: 'style-123', + styleId: 'cmojrmkc9002t01ry96yi6h48', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } }); @@ -59,7 +70,7 @@ describe('UpdateStyleTool', () => { let result; try { result = await new UpdateStyleTool({ httpRequest }).run({ - styleId: 'style-123', + styleId: 'cmojrmkc9002t01ry96yi6h48', name: 'Updated Style', style: { version: 8, sources: {}, layers: [] } }); From 11cdd285cbe1197f813476f8d353b52facc5f354 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 14:52:03 +0300 Subject: [PATCH 2/6] fix: update tests for Prettier, Zod v4, and @mcp-ui/server v6.1.0 upgrades - Reformat dynamic imports to match Prettier 3.8.x style - Update Zod v4 error code assertions (invalid_value, too_big) - Update mimeType assertions for @mcp-ui/server v6.1.0 (text/html;profile=mcp-app) - Fix PreviewStyleTool test mimeType expectation --- test/tools/create-token-tool/CreateTokenTool.test.ts | 5 ++--- test/tools/delete-style-tool/DeleteStyleTool.test.ts | 5 ++--- test/tools/list-styles-tool/ListStylesTool.test.ts | 5 ++--- test/tools/list-tokens-tool/ListTokensTool.test.ts | 7 +++---- test/tools/preview-style-tool/PreviewStyleTool.test.ts | 7 +++---- test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts | 5 ++--- test/tools/update-style-tool/UpdateStyleTool.test.ts | 5 ++--- 7 files changed, 16 insertions(+), 23 deletions(-) diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 9825183..2ee44d7 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -44,9 +44,8 @@ describe('CreateTokenTool', () => { }); it('should have correct input schema', async () => { - const { CreateTokenSchema } = await import( - '../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js' - ); + const { CreateTokenSchema } = + await import('../../../src/tools/create-token-tool/CreateTokenTool.input.schema.js'); expect(CreateTokenSchema).toBeDefined(); }); }); diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index b25476f..3df8667 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -29,9 +29,8 @@ describe('DeleteStyleTool', () => { }); it('should have correct input schema', async () => { - const { DeleteStyleSchema } = await import( - '../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js' - ); + const { DeleteStyleSchema } = + await import('../../../src/tools/delete-style-tool/DeleteStyleTool.input.schema.js'); expect(DeleteStyleSchema).toBeDefined(); }); }); diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index dc56a41..966efa9 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -30,9 +30,8 @@ describe('ListStylesTool', () => { }); it('should have correct input schema', async () => { - const { ListStylesSchema } = await import( - '../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js' - ); + const { ListStylesSchema } = + await import('../../../src/tools/list-styles-tool/ListStylesTool.input.schema.js'); expect(ListStylesSchema).toBeDefined(); }); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index dfcd213..4d1fecf 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -43,9 +43,8 @@ describe('ListTokensTool', () => { }); it('should have correct input schema', async () => { - const { ListTokensSchema } = await import( - '../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js' - ); + const { ListTokensSchema } = + await import('../../../src/tools/list-tokens-tool/ListTokensTool.input.schema.js'); expect(ListTokensSchema).toBeDefined(); }); }); @@ -59,7 +58,7 @@ describe('ListTokensTool', () => { expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); const errorText = (result.content[0] as TextContent).text; - expect(errorText).toContain('<=100'); + expect(errorText).toContain('too_big'); }); it('validates sortby enum values', async () => { diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index c543fe1..93ee55e 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -21,9 +21,8 @@ describe('PreviewStyleTool', () => { }); it('should have correct input schema', async () => { - const { PreviewStyleSchema } = await import( - '../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js' - ); + const { PreviewStyleSchema } = + await import('../../../src/tools/preview-style-tool/PreviewStyleTool.input.schema.js'); expect(PreviewStyleSchema).toBeDefined(); }); }); @@ -166,7 +165,7 @@ describe('PreviewStyleTool', () => { type: 'resource', resource: { uri: expect.stringMatching(/^ui:\/\/mapbox\/preview-style\//), - mimeType: 'text/uri-list', + mimeType: 'text/html;profile=mcp-app', text: expect.stringContaining( 'https://api.mapbox.com/styles/v1/test-user/cmojrmkc9002t01ry96yi6h48.html?access_token=pk.' ) diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index f7a53e7..667a422 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -29,9 +29,8 @@ describe('RetrieveStyleTool', () => { }); it('should have correct input schema', async () => { - const { RetrieveStyleSchema } = await import( - '../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js' - ); + const { RetrieveStyleSchema } = + await import('../../../src/tools/retrieve-style-tool/RetrieveStyleTool.input.schema.js'); expect(RetrieveStyleSchema).toBeDefined(); }); }); diff --git a/test/tools/update-style-tool/UpdateStyleTool.test.ts b/test/tools/update-style-tool/UpdateStyleTool.test.ts index c922fd2..5b609bf 100644 --- a/test/tools/update-style-tool/UpdateStyleTool.test.ts +++ b/test/tools/update-style-tool/UpdateStyleTool.test.ts @@ -29,9 +29,8 @@ describe('UpdateStyleTool', () => { }); it('should have correct input schema', async () => { - const { UpdateStyleInputSchema } = await import( - '../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js' - ); + const { UpdateStyleInputSchema } = + await import('../../../src/tools/update-style-tool/UpdateStyleTool.input.schema.js'); expect(UpdateStyleInputSchema).toBeDefined(); }); }); From 064a3a1289350e40cf1682bd0004fc4843b7e5dd Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 15:14:36 +0300 Subject: [PATCH 3/6] fix: reject cross-origin Link headers and redact tokens from logs - Validate that pagination next-page URLs from Link response headers share the same origin as the configured API endpoint; cross-origin URLs are rejected to prevent access token exfiltration - Add redactToken() utility that strips access_token query parameter values from strings before they reach log output or MCP client error responses (network errors include the full request URL in their message which would otherwise expose the token) - Remove full URL from info/debug level pagination log messages --- src/tools/MapboxApiBasedTool.ts | 9 +++- src/tools/list-tokens-tool/ListTokensTool.ts | 48 +++++++++++-------- .../list-tokens-tool/ListTokensTool.test.ts | 35 ++++++++++++++ 3 files changed, 70 insertions(+), 22 deletions(-) diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 3bb897a..17daa90 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -13,6 +13,11 @@ import { context, trace, SpanStatusCode } from '@opentelemetry/api'; import type { ToolExecutionContext } from '../utils/tracing.js'; import { createToolExecutionContext } from '../utils/tracing.js'; +/** Remove access_token query parameter values from strings before logging or returning to callers. */ +export function redactToken(s: string): string { + return s.replace(/access_token=[^&\s#"']+/g, 'access_token=***'); +} + /** * Standard error response format from Mapbox API */ @@ -126,8 +131,8 @@ export abstract class MapboxApiBasedTool< toolContext.span.end(); return result; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const errorMessage = redactToken(rawMessage); this.log( 'error', `${this.name}: Error during execution: ${errorMessage}` diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index e2eb0b6..59ca5e8 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -4,7 +4,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; import type { ToolExecutionContext } from '../../utils/tracing.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import { MapboxApiBasedTool, redactToken } from '../MapboxApiBasedTool.js'; import { ListTokensSchema, ListTokensInput @@ -110,7 +110,7 @@ export class ListTokensTool extends MapboxApiBasedTool< while (url) { pageCount++; this.log('info', `ListTokensTool: Fetching page ${pageCount}`); - this.log('debug', `ListTokensTool: Fetching URL: ${url}`); + this.log('debug', `ListTokensTool: Fetching URL: ${redactToken(url)}`); const response = await this.httpRequest(url, { method: 'GET', @@ -157,26 +157,34 @@ export class ListTokensTool extends MapboxApiBasedTool< if (linkHeader) { const links = this.parseLinkHeader(linkHeader); if (links.next) { - // Ensure the next URL includes the access token const nextUrl = new URL(links.next); - if (!nextUrl.searchParams.has('access_token')) { - nextUrl.searchParams.append('access_token', accessToken); - } - const nextUrlString = nextUrl.toString(); - - if (shouldAutoPaginate) { - url = nextUrlString; - this.log('info', `ListTokensTool: Next page available: ${url}`); + // Reject cross-origin Link headers to prevent token exfiltration + const apiOrigin = new URL(ListTokensTool.mapboxApiEndpoint).origin; + if (nextUrl.origin !== apiOrigin) { + this.log( + 'warning', + `ListTokensTool: Refusing cross-origin Link header: ${nextUrl.origin}` + ); } else { - // For manual pagination, extract the start parameter from the next URL - const nextUrl = new URL(links.next); - const startParam = nextUrl.searchParams.get('start'); - if (startParam) { - nextPageUrl = startParam; - this.log( - 'info', - `ListTokensTool: Next page start token saved for manual pagination: ${nextPageUrl}` - ); + // Ensure the next URL includes the access token + if (!nextUrl.searchParams.has('access_token')) { + nextUrl.searchParams.append('access_token', accessToken); + } + const nextUrlString = nextUrl.toString(); + + if (shouldAutoPaginate) { + url = nextUrlString; + this.log('info', 'ListTokensTool: Next page available'); + } else { + // For manual pagination, extract the start parameter from the next URL + const startParam = nextUrl.searchParams.get('start'); + if (startParam) { + nextPageUrl = startParam; + this.log( + 'info', + 'ListTokensTool: Next page start token saved for manual pagination' + ); + } } } } diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index 4d1fecf..f37c62e 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -427,6 +427,41 @@ describe('ListTokensTool', () => { expect(responseData.next_start).toBeUndefined(); }); + it('refuses cross-origin Link header to prevent token exfiltration', async () => { + const mockTokens = [ + { + id: 'cktest123', + note: 'Token', + usage: 'pk', + client: 'api', + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test123', + scopes: ['styles:read'], + created: '2023-01-01T00:00:00.000Z', + modified: '2023-01-01T00:00:00.000Z', + default: false + } + ]; + + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const headers = new Headers(); + headers.set('Link', '; rel="next"'); + + mockHttpRequest.mockResolvedValueOnce({ + ok: true, + headers, + json: async () => mockTokens + } as Response); + + const tool = createListTokensTool(httpRequest); + const result = await tool.run({}); + + expect(result.isError).toBe(false); + const responseData = JSON.parse((result.content[0] as TextContent).text); + expect(responseData.tokens).toHaveLength(1); + // Pagination should stop — no second request made to the attacker URL + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + }); + it('filters by token usage type', async () => { const mockTokens = [ { From 22ad9d791fed19c1595edd5625045b797ea3c7d2 Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 18:37:49 +0300 Subject: [PATCH 4/6] refactor: extract handleValidationError helper and include error details in validation failures --- src/tools/MapboxApiBasedTool.ts | 12 ++++++++++++ src/tools/create-token-tool/CreateTokenTool.ts | 12 ++---------- src/tools/list-styles-tool/ListStylesTool.ts | 12 ++---------- src/tools/list-tokens-tool/ListTokensTool.ts | 12 ++---------- src/tools/retrieve-style-tool/RetrieveStyleTool.ts | 12 ++---------- src/tools/tilequery-tool/TilequeryTool.ts | 12 ++---------- src/tools/update-style-tool/UpdateStyleTool.ts | 12 ++---------- 7 files changed, 24 insertions(+), 60 deletions(-) diff --git a/src/tools/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 17daa90..eb52d3e 100644 --- a/src/tools/MapboxApiBasedTool.ts +++ b/src/tools/MapboxApiBasedTool.ts @@ -217,6 +217,18 @@ export abstract class MapboxApiBasedTool< }; } + protected handleValidationError(error: unknown): CallToolResult { + return { + isError: true, + content: [ + { + type: 'text', + text: `Unexpected API response format from Mapbox API: ${error instanceof Error ? error.message : 'Unknown validation error'}` + } + ] + }; + } + /** * Tool logic to be implemented by subclasses. * Must return a complete OutputSchema with content and optional structured content. diff --git a/src/tools/create-token-tool/CreateTokenTool.ts b/src/tools/create-token-tool/CreateTokenTool.ts index 6744221..ed374ad 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -95,16 +95,8 @@ export class CreateTokenTool extends MapboxApiBasedTool< let data; try { data = CreateTokenOutputSchema.parse(rawData); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } this.log('info', `CreateTokenTool: Successfully created token`); diff --git a/src/tools/list-styles-tool/ListStylesTool.ts b/src/tools/list-styles-tool/ListStylesTool.ts index ff91992..fa924f7 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -73,16 +73,8 @@ export class ListStylesTool extends MapboxApiBasedTool< let validatedData; try { validatedData = StylesArraySchema.parse(rawData); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } this.log('info', `ListStylesTool: Successfully listed styles`); diff --git a/src/tools/list-tokens-tool/ListTokensTool.ts b/src/tools/list-tokens-tool/ListTokensTool.ts index 59ca5e8..44345fd 100644 --- a/src/tools/list-tokens-tool/ListTokensTool.ts +++ b/src/tools/list-tokens-tool/ListTokensTool.ts @@ -133,16 +133,8 @@ export class ListTokensTool extends MapboxApiBasedTool< let validatedTokens; try { validatedTokens = TokenObjectSchema.array().parse(tokens); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } allTokens.push(...validatedTokens); diff --git a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts index a89cb30..6e5091f 100644 --- a/src/tools/retrieve-style-tool/RetrieveStyleTool.ts +++ b/src/tools/retrieve-style-tool/RetrieveStyleTool.ts @@ -57,16 +57,8 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< let data: MapboxStyleOutput; try { data = MapboxStyleOutputSchema.parse(rawData); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } this.log( diff --git a/src/tools/tilequery-tool/TilequeryTool.ts b/src/tools/tilequery-tool/TilequeryTool.ts index 4eae273..9800e9c 100644 --- a/src/tools/tilequery-tool/TilequeryTool.ts +++ b/src/tools/tilequery-tool/TilequeryTool.ts @@ -84,16 +84,8 @@ export class TilequeryTool extends MapboxApiBasedTool< let data: TilequeryResponse; try { data = TilequeryResponseSchema.parse(rawData); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } this.log( diff --git a/src/tools/update-style-tool/UpdateStyleTool.ts b/src/tools/update-style-tool/UpdateStyleTool.ts index 848c2e8..0b613fe 100644 --- a/src/tools/update-style-tool/UpdateStyleTool.ts +++ b/src/tools/update-style-tool/UpdateStyleTool.ts @@ -67,16 +67,8 @@ export class UpdateStyleTool extends MapboxApiBasedTool< let data: MapboxStyleOutput; try { data = MapboxStyleOutputSchema.parse(rawData); - } catch { - return { - isError: true, - content: [ - { - type: 'text', - text: 'Unexpected API response format from Mapbox API' - } - ] - }; + } catch (validationError) { + return this.handleValidationError(validationError); } this.log('info', `UpdateStyleTool: Successfully updated style ${data.id}`); From da324a492396c05afff1eb04631058d09fb017ae Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 18:44:55 +0300 Subject: [PATCH 5/6] test: assert validation error details are included in error response --- test/tools/create-token-tool/CreateTokenTool.test.ts | 6 ++++++ test/tools/list-styles-tool/ListStylesTool.test.ts | 6 ++++++ test/tools/list-tokens-tool/ListTokensTool.test.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/test/tools/create-token-tool/CreateTokenTool.test.ts b/test/tools/create-token-tool/CreateTokenTool.test.ts index 2ee44d7..c3b64c8 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -386,6 +386,12 @@ describe('CreateTokenTool', () => { // Schema validation failure now returns an error response expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ + ); + expect(errorText).toContain('"code"'); }); }); }); diff --git a/test/tools/list-styles-tool/ListStylesTool.test.ts b/test/tools/list-styles-tool/ListStylesTool.test.ts index 966efa9..41f3efd 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -290,6 +290,12 @@ describe('ListStylesTool', () => { // Schema validation failure now returns an error response expect(result.isError).toBe(true); expect(result.content[0].type).toBe('text'); + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ + ); + expect(errorText).toContain('"code"'); assertHeadersSent(mockHttpRequest); }); diff --git a/test/tools/list-tokens-tool/ListTokensTool.test.ts b/test/tools/list-tokens-tool/ListTokensTool.test.ts index f37c62e..981016a 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -624,6 +624,12 @@ describe('ListTokensTool', () => { // Schema validation failure now returns an error response expect(result.isError).toBe(true); expect(result.content[0]).toHaveProperty('type', 'text'); + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ + ); + expect(errorText).toContain('"code"'); }); }); }); From 5c16a085331fca7b4cb80836b84292937a28274d Mon Sep 17 00:00:00 2001 From: Mofei Zhu <13761509829@163.com> Date: Mon, 4 May 2026 19:52:02 +0300 Subject: [PATCH 6/6] test: add validation error response tests for TilequeryTool and RetrieveStyleTool --- .../RetrieveStyleTool.test.ts | 16 ++++++++++++ .../tilequery-tool/TilequeryTool.test.ts | 26 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts index 667a422..afa5676 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -101,6 +101,22 @@ describe('RetrieveStyleTool', () => { assertHeadersSent(mockHttpRequest); }); + it('returns error with validation details when API response does not match schema', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => ({ unexpected: 'format' }) + }); + + const result = await new RetrieveStyleTool({ httpRequest }).run({ + styleId: 'cmojrmkc9002t01ry96yi6h48' + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: string; text: string }).text; + expect(text).toMatch(/Unexpected API response format from Mapbox API:/); + expect(text).toContain('"code"'); + }); + it('handles styles with null terrain and other nullable fields', async () => { // Real-world API response with null values for optional fields const styleData = { diff --git a/test/tools/tilequery-tool/TilequeryTool.test.ts b/test/tools/tilequery-tool/TilequeryTool.test.ts index 8acd906..7bc8443 100644 --- a/test/tools/tilequery-tool/TilequeryTool.test.ts +++ b/test/tools/tilequery-tool/TilequeryTool.test.ts @@ -1,11 +1,16 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { TilequeryTool } from '../../../src/tools/tilequery-tool/TilequeryTool.js'; import { TilequeryInput } from '../../../src/tools/tilequery-tool/TilequeryTool.input.schema.js'; import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +beforeAll(() => { + process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJ1IjoidGVzdC11c2VyIiwiYSI6InRlc3QtYXBpIn0.signature'; +}); + describe('TilequeryTool', () => { let tool: TilequeryTool; @@ -99,4 +104,23 @@ describe('TilequeryTool', () => { expect(result.success).toBe(false); }); }); + + describe('execute', () => { + it('returns error with validation details when API response does not match schema', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => ({ unexpected: 'format' }) + }); + + const result = await new TilequeryTool({ httpRequest }).run({ + longitude: -122.4194, + latitude: 37.7749 + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: string; text: string }).text; + expect(text).toMatch(/Unexpected API response format from Mapbox API:/); + expect(text).toContain('"code"'); + }); + }); });