Skip to content

Commit fa7ddeb

Browse files
feat: validate style IDs in style_comparison_tool (#121)
* chore: add CVE-2026-4926 entry to CHANGELOG Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: validate style IDs in style_comparison_tool Add input validation for the before/after style parameters to ensure they contain only alphanumeric characters, hyphens, and underscores. Validation is enforced at both the Zod schema layer and inside processStyleId() after the mapbox://styles/ prefix is stripped, so malformed style IDs are rejected with a descriptive error before any comparison URL is constructed. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 57dc574 commit fa7ddeb

4 files changed

Lines changed: 77 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
## Unreleased
22

3+
### New Features
4+
5+
- **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.
6+
37
## 0.8.1 - 2026-06-11
48

59
### Changed

src/tools/style-comparison-tool/StyleComparisonTool.schema.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@ import { z } from 'zod';
66
export const StyleComparisonSchema = z.object({
77
before: z
88
.string()
9+
.regex(
10+
/^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/,
11+
'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.'
12+
)
913
.describe(
1014
'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'
1115
),
1216
after: z
1317
.string()
18+
.regex(
19+
/^(?:mapbox:\/\/styles\/)?[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)?$/,
20+
'Invalid style format. Use mapbox://styles/username/styleId, username/styleId, or a styleId containing only letters, numbers, hyphens, and underscores.'
21+
)
1422
.describe(
1523
'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'
1624
),

src/tools/style-comparison-tool/StyleComparisonTool.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,33 +40,49 @@ export class StyleComparisonTool extends BaseTool<
4040
super({ inputSchema: StyleComparisonSchema });
4141
}
4242

43+
/**
44+
* Validates that a resolved username/styleId contains only safe characters.
45+
* Style IDs must be alphanumeric with hyphens and underscores only.
46+
*/
47+
private validateStyleId(resolved: string): void {
48+
if (!/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/.test(resolved)) {
49+
throw new Error(
50+
`Invalid style ID format: "${resolved}". ` +
51+
`Style IDs must be in username/styleId format using only letters, numbers, hyphens, and underscores.`
52+
);
53+
}
54+
}
55+
4356
/**
4457
* Processes style input to extract username/styleId format
4558
*/
4659
private processStyleId(style: string, accessToken: string): string {
60+
let resolved: string;
61+
4762
// If it's a full URL, extract the username/styleId part
4863
if (style.startsWith('mapbox://styles/')) {
49-
return style.replace('mapbox://styles/', '');
50-
}
51-
52-
// If it contains a slash, assume it's already username/styleId format
53-
if (style.includes('/')) {
54-
return style;
64+
resolved = style.replace('mapbox://styles/', '');
65+
} else if (style.includes('/')) {
66+
// If it contains a slash, assume it's already username/styleId format
67+
resolved = style;
68+
} else {
69+
// If it's just a style ID, try to get username from the token
70+
try {
71+
const username = getUserNameFromToken(accessToken);
72+
resolved = `${username}/${style}`;
73+
} catch (error) {
74+
throw new Error(
75+
`Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` +
76+
`Please provide either:\n` +
77+
`1. Full style URL: mapbox://styles/username/${style}\n` +
78+
`2. Username/styleId format: username/${style}\n` +
79+
`3. Just the style ID with a valid Mapbox token that contains username information`
80+
);
81+
}
5582
}
5683

57-
// If it's just a style ID, try to get username from the token
58-
try {
59-
const username = getUserNameFromToken(accessToken);
60-
return `${username}/${style}`;
61-
} catch (error) {
62-
throw new Error(
63-
`Could not determine username for style ID "${style}". ${error instanceof Error ? error.message : ''}\n` +
64-
`Please provide either:\n` +
65-
`1. Full style URL: mapbox://styles/username/${style}\n` +
66-
`2. Username/styleId format: username/${style}\n` +
67-
`3. Just the style ID with a valid Mapbox token that contains username information`
68-
);
69-
}
84+
this.validateStyleId(resolved);
85+
return resolved;
7086
}
7187

7288
protected async execute(

test/tools/style-comparison-tool/StyleComparisonTool.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ describe('StyleComparisonTool', () => {
130130
).toContain('Invalid token type');
131131
});
132132

133+
it('should reject style IDs with invalid characters', async () => {
134+
const input = {
135+
before: 'mapbox/streets-v12',
136+
after: 'bad</code><img onerror=alert(1)>',
137+
accessToken: 'pk.test.token'
138+
};
139+
140+
const result = await tool.run(input);
141+
142+
expect(result.isError).toBe(true);
143+
expect(
144+
(result.content[0] as { type: 'text'; text: string }).text
145+
).toContain('Invalid style format');
146+
});
147+
148+
it('should reject style URLs with invalid characters after stripping scheme', async () => {
149+
const input = {
150+
before: 'mapbox://styles/mapbox/streets-v12',
151+
after: 'mapbox://styles/bad<user>/evil"style',
152+
accessToken: 'pk.test.token'
153+
};
154+
155+
const result = await tool.run(input);
156+
157+
expect(result.isError).toBe(true);
158+
expect(
159+
(result.content[0] as { type: 'text'; text: string }).text
160+
).toContain('Invalid style format');
161+
});
162+
133163
it('should return error for style ID without valid username in token', async () => {
134164
// Mock getUserNameFromToken to throw an error
135165
vi.spyOn(jwtUtils, 'getUserNameFromToken').mockImplementation(() => {

0 commit comments

Comments
 (0)