diff --git a/packages/mcp-core/src/__tests__/api-client.test.ts b/packages/mcp-core/src/__tests__/api-client.test.ts index 0ad78e6..59d292d 100644 --- a/packages/mcp-core/src/__tests__/api-client.test.ts +++ b/packages/mcp-core/src/__tests__/api-client.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { apiRequest, ApiRequestError, + InvalidApiPathError, UnauthorizedError, UntrustedApiHostError, } from '../api-client.js'; @@ -134,6 +135,27 @@ describe('apiRequest', () => { expect(invalidateTokenMock).not.toHaveBeenCalled(); }); + it.each([ + '/orgs/acme/teams/..', + '/orgs/../subscriptions', + '/orgs/./acme', + '/orgs/acme/..?x=1', + '/orgs/%2e%2e/subscriptions', + '/orgs/.%2E/x', + '/orgs\\..\\subscriptions', + '/orgs/.\t./subscriptions', + '/orgs/%2e\n%2e/x', + ])('rejects the dot segment in %s before fetching', async (path) => { + await expect(apiRequest(path, { token: 'secret' })).rejects.toBeInstanceOf(InvalidApiPathError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('allows dots inside a segment and in the query string', async () => { + mockFetch.mockResolvedValue(makeResponse({})); + await apiRequest('/orgs/a.b/files/..hidden?next=../x'); + expect(mockFetch).toHaveBeenCalledOnce(); + }); + it('refuses to send a token to a host outside the allowlist', async () => { await expect( apiRequest('/orgs', { token: 'secret', baseUrl: 'https://evil.example.com' }), diff --git a/packages/mcp-core/src/api-client.ts b/packages/mcp-core/src/api-client.ts index 922c057..3c94d61 100644 --- a/packages/mcp-core/src/api-client.ts +++ b/packages/mcp-core/src/api-client.ts @@ -65,6 +65,24 @@ export class UntrustedApiHostError extends Error { } } +export class InvalidApiPathError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidApiPathError'; + } +} + +export function assertNoDotSegments(path: string): void { + const [pathname = ''] = path.split(/[?#]/, 1); + const segments = pathname + .replace(/[\t\n\r]/g, '') + .split(/[\\/]/) + .map((segment) => segment.replace(/%2e/gi, '.')); + if (segments.some((segment) => segment === '.' || segment === '..')) { + throw new InvalidApiPathError(`refusing a request path with a dot segment: ${path}`); + } +} + function allowedApiHosts(): string[] { const extra = (process.env.FERRLABS_MCP_ALLOWED_API_HOSTS ?? '') .split(',') @@ -106,6 +124,7 @@ export function assertCredentialTarget(url: string): void { export async function apiRequest(path: string, options: RequestOptions = {}): Promise { const { method = 'GET', body, token, baseUrl, headers: extraHeaders } = options; const base = baseUrl ?? DEFAULT_API_URL; + assertNoDotSegments(path); const url = `${base}${path}`; if (token) assertCredentialTarget(url); diff --git a/packages/mcp-core/src/index.ts b/packages/mcp-core/src/index.ts index d0042fe..372e927 100644 --- a/packages/mcp-core/src/index.ts +++ b/packages/mcp-core/src/index.ts @@ -4,6 +4,7 @@ export { ApiRequestError, UnauthorizedError, UntrustedApiHostError, + InvalidApiPathError, } from './api-client.js'; export { getToken, clearTokenCache, invalidateToken } from './auth/index.js'; export { runWithAuthContext, getRequestBearerToken } from './auth/context.js';