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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unreleased

- security: restrict document tool URL allowlist to documentation hosts and block access_token params (#34)
- chore: upgrade @opentelemetry/\* packages to latest minor versions (#TBD)

## 0.3.0 - 2026-04-15
Expand Down
35 changes: 33 additions & 2 deletions src/tools/batch-get-documents-tool/BatchGetDocumentsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,28 @@ import {
BatchGetDocumentsInput
} from './BatchGetDocumentsTool.input.schema.js';

// Explicit allowlist of hostnames this docs tool is permitted to fetch.
// api.mapbox.com is intentionally excluded — it is a live API that requires
// auth tokens, not a documentation host. Allowing it would let callers poison
// the shared cache with token-authorized private responses under no-token keys.
const ALLOWED_DOC_HOSTNAMES = new Set([
'docs.mapbox.com',
'mapbox.com',
'docs.tilestream.net'
]);

function isMapboxUrl(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'mapbox.com' || hostname.endsWith('.mapbox.com');
return ALLOWED_DOC_HOSTNAMES.has(hostname);
} catch {
return false;
}
}

function hasAccessToken(url: string): boolean {
try {
return new URL(url).searchParams.has('access_token');
} catch {
return false;
}
Expand Down Expand Up @@ -54,7 +72,20 @@ export class BatchGetDocumentsTool extends BaseTool<
content: [
{
type: 'text',
text: `Invalid URLs: only mapbox.com URLs are supported. Invalid: ${invalidUrls.join(', ')}`
text: `Invalid URLs: only mapbox.com documentation URLs are supported. Invalid: ${invalidUrls.join(', ')}`
}
],
isError: true
};
}

const tokenUrls = input.urls.filter(hasAccessToken);
if (tokenUrls.length > 0) {
return {
content: [
{
type: 'text',
text: `Invalid URLs: URLs must not contain access_token. Invalid: ${tokenUrls.join(', ')}`
}
],
isError: true
Expand Down
34 changes: 32 additions & 2 deletions src/tools/get-document-tool/GetDocumentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,28 @@ import {
GetDocumentInput
} from './GetDocumentTool.input.schema.js';

// Explicit allowlist of hostnames this docs tool is permitted to fetch.
// api.mapbox.com is intentionally excluded — it is a live API that requires
// auth tokens, not a documentation host. Allowing it would let callers poison
// the shared cache with token-authorized private responses under no-token keys.
const ALLOWED_DOC_HOSTNAMES = new Set([
'docs.mapbox.com',
'mapbox.com',
'docs.tilestream.net'
]);

function isMapboxUrl(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'mapbox.com' || hostname.endsWith('.mapbox.com');
return ALLOWED_DOC_HOSTNAMES.has(hostname);
} catch {
return false;
}
}

function hasAccessToken(url: string): boolean {
try {
return new URL(url).searchParams.has('access_token');
} catch {
return false;
}
Expand Down Expand Up @@ -45,7 +63,19 @@ export class GetDocumentTool extends BaseTool<typeof GetDocumentSchema> {
content: [
{
type: 'text',
text: `Invalid URL: only mapbox.com URLs are supported. Received: ${input.url}`
text: `Invalid URL: only mapbox.com documentation URLs are supported. Received: ${input.url}`
}
],
isError: true
};
}

if (hasAccessToken(input.url)) {
return {
content: [
{
type: 'text',
text: `Invalid URL: URLs must not contain access_token. Received: ${input.url}`
}
],
isError: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,52 @@ describe('BatchGetDocumentsTool', () => {
expect(result.isError).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});

it('rejects api.mapbox.com URLs', async () => {
const httpRequest = vi.fn();
const tool = new BatchGetDocumentsTool({ httpRequest });

const result = await tool.run({
urls: ['https://api.mapbox.com/styles/v1/owner/styleId']
});

expect(result.isError).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});

it('rejects URLs containing access_token', async () => {
const httpRequest = vi.fn();
const tool = new BatchGetDocumentsTool({ httpRequest });

const result = await tool.run({
urls: ['https://docs.mapbox.com/page?access_token=pk.secret']
});

expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toMatch(
/access_token/
);
expect(httpRequest).not.toHaveBeenCalled();
});

it('blocks cache poisoning: tokenized URL cannot prime cache for no-token URL', async () => {
// Even if somehow both URLs passed validation (they do not), this test
// documents the expected behavior: private data must not leak.
// In practice the access_token check above prevents this entirely.
const httpRequest = vi.fn().mockResolvedValue(makeResponse('private'));
const tool = new BatchGetDocumentsTool({ httpRequest });

// Attempt the poisoning using an api.mapbox.com URL — must be rejected
const poisonResult = await tool.run({
urls: [
'https://api.mapbox.com/styles/v1/owner/id?access_token=secret',
'https://api.mapbox.com/styles/v1/owner/id'
]
});
expect(poisonResult.isError).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
expect(docCache.size).toBe(0);
});
});

describe('HTTP errors', () => {
Expand Down
104 changes: 104 additions & 0 deletions test/tools/get-document-tool/GetDocumentTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetDocumentTool } from '../../../src/tools/get-document-tool/GetDocumentTool.js';
import { docCache } from '../../../src/utils/docCache.js';

beforeEach(() => {
docCache.clear();
});

function makeResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: {
'content-type': 'text/plain',
'content-length': String(Buffer.byteLength(body, 'utf8'))
}
});
}

describe('GetDocumentTool', () => {
describe('URL validation', () => {
it('rejects non-mapbox URLs', async () => {
const httpRequest = vi.fn();
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({ url: 'https://evil.com/page' });

expect(result.isError).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});

it('rejects api.mapbox.com URLs', async () => {
const httpRequest = vi.fn();
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({
url: 'https://api.mapbox.com/styles/v1/owner/styleId'
});

expect(result.isError).toBe(true);
expect(httpRequest).not.toHaveBeenCalled();
});

it('rejects URLs containing access_token', async () => {
const httpRequest = vi.fn();
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({
url: 'https://docs.mapbox.com/page?access_token=pk.secret'
});

expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toMatch(
/access_token/
);
expect(httpRequest).not.toHaveBeenCalled();
});

it('allows docs.mapbox.com URLs', async () => {
const httpRequest = vi.fn().mockResolvedValue(makeResponse('content'));
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({ url: 'https://docs.mapbox.com/page' });

expect(result.isError).toBe(false);
});
});

describe('caching', () => {
it('returns cached content without an HTTP request', async () => {
docCache.set('https://docs.mapbox.com/page', 'cached content');
const httpRequest = vi.fn();
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({ url: 'https://docs.mapbox.com/page' });

expect(result.isError).toBe(false);
expect((result.content[0] as { text: string }).text).toBe(
'cached content'
);
expect(httpRequest).not.toHaveBeenCalled();
});
});

describe('HTTP errors', () => {
it('returns an error on non-ok response', async () => {
const httpRequest = vi
.fn()
.mockResolvedValue(
new Response('Not Found', { status: 404, statusText: 'Not Found' })
);
const tool = new GetDocumentTool({ httpRequest });

const result = await tool.run({ url: 'https://docs.mapbox.com/missing' });

expect(result.isError).toBe(true);
expect((result.content[0] as { text: string }).text).toMatch(
/Failed to fetch/
);
});
});
});
Loading