Skip to content

Commit 9932e54

Browse files
authored
Mint GeoJSON preview token per request instead of caching (#118)
* fix: mint GeoJSON preview token per request instead of caching it The GeoJSON Preview UI resource reused a process-wide cached Mapbox GL token across requests. Mint a short-lived token per request and verify it belongs to the requesting account before embedding it. Adds tests for the resource. * docs: update release workflow name and pre-release suffix guidance * fix: geojson_preview_tool uses geojson.io root URL instead of dead /next/ path
1 parent 0151053 commit 9932e54

7 files changed

Lines changed: 189 additions & 38 deletions

File tree

CHANGELOG.md

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

3+
### Changed
4+
5+
- **GeoJSON Preview UI resource** now mints its short-lived Mapbox GL token per request instead of reusing a process-wide cached one, and verifies the minted token belongs to the requesting account before embedding it.
6+
7+
### Fixed
8+
9+
- **geojson_preview_tool**: Generate `https://geojson.io/?data=...` instead of `https://geojson.io/next/?data=...`. The `/next/` path now returns 404, so the previewed link no longer opened a broken page. The `?data=` query-param format is unchanged.
10+
311
## 0.8.0 - 2026-05-05
412

513
## 0.7.5 - 2026-05-05

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,7 +1330,7 @@ Follow these steps to publish a new release:
13301330

13311331
5. **Publish via the [mcp-server-publisher](https://github.com/mapbox/mcp-server-publisher) workflow:**
13321332
- Go to the Actions tab in the `mcp-server-publisher` repo
1333-
- Select "Manual Release MCP Server to NPM and MCP Registry"
1333+
- Select "Release MCP Server"
13341334
- Choose `mcp-devkit-server` from the repository dropdown
13351335
- Enter the version — it **must exactly match** the `package.json` version
13361336
- Leave the branch field empty for stable releases (or specify a branch for dev releases)
@@ -1352,7 +1352,7 @@ The `sync-manifest-version.cjs` script handles syncing these automatically from
13521352

13531353
To publish a pre-release from a feature branch:
13541354

1355-
1. Set the version in `package.json` with a pre-release suffix (e.g., `1.0.0-dev` or `1.0.0-beta`)
1355+
1. Set the version in `package.json` with a pre-release suffix `-dev` (e.g., `1.0.0-dev`)
13561356
2. Run `node scripts/sync-manifest-version.cjs`
13571357
3. In the publisher workflow, enter the version and specify the branch name
13581358
4. The package will be published to NPM under the `dev` tag (won't affect `latest`)

src/resources/ui-apps/GeojsonPreviewUIResource.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,12 @@ import {
1616

1717
const MAPBOX_GL_VERSION = '3.12.0';
1818

19-
// GL JS requires a public (pk.*) token. We create a short-lived one from the
20-
// sk.* server token and cache it until it's close to expiry.
21-
interface CachedToken {
22-
token: string;
23-
expiresAt: number; // ms since epoch
24-
}
25-
let cachedPublicToken: CachedToken | null = null;
26-
27-
async function getPublicToken(skToken: string): Promise<string> {
28-
const now = Date.now();
29-
// Re-use cached token if it has more than 5 minutes left
30-
if (cachedPublicToken && cachedPublicToken.expiresAt - now > 5 * 60 * 1000) {
31-
return cachedPublicToken.token;
32-
}
33-
19+
// GL JS needs a public token; mint a short-lived one per request from the
20+
// caller's sk.*. Do NOT cache it in module scope — on a multi-tenant server a
21+
// process-global cache can return one caller's token to a different caller.
22+
async function createPreviewToken(skToken: string): Promise<string> {
3423
const username = getUserNameFromToken(skToken);
35-
const expires = new Date(now + 60 * 60 * 1000).toISOString(); // 1 hour
24+
const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
3625
const url = `${mapboxApiEndpoint()}tokens/v2/${username}?access_token=${skToken}`;
3726

3827
const response = await fetch(url, {
@@ -46,11 +35,11 @@ async function getPublicToken(skToken: string): Promise<string> {
4635
});
4736

4837
if (!response.ok) {
49-
throw new Error(`Token API ${response.status}: ${await response.text()}`);
38+
// Do not include the response body — it may echo the token back.
39+
throw new Error(`Token API ${response.status}`);
5040
}
5141

5242
const data = (await response.json()) as { token: string };
53-
cachedPublicToken = { token: data.token, expiresAt: now + 60 * 60 * 1000 };
5443
return data.token;
5544
}
5645

@@ -80,7 +69,12 @@ export class GeojsonPreviewUIResource extends BaseResource {
8069
let accessToken = '';
8170
if (skToken.startsWith('sk.')) {
8271
try {
83-
accessToken = await getPublicToken(skToken);
72+
const minted = await createPreviewToken(skToken);
73+
// Defense in depth: only embed a token minted for the caller's own
74+
// account, so a token can never be served to a different caller.
75+
if (getUserNameFromToken(minted) === getUserNameFromToken(skToken)) {
76+
accessToken = minted;
77+
}
8478
} catch {
8579
// Non-fatal — map won't render but the link button still works
8680
}

src/tools/geojson-preview-tool/GeojsonPreviewTool.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
export class GeojsonPreviewTool extends BaseTool<typeof GeojsonPreviewSchema> {
1515
name = 'geojson_preview_tool';
1616
description =
17-
'Generate a geojson.io/next URL to visualize GeoJSON data. Returns only the URL link.';
17+
'Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.';
1818
readonly annotations = {
1919
readOnlyHint: true,
2020
destructiveHint: false,
@@ -78,13 +78,13 @@ export class GeojsonPreviewTool extends BaseTool<typeof GeojsonPreviewSchema> {
7878
};
7979
}
8080

81-
// Generate geojson.io/next URL
82-
// Note: geojson.io/next uses query params (?data=) not hash params (#data=)
81+
// Generate geojson.io URL
82+
// Note: geojson.io uses query params (?data=) not hash params (#data=)
8383
const geojsonString = JSON.stringify(geojsonData);
8484
const encodedGeoJSON = encodeURIComponent(geojsonString);
85-
const geojsonIOUrl = `https://geojson.io/next/?data=data:application/json,${encodedGeoJSON}`;
85+
const geojsonIOUrl = `https://geojson.io/?data=data:application/json,${encodedGeoJSON}`;
8686

87-
// Use geojson.io/next as the display URL
87+
// Use geojson.io as the display URL
8888
const displayUrl = geojsonIOUrl;
8989

9090
// Build content array with URL
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright (c) Mapbox, Inc.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it, expect, vi, afterEach } from 'vitest';
5+
import { GeojsonPreviewUIResource } from '../../../src/resources/ui-apps/GeojsonPreviewUIResource.js';
6+
7+
const uri = new URL('ui://mapbox/geojson-preview/index.html');
8+
9+
// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`).
10+
function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string {
11+
const payload = Buffer.from(JSON.stringify({ u: username })).toString(
12+
'base64'
13+
);
14+
return `${prefix}.${payload}.sig`;
15+
}
16+
17+
function embeddedToken(html: string): string | null {
18+
const m = html.match(/var TOKEN = '([^']*)'/);
19+
return m ? m[1] : null;
20+
}
21+
22+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
23+
function extra(token?: string): any {
24+
return token ? { authInfo: { token } } : {};
25+
}
26+
27+
async function readHtml(
28+
resource: GeojsonPreviewUIResource,
29+
token?: string
30+
): Promise<string> {
31+
const result = await resource['readCallback'](uri, extra(token));
32+
return result.contents[0].text as string;
33+
}
34+
35+
// Stub global fetch to mirror real Mapbox behaviour: POST tokens/v2/{username}
36+
// mints a `tk` token for THAT account. Returns the mock for call assertions.
37+
function stubMintingFetch() {
38+
const fn = vi.fn(async (input: string | URL | Request) => {
39+
const url = String(input);
40+
const username = decodeURIComponent(
41+
url.match(/tokens\/v2\/([^?]+)/)?.[1] ?? ''
42+
);
43+
return new Response(JSON.stringify({ token: makeToken('tk', username) }), {
44+
status: 200
45+
});
46+
});
47+
vi.stubGlobal('fetch', fn);
48+
return fn;
49+
}
50+
51+
describe('GeojsonPreviewUIResource — AGI-905 cross-account token leak', () => {
52+
afterEach(() => {
53+
vi.unstubAllGlobals();
54+
});
55+
56+
it('embeds only the caller’s own minted token, never another account’s (regression)', async () => {
57+
const fetchMock = stubMintingFetch();
58+
const resource = new GeojsonPreviewUIResource();
59+
60+
const htmlA = await readHtml(resource, makeToken('sk', 'accountA'));
61+
const htmlB = await readHtml(resource, makeToken('sk', 'accountB'));
62+
63+
const tokA = embeddedToken(htmlA);
64+
const tokB = embeddedToken(htmlB);
65+
66+
// Each caller receives a token minted for their own account.
67+
expect(tokA).toBe(makeToken('tk', 'accountA'));
68+
expect(tokB).toBe(makeToken('tk', 'accountB'));
69+
70+
// B must never receive A's token.
71+
expect(tokB).not.toBe(tokA);
72+
expect(htmlB).not.toContain(tokA as string);
73+
74+
// No process-global cache: each read mints fresh.
75+
expect(fetchMock).toHaveBeenCalledTimes(2);
76+
});
77+
78+
it('mints a fresh token on every read (no shared cache, even for the same caller)', async () => {
79+
const fetchMock = stubMintingFetch();
80+
const resource = new GeojsonPreviewUIResource();
81+
const sk = makeToken('sk', 'acct');
82+
83+
await readHtml(resource, sk);
84+
await readHtml(resource, sk);
85+
86+
expect(fetchMock).toHaveBeenCalledTimes(2);
87+
});
88+
89+
it('does not embed a token minted for a different account (identity assertion)', async () => {
90+
// Simulate a (hypothetical) backend returning a token for someone else.
91+
vi.stubGlobal(
92+
'fetch',
93+
vi.fn(
94+
async () =>
95+
new Response(JSON.stringify({ token: makeToken('tk', 'attacker') }), {
96+
status: 200
97+
})
98+
)
99+
);
100+
const resource = new GeojsonPreviewUIResource();
101+
102+
const html = await readHtml(resource, makeToken('sk', 'victim'));
103+
104+
expect(embeddedToken(html)).toBe('');
105+
});
106+
107+
it('renders without a token when minting fails (graceful degradation)', async () => {
108+
vi.stubGlobal(
109+
'fetch',
110+
vi.fn(async () => new Response('forbidden', { status: 403 }))
111+
);
112+
const resource = new GeojsonPreviewUIResource();
113+
114+
const result = await resource['readCallback'](
115+
uri,
116+
extra(makeToken('sk', 'acct'))
117+
);
118+
119+
expect(result.contents).toHaveLength(1);
120+
expect(embeddedToken(result.contents[0].text as string)).toBe('');
121+
});
122+
123+
it('passes a pk token through unchanged without minting', async () => {
124+
const fetchMock = stubMintingFetch();
125+
const resource = new GeojsonPreviewUIResource();
126+
const pk = makeToken('pk', 'acct');
127+
128+
const html = await readHtml(resource, pk);
129+
130+
expect(embeddedToken(html)).toBe(pk);
131+
expect(fetchMock).not.toHaveBeenCalled();
132+
});
133+
134+
it('renders without a token when no token is provided', async () => {
135+
const saved = process.env.MAPBOX_ACCESS_TOKEN;
136+
delete process.env.MAPBOX_ACCESS_TOKEN;
137+
try {
138+
const fetchMock = stubMintingFetch();
139+
const resource = new GeojsonPreviewUIResource();
140+
141+
const html = await readHtml(resource);
142+
143+
expect(embeddedToken(html)).toBe('');
144+
expect(fetchMock).not.toHaveBeenCalled();
145+
} finally {
146+
if (saved !== undefined) process.env.MAPBOX_ACCESS_TOKEN = saved;
147+
}
148+
});
149+
});

test/tools/__snapshots__/tool-naming-convention.test.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ exports[`Tool Naming Convention > should maintain consistent tool list (snapshot
4444
},
4545
{
4646
"className": "GeojsonPreviewTool",
47-
"description": "Generate a geojson.io/next URL to visualize GeoJSON data. Returns only the URL link.",
47+
"description": "Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.",
4848
"toolName": "geojson_preview_tool",
4949
},
5050
{

test/tools/geojson-preview-tool/GeojsonPreviewTool.test.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('GeojsonPreviewTool', () => {
1414
const tool = new GeojsonPreviewTool();
1515
expect(tool.name).toBe('geojson_preview_tool');
1616
expect(tool.description).toBe(
17-
'Generate a geojson.io/next URL to visualize GeoJSON data. Returns only the URL link.'
17+
'Generate a geojson.io URL to visualize GeoJSON data. Returns only the URL link.'
1818
);
1919
});
2020

@@ -39,8 +39,8 @@ describe('GeojsonPreviewTool', () => {
3939
expect(result.content[0].type).toBe('text');
4040
const content = result.content[0];
4141
if (content.type === 'text') {
42-
// Should return geojson.io/next URL with query param format
43-
expect(content.text).toMatch(/^https:\/\/geojson\.io\/next\/\?data=/);
42+
// Should return geojson.io URL with query param format
43+
expect(content.text).toMatch(/^https:\/\/geojson\.io\/\?data=/);
4444
}
4545

4646
// Verify MCP-UI resource is included by default
@@ -53,9 +53,9 @@ describe('GeojsonPreviewTool', () => {
5353
}
5454
});
5555

56-
// Verify the iframe URL is geojson.io/next with query param
56+
// Verify the iframe URL is geojson.io with query param
5757
const iframeUrl = (result.content[1] as any).resource.text;
58-
expect(iframeUrl).toMatch(/^https:\/\/geojson\.io\/next\/\?data=/);
58+
expect(iframeUrl).toMatch(/^https:\/\/geojson\.io\/\?data=/);
5959
});
6060

6161
it('returns URL and MCP-UI resource for backward compatibility', async () => {
@@ -94,8 +94,8 @@ describe('GeojsonPreviewTool', () => {
9494
expect(result.content).toHaveLength(2);
9595
const content = result.content[0];
9696
if (content.type === 'text') {
97-
// Should return geojson.io/next URL with query param
98-
expect(content.text).toMatch(/^https:\/\/geojson\.io\/next\/\?data=/);
97+
// Should return geojson.io URL with query param
98+
expect(content.text).toMatch(/^https:\/\/geojson\.io\/\?data=/);
9999
}
100100
});
101101

@@ -132,8 +132,8 @@ describe('GeojsonPreviewTool', () => {
132132
expect(result.content).toHaveLength(2);
133133
const content = result.content[0];
134134
if (content.type === 'text') {
135-
// Should return geojson.io/next URL with query param
136-
expect(content.text).toMatch(/^https:\/\/geojson\.io\/next\/\?data=/);
135+
// Should return geojson.io URL with query param
136+
expect(content.text).toMatch(/^https:\/\/geojson\.io\/\?data=/);
137137
}
138138
});
139139

@@ -185,8 +185,8 @@ describe('GeojsonPreviewTool', () => {
185185
expect(result.content).toHaveLength(2);
186186
const content = result.content[0];
187187
if (content.type === 'text') {
188-
// Should return geojson.io/next URL with query param
189-
expect(content.text).toMatch(/^https:\/\/geojson\.io\/next\/\?data=/);
188+
// Should return geojson.io URL with query param
189+
expect(content.text).toMatch(/^https:\/\/geojson\.io\/\?data=/);
190190
}
191191
});
192192
});

0 commit comments

Comments
 (0)