From b18a4425e2fe1cb602b17c535414f265715a1ecf Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 18 Mar 2026 14:43:58 -0400 Subject: [PATCH 1/2] fix(security): validate style parameter and strip credentials from static map URL - Add regex validation to the style parameter to prevent path traversal attacks; accepts only username/style-id with alphanumeric chars, hyphens, and underscores - Apply encodeURIComponent() to each style segment before URL interpolation - Return public URL (without access_token) in text content to avoid leaking credentials to the model context; token is still used internally for the fetch and the MCP Apps iframe URL Reported via HackerOne (2026-03-18) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 ++++ .../StaticMapImageTool.input.schema.ts | 6 +++- .../StaticMapImageTool.ts | 12 ++++++-- .../StaticMapImageTool.test.ts | 28 +++++++++++++++++-- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4688576..642c9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## Unreleased +### Security + +- **static_map_image_tool**: Validate `style` parameter against `username/style-id` format to prevent path traversal attacks where a crafted style value (e.g., `../../tokens/v2`) could escape the `/styles/v1/` URL path and access arbitrary Mapbox API endpoints using the server operator's token +- **static_map_image_tool**: Remove access token from URL returned in text content — the token is only used internally for the HTTP fetch and the MCP Apps iframe URL, not exposed to the model context + ### Exports - Added `getAllTools` to `@mapbox/mcp-server/tools` subpath export for batch access to all registered tools diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts b/src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts index bddbe41..c296d4a 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.input.schema.ts @@ -339,10 +339,14 @@ export const StaticMapImageInputSchema = z.object({ ), style: z .string() + .regex( + /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/, + 'Style must be in the format username/style-id using only alphanumeric characters, hyphens, and underscores (e.g., mapbox/streets-v12)' + ) .optional() .default('mapbox/streets-v12') .describe( - 'Mapbox style ID (e.g., mapbox/streets-v12, mapbox/satellite-v9, mapbox/dark-v11)' + 'Mapbox style ID in the format username/style-id (e.g., mapbox/streets-v12, mapbox/satellite-v9, mapbox/dark-v11)' ), highDensity: z .boolean() diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 4329ebe..914c130 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -108,7 +108,14 @@ export class StaticMapImageTool extends MapboxApiBasedTool< } const density = input.highDensity ? '@2x' : ''; - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${input.style}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; + const encodedStyle = input.style + .split('/') + .map(encodeURIComponent) + .join('/'); + const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; + + // Public URL without credentials for returning to the model + const publicUrl = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}`; // Fetch and encode image as base64 for clients without MCP Apps support const response = await this.httpRequest(url); @@ -125,10 +132,11 @@ export class StaticMapImageTool extends MapboxApiBasedTool< const mimeType = isRasterStyle ? 'image/jpeg' : 'image/png'; // content[0] MUST be the URL text — MCP Apps UI finds it via content.find(c => c.type === 'text') + // Use public URL (without credentials) to avoid leaking the access token const content: CallToolResult['content'] = [ { type: 'text', - text: url + text: publicUrl }, { type: 'image', diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index 71317fc..331e376 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -53,7 +53,7 @@ describe('StaticMapImageTool', () => { ); expect(textContent.text).toContain('-74.006,40.7128,10'); expect(textContent.text).toContain('800x600'); - expect(textContent.text).toContain('access_token='); + expect(textContent.text).not.toContain('access_token='); } finally { // Restore environment variable if (originalEnv !== undefined) { @@ -152,7 +152,7 @@ describe('StaticMapImageTool', () => { expect(url).toContain('styles/v1/mapbox/dark-v10/static/'); expect(url).toContain('-122.4194,37.7749,15'); expect(url).toContain('1024x768'); - expect(url).toContain('access_token='); + expect(url).not.toContain('access_token='); }); it('uses default style when not specified', async () => { @@ -168,6 +168,30 @@ describe('StaticMapImageTool', () => { expect(url).toContain('styles/v1/mapbox/streets-v12/static/'); }); + it('rejects style values with path traversal patterns', async () => { + const { httpRequest } = setupHttpRequest(); + const tool = new StaticMapImageTool({ httpRequest }); + + const traversalPayloads = [ + '../../tokens/v2', + '../styles', + 'mapbox/../../../tokens', + '/etc/passwd', + 'mapbox/streets-v12/../../tokens' + ]; + + for (const style of traversalPayloads) { + await expect( + tool.run({ + center: { longitude: -74, latitude: 40 }, + zoom: 10, + size: { width: 600, height: 400 }, + style + }) + ).resolves.toMatchObject({ isError: true }); + } + }); + it('validates coordinate constraints', async () => { const { httpRequest } = setupHttpRequest(); const tool = new StaticMapImageTool({ httpRequest }); From 47e80a57bcd30afda0709b2cddba36042685dfa2 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 20 Mar 2026 10:53:45 -0400 Subject: [PATCH 2/2] refactor: derive authenticated URL from public URL Co-Authored-By: Claude Sonnet 4.6 --- src/tools/static-map-image-tool/StaticMapImageTool.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index 914c130..72227c2 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -112,10 +112,8 @@ export class StaticMapImageTool extends MapboxApiBasedTool< .split('/') .map(encodeURIComponent) .join('/'); - const url = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}?access_token=${accessToken}`; - - // Public URL without credentials for returning to the model const publicUrl = `${MapboxApiBasedTool.mapboxApiEndpoint}styles/v1/${encodedStyle}/static/${overlayString}${lng},${lat},${input.zoom}/${width}x${height}${density}`; + const url = `${publicUrl}?access_token=${accessToken}`; // Fetch and encode image as base64 for clients without MCP Apps support const response = await this.httpRequest(url);