Skip to content

Commit 064a3a1

Browse files
committed
fix: reject cross-origin Link headers and redact tokens from logs
- Validate that pagination next-page URLs from Link response headers share the same origin as the configured API endpoint; cross-origin URLs are rejected to prevent access token exfiltration - Add redactToken() utility that strips access_token query parameter values from strings before they reach log output or MCP client error responses (network errors include the full request URL in their message which would otherwise expose the token) - Remove full URL from info/debug level pagination log messages
1 parent 11cdd28 commit 064a3a1

3 files changed

Lines changed: 70 additions & 22 deletions

File tree

src/tools/MapboxApiBasedTool.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { context, trace, SpanStatusCode } from '@opentelemetry/api';
1313
import type { ToolExecutionContext } from '../utils/tracing.js';
1414
import { createToolExecutionContext } from '../utils/tracing.js';
1515

16+
/** Remove access_token query parameter values from strings before logging or returning to callers. */
17+
export function redactToken(s: string): string {
18+
return s.replace(/access_token=[^&\s#"']+/g, 'access_token=***');
19+
}
20+
1621
/**
1722
* Standard error response format from Mapbox API
1823
*/
@@ -126,8 +131,8 @@ export abstract class MapboxApiBasedTool<
126131
toolContext.span.end();
127132
return result;
128133
} catch (error) {
129-
const errorMessage =
130-
error instanceof Error ? error.message : String(error);
134+
const rawMessage = error instanceof Error ? error.message : String(error);
135+
const errorMessage = redactToken(rawMessage);
131136
this.log(
132137
'error',
133138
`${this.name}: Error during execution: ${errorMessage}`

src/tools/list-tokens-tool/ListTokensTool.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
55
import type { HttpRequest } from '../../utils/types.js';
66
import type { ToolExecutionContext } from '../../utils/tracing.js';
7-
import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js';
7+
import { MapboxApiBasedTool, redactToken } from '../MapboxApiBasedTool.js';
88
import {
99
ListTokensSchema,
1010
ListTokensInput
@@ -110,7 +110,7 @@ export class ListTokensTool extends MapboxApiBasedTool<
110110
while (url) {
111111
pageCount++;
112112
this.log('info', `ListTokensTool: Fetching page ${pageCount}`);
113-
this.log('debug', `ListTokensTool: Fetching URL: ${url}`);
113+
this.log('debug', `ListTokensTool: Fetching URL: ${redactToken(url)}`);
114114

115115
const response = await this.httpRequest(url, {
116116
method: 'GET',
@@ -157,26 +157,34 @@ export class ListTokensTool extends MapboxApiBasedTool<
157157
if (linkHeader) {
158158
const links = this.parseLinkHeader(linkHeader);
159159
if (links.next) {
160-
// Ensure the next URL includes the access token
161160
const nextUrl = new URL(links.next);
162-
if (!nextUrl.searchParams.has('access_token')) {
163-
nextUrl.searchParams.append('access_token', accessToken);
164-
}
165-
const nextUrlString = nextUrl.toString();
166-
167-
if (shouldAutoPaginate) {
168-
url = nextUrlString;
169-
this.log('info', `ListTokensTool: Next page available: ${url}`);
161+
// Reject cross-origin Link headers to prevent token exfiltration
162+
const apiOrigin = new URL(ListTokensTool.mapboxApiEndpoint).origin;
163+
if (nextUrl.origin !== apiOrigin) {
164+
this.log(
165+
'warning',
166+
`ListTokensTool: Refusing cross-origin Link header: ${nextUrl.origin}`
167+
);
170168
} else {
171-
// For manual pagination, extract the start parameter from the next URL
172-
const nextUrl = new URL(links.next);
173-
const startParam = nextUrl.searchParams.get('start');
174-
if (startParam) {
175-
nextPageUrl = startParam;
176-
this.log(
177-
'info',
178-
`ListTokensTool: Next page start token saved for manual pagination: ${nextPageUrl}`
179-
);
169+
// Ensure the next URL includes the access token
170+
if (!nextUrl.searchParams.has('access_token')) {
171+
nextUrl.searchParams.append('access_token', accessToken);
172+
}
173+
const nextUrlString = nextUrl.toString();
174+
175+
if (shouldAutoPaginate) {
176+
url = nextUrlString;
177+
this.log('info', 'ListTokensTool: Next page available');
178+
} else {
179+
// For manual pagination, extract the start parameter from the next URL
180+
const startParam = nextUrl.searchParams.get('start');
181+
if (startParam) {
182+
nextPageUrl = startParam;
183+
this.log(
184+
'info',
185+
'ListTokensTool: Next page start token saved for manual pagination'
186+
);
187+
}
180188
}
181189
}
182190
}

test/tools/list-tokens-tool/ListTokensTool.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,41 @@ describe('ListTokensTool', () => {
427427
expect(responseData.next_start).toBeUndefined();
428428
});
429429

430+
it('refuses cross-origin Link header to prevent token exfiltration', async () => {
431+
const mockTokens = [
432+
{
433+
id: 'cktest123',
434+
note: 'Token',
435+
usage: 'pk',
436+
client: 'api',
437+
token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.test123',
438+
scopes: ['styles:read'],
439+
created: '2023-01-01T00:00:00.000Z',
440+
modified: '2023-01-01T00:00:00.000Z',
441+
default: false
442+
}
443+
];
444+
445+
const { httpRequest, mockHttpRequest } = setupHttpRequest();
446+
const headers = new Headers();
447+
headers.set('Link', '<https://attacker.example.com/collect>; rel="next"');
448+
449+
mockHttpRequest.mockResolvedValueOnce({
450+
ok: true,
451+
headers,
452+
json: async () => mockTokens
453+
} as Response);
454+
455+
const tool = createListTokensTool(httpRequest);
456+
const result = await tool.run({});
457+
458+
expect(result.isError).toBe(false);
459+
const responseData = JSON.parse((result.content[0] as TextContent).text);
460+
expect(responseData.tokens).toHaveLength(1);
461+
// Pagination should stop — no second request made to the attacker URL
462+
expect(mockHttpRequest).toHaveBeenCalledTimes(1);
463+
});
464+
430465
it('filters by token usage type', async () => {
431466
const mockTokens = [
432467
{

0 commit comments

Comments
 (0)