diff --git a/CHANGELOG.md b/CHANGELOG.md index 706edfa..15a100e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### New Features + +- **Style ID validation for `style_comparison_tool`**: `before` and `after` inputs are now validated to contain only alphanumeric characters, hyphens, and underscores (after stripping the optional `mapbox://styles/` prefix). Validation is enforced at both the Zod schema layer and inside `processStyleId()`. Malformed style IDs are rejected with a descriptive error before any URL is constructed. + ## 0.8.1 - 2026-06-11 ### Changed diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts index ec25e25..1265c11 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.schema.ts @@ -6,11 +6,19 @@ import { z } from 'zod'; export const StyleComparisonSchema = z.object({ before: z .string() + .regex( + /^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/, + 'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.' + ) .describe( 'Mapbox style for the "before" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' ), after: z .string() + .regex( + /^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/, + 'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.' + ) .describe( 'Mapbox style for the "after" side. Accepts: full style URL (mapbox://styles/username/styleId), username/styleId format, or just styleId if using your own styles' ), diff --git a/src/tools/style-comparison-tool/StyleComparisonTool.ts b/src/tools/style-comparison-tool/StyleComparisonTool.ts index 82f7638..e916b5a 100644 --- a/src/tools/style-comparison-tool/StyleComparisonTool.ts +++ b/src/tools/style-comparison-tool/StyleComparisonTool.ts @@ -40,33 +40,49 @@ export class StyleComparisonTool extends BaseTool< super({ inputSchema: StyleComparisonSchema }); } + /** + * Validates that a resolved username/styleId contains only safe characters. + * Style IDs must be alphanumeric with hyphens and underscores only. + */ + private validateStyleId(resolved: string): void { + if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/.test(resolved)) { + throw new Error( + `Invalid style ID format: "${resolved}". ` + + `Style IDs must be in username/styleId format using only letters, numbers, hyphens, and underscores.` + ); + } + } + /** * Processes style input to extract username/styleId format */ private processStyleId(style: string, accessToken: string): string { + let resolved: string; + // If it's a full URL, extract the username/styleId part if (style.startsWith('mapbox://styles/')) { - return style.replace('mapbox://styles/', ''); - } - - // If it contains a slash, assume it's already username/styleId format - if (style.includes('/')) { - return style; + resolved = style.replace('mapbox://styles/', ''); + } else if (style.includes('/')) { + // If it contains a slash, assume it's already username/styleId format + resolved = style; + } else { + // If it's just a style ID, try to get username from the token + try { + const username = getUserNameFromToken(accessToken); + resolved = `${username}/${style}`; + } catch (error) { + throw new Error( + `Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` + + `Please provide either:\n` + + `1. Full style URL: mapbox://styles/username/${style}\n` + + `2. Username/styleId format: username/${style}\n` + + `3. Just the style ID with a valid Mapbox token that contains username information` + ); + } } - // If it's just a style ID, try to get username from the token - try { - const username = getUserNameFromToken(accessToken); - return `${username}/${style}`; - } catch (error) { - throw new Error( - `Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` + - `Please provide either:\n` + - `1. Full style URL: mapbox://styles/username/${style}\n` + - `2. Username/styleId format: username/${style}\n` + - `3. Just the style ID with a valid Mapbox token that contains username information` - ); - } + this.validateStyleId(resolved); + return resolved; } protected async execute( diff --git a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts index e715011..5024af7 100644 --- a/test/tools/style-comparison-tool/StyleComparisonTool.test.ts +++ b/test/tools/style-comparison-tool/StyleComparisonTool.test.ts @@ -130,6 +130,36 @@ describe('StyleComparisonTool', () => { ).toContain('Invalid token type'); }); + it('should reject style IDs with invalid characters', async () => { + const input = { + before: 'mapbox/streets-v12', + after: 'bad', + accessToken: 'pk.test.token' + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(true); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('Invalid style format'); + }); + + it('should reject style URLs with invalid characters after stripping scheme', async () => { + const input = { + before: 'mapbox://styles/mapbox/streets-v12', + after: 'mapbox://styles/bad/evil"style', + accessToken: 'pk.test.token' + }; + + const result = await tool.run(input); + + expect(result.isError).toBe(true); + expect( + (result.content[0] as { type: 'text'; text: string }).text + ).toContain('Invalid style format'); + }); + it('should return error for style ID without valid username in token', async () => { // Mock getUserNameFromToken to throw an error vi.spyOn(jwtUtils, 'getUserNameFromToken').mockImplementation(() => {