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/MapboxApiBasedTool.ts b/src/tools/MapboxApiBasedTool.ts index 3bb897a..eb52d3e 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}` @@ -212,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 cfd07bb..ed374ad 100644 --- a/src/tools/create-token-tool/CreateTokenTool.ts +++ b/src/tools/create-token-tool/CreateTokenTool.ts @@ -92,12 +92,12 @@ 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 (validationError) { + return this.handleValidationError(validationError); + } 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..fa924f7 100644 --- a/src/tools/list-styles-tool/ListStylesTool.ts +++ b/src/tools/list-styles-tool/ListStylesTool.ts @@ -70,12 +70,12 @@ 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 (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 69a3221..44345fd 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', @@ -130,12 +130,12 @@ 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 (validationError) { + return this.handleValidationError(validationError); + } allTokens.push(...validatedTokens); this.log( @@ -149,26 +149,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/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..6e5091f 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); @@ -58,15 +58,13 @@ export class RetrieveStyleTool extends MapboxApiBasedTool< 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; + return this.handleValidationError(validationError); } - 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..9800e9c 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,11 @@ 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; + return this.handleValidationError(validationError); } 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..0b613fe 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; @@ -68,12 +68,7 @@ export class UpdateStyleTool extends MapboxApiBasedTool< 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; + return this.handleValidationError(validationError); } 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..c3b64c8 100644 --- a/test/tools/create-token-tool/CreateTokenTool.test.ts +++ b/test/tools/create-token-tool/CreateTokenTool.test.ts @@ -377,29 +377,21 @@ 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' - ) + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ ); - - // 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'); + expect(errorText).toContain('"code"'); }); }); }); diff --git a/test/tools/delete-style-tool/DeleteStyleTool.test.ts b/test/tools/delete-style-tool/DeleteStyleTool.test.ts index 7c576a5..3df8667 100644 --- a/test/tools/delete-style-tool/DeleteStyleTool.test.ts +++ b/test/tools/delete-style-tool/DeleteStyleTool.test.ts @@ -42,7 +42,7 @@ describe('DeleteStyleTool', () => { }); const result = await new DeleteStyleTool({ httpRequest }).run({ - styleId: 'style-123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); expect(result.content[0]).toEqual({ @@ -60,7 +60,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..41f3efd 100644 --- a/test/tools/list-styles-tool/ListStylesTool.test.ts +++ b/test/tools/list-styles-tool/ListStylesTool.test.ts @@ -284,29 +284,18 @@ 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') + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ ); - - // 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'); - } + 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 380af90..981016a 100644 --- a/test/tools/list-tokens-tool/ListTokensTool.test.ts +++ b/test/tools/list-tokens-tool/ListTokensTool.test.ts @@ -58,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 () => { @@ -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 = [ { @@ -583,26 +618,18 @@ 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' - ) + const errorText = (result.content[0] as { type: string; text: string }) + .text; + expect(errorText).toMatch( + /Unexpected API response format from Mapbox API:/ ); - - // 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'); + expect(errorText).toContain('"code"'); }); }); }); diff --git a/test/tools/preview-style-tool/PreviewStyleTool.test.ts b/test/tools/preview-style-tool/PreviewStyleTool.test.ts index 7a4eecf..93ee55e 100644 --- a/test/tools/preview-style-tool/PreviewStyleTool.test.ts +++ b/test/tools/preview-style-tool/PreviewStyleTool.test.ts @@ -29,7 +29,7 @@ describe('PreviewStyleTool', () => { 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 +39,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 +54,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 +76,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 +90,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 +104,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 +122,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 +139,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 +150,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.' ) }); @@ -165,7 +167,7 @@ describe('PreviewStyleTool', () => { uri: expect.stringMatching(/^ui:\/\/mapbox\/preview-style\//), mimeType: 'text/html;profile=mcp-app', 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 +175,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 +187,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..afa5676 100644 --- a/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts +++ b/test/tools/retrieve-style-tool/RetrieveStyleTool.test.ts @@ -36,7 +36,17 @@ describe('RetrieveStyleTool', () => { }); 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 +54,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 +82,7 @@ describe('RetrieveStyleTool', () => { let result; try { result = await new RetrieveStyleTool({ httpRequest }).run({ - styleId: 'style-456' + styleId: 'cmojrmkc9002t01ry96yi6h49' }); } catch (e) { if (e instanceof Error) { @@ -83,10 +101,26 @@ 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 = { - id: 'cjxyz123', + id: 'cmojrmkc9002t01ry96yi6h48', name: 'Production Style', owner: 'test-user', version: 8, @@ -118,7 +152,7 @@ describe('RetrieveStyleTool', () => { }); const result = await new RetrieveStyleTool({ httpRequest }).run({ - styleId: 'cjxyz123' + styleId: 'cmojrmkc9002t01ry96yi6h48' }); expect(result.isError).toBe(false); @@ -130,7 +164,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/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"'); + }); + }); }); diff --git a/test/tools/update-style-tool/UpdateStyleTool.test.ts b/test/tools/update-style-tool/UpdateStyleTool.test.ts index f196c29..5b609bf 100644 --- a/test/tools/update-style-tool/UpdateStyleTool.test.ts +++ b/test/tools/update-style-tool/UpdateStyleTool.test.ts @@ -38,11 +38,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 +69,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: [] } });