Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
),
Expand Down
54 changes: 35 additions & 19 deletions src/tools/style-comparison-tool/StyleComparisonTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions test/tools/style-comparison-tool/StyleComparisonTool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</code><img onerror=alert(1)>',
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<user>/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(() => {
Expand Down
Loading