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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ yarn-error.log*
tmp/
temp/
*.tmp
/.claude/settings.local.json
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.9.3",
"openapi-types": "^12.1.3",
"yaml": "^2.8.1"
"yaml": "^2.8.3"
},
"peerDependencies": {
"zod": "^4.0.0"
Expand Down
224 changes: 224 additions & 0 deletions src/__tests__/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,3 +1594,227 @@ describe('ResponseBuilder', () => {
expect(schema?.oneOf).toHaveLength(2);
});
});

describe('SSRF Prevention - $ref resolution security', () => {
let derefSpy: jest.SpyInstance;

beforeEach(() => {
derefSpy = jest.spyOn(
// eslint-disable-next-line @typescript-eslint/no-var-requires
require('@apidevtools/json-schema-ref-parser').default,
'dereference',
);
});

afterEach(() => {
derefSpy.mockRestore();
});

const minimalSpec: OpenAPIDocument = {
openapi: '3.0.0',
info: { title: 'Test', version: '1.0.0' },
paths: {
'/test': {
get: {
operationId: 'getTest',
summary: 'test',
responses: { '200': { description: 'OK' } },
},
},
},
};

it('should block file:// protocol by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const callArgs = derefSpy.mock.calls[0];
const options = callArgs[1];
expect(options?.resolve?.file).toBe(false);
});

it('should pass canRead filter for http resolver by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const callArgs = derefSpy.mock.calls[0];
const options = callArgs[1];
expect(options?.resolve?.http?.canRead).toBeInstanceOf(Function);
});

it('should allow external HTTPS refs to public hosts by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'https://api.example.com/schemas/user.json' })).toBe(true);
});

it('should allow external HTTP refs to public hosts by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://api.example.com/schemas/user.json' })).toBe(true);
});

it('should block cloud metadata endpoint (169.254.169.254) by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' })).toBe(false);
});

it('should block localhost by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://localhost/admin' })).toBe(false);
expect(canRead({ url: 'http://127.0.0.1/admin' })).toBe(false);
});

it('should block private IP ranges by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://10.0.0.1/internal' })).toBe(false);
expect(canRead({ url: 'http://172.16.0.1/internal' })).toBe(false);
expect(canRead({ url: 'http://192.168.1.1/internal' })).toBe(false);
});

it('should block Google cloud metadata hostname by default', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://metadata.google.internal/computeMetadata/v1/' })).toBe(false);
});

it('should allow file:// when explicitly in allowedProtocols', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedProtocols: ['file', 'http', 'https'] },
});
await generator.generateTools();

const options = derefSpy.mock.calls[0][1];
expect(options?.resolve?.file).toBeUndefined(); // not blocked
});

it('should restrict to allowedHosts when configured', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedHosts: ['schemas.example.com'] },
});
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'https://schemas.example.com/user.json' })).toBe(true);
expect(canRead({ url: 'https://evil.com/schema.json' })).toBe(false);
});

it('should support exotic protocols in allowedProtocols', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedProtocols: ['https', 'ftp'] },
});
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'https://example.com/schema.json' })).toBe(true);
expect(canRead({ url: 'ftp://example.com/schema.json' })).toBe(true);
expect(canRead({ url: 'http://example.com/schema.json' })).toBe(false);
});

it('should block custom blockedHosts', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { blockedHosts: ['evil.com', 'malicious.io'] },
});
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'https://evil.com/schema.json' })).toBe(false);
expect(canRead({ url: 'https://malicious.io/schema.json' })).toBe(false);
expect(canRead({ url: 'https://good.com/schema.json' })).toBe(true);
});

it('should allow internal IPs when allowInternalIPs is true', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowInternalIPs: true },
});
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'http://169.254.169.254/latest/meta-data/' })).toBe(true);
expect(canRead({ url: 'http://127.0.0.1/admin' })).toBe(true);
expect(canRead({ url: 'http://10.0.0.1/internal' })).toBe(true);
});

it('should disable all external resolution when allowedProtocols is empty', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedProtocols: [] },
});
await generator.generateTools();

const options = derefSpy.mock.calls[0][1];
expect(options?.resolve?.external).toBe(false);
});

it('should still resolve internal #/ refs with default settings', async () => {
const specWithInternalRef: OpenAPIDocument = {
openapi: '3.0.0',
info: { title: 'Test', version: '1.0.0' },
paths: {
'/test': {
get: {
operationId: 'getTest',
summary: 'test',
responses: {
'200': {
description: 'OK',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/TestResponse' },
},
},
},
},
},
},
},
components: {
schemas: {
TestResponse: {
type: 'object',
properties: { id: { type: 'string' } },
},
},
},
};

// Don't spy - let actual dereference run to verify internal refs work
derefSpy.mockRestore();
const generator = await OpenAPIToolGenerator.fromJSON(specWithInternalRef);
const tools = await generator.generateTools();
expect(tools).toHaveLength(1);
expect(tools[0].inputSchema).toBeDefined();
});

it('should still skip all dereferencing when dereference: false', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
dereference: false,
});
await generator.generateTools();

expect(derefSpy).not.toHaveBeenCalled();
});

it('should return false for malformed URLs', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec);
await generator.generateTools();

const canRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(canRead({ url: 'not-a-valid-url' })).toBe(false);
expect(canRead({ url: '' })).toBe(false);
});
});
118 changes: 118 additions & 0 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import $RefParser from '@apidevtools/json-schema-ref-parser';
import type {
OpenAPIDocument,
LoadOptions,
RefResolutionOptions,
GenerateOptions,
McpOpenAPITool,
ValidationResult,
Expand All @@ -17,6 +18,7 @@ import type {
ToolMetadata,
ServerObject,
} from './types';
import type { ParserOptions } from '@apidevtools/json-schema-ref-parser';
import { isReferenceObject } from './types';
import { ParameterResolver } from './parameter-resolver';
import { ResponseBuilder } from './response-builder';
Expand All @@ -43,6 +45,7 @@ export class OpenAPIToolGenerator {
timeout: options.timeout ?? 30000,
validate: options.validate ?? true,
followRedirects: options.followRedirects ?? true,
refResolution: options.refResolution ?? {},
};
}

Expand Down Expand Up @@ -164,6 +167,119 @@ export class OpenAPIToolGenerator {
return validator.validate(this.document);
}

/**
* Hostnames and IP patterns that are blocked by default to prevent SSRF.
* Covers RFC 1918/6598 private ranges, link-local, loopback, and cloud metadata endpoints.
*/
private static readonly BLOCKED_HOSTNAME_PATTERNS: ReadonlyArray<string | RegExp> = [
'localhost',
'metadata.google.internal',
/^127\.\d+\.\d+\.\d+$/, // 127.0.0.0/8 loopback
/^10\.\d+\.\d+\.\d+$/, // 10.0.0.0/8 private
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/, // 172.16.0.0/12 private
/^192\.168\.\d+\.\d+$/, // 192.168.0.0/16 private
/^169\.254\.\d+\.\d+$/, // 169.254.0.0/16 link-local / cloud metadata
/^0\.0\.0\.0$/, // unspecified
'::1', // IPv6 loopback
/^fd[0-9a-f]{2}:/i, // fd00::/8 IPv6 ULA
/^fe80:/i, // fe80::/10 IPv6 link-local
/^\[::1\]$/, // bracketed IPv6 loopback
/^\[fd[0-9a-f]{2}:/i, // bracketed IPv6 ULA
/^\[fe80:/i, // bracketed IPv6 link-local
];

/**
* Check whether a hostname is blocked (internal/private IP or explicit blocklist).
*/
private isBlockedHost(hostname: string, refOpts: Required<RefResolutionOptions>): boolean {
if (refOpts.allowInternalIPs) {
// Only check user-provided blockedHosts
return refOpts.blockedHosts.includes(hostname);
}

// Check user-provided blockedHosts
if (refOpts.blockedHosts.includes(hostname)) {
return true;
}

// Check built-in block list
for (const pattern of OpenAPIToolGenerator.BLOCKED_HOSTNAME_PATTERNS) {
if (typeof pattern === 'string') {
if (hostname === pattern) return true;
} else {
if (pattern.test(hostname)) return true;
}
}

return false;
}

/**
* Build $RefParser options based on refResolution configuration.
* Defaults: allow http/https, block file://, block internal IPs.
*/
private buildRefParserOptions(): ParserOptions {
const raw = this.options.refResolution;
const refOpts: Required<RefResolutionOptions> = {
allowedProtocols: raw.allowedProtocols ?? ['http', 'https'],
allowedHosts: raw.allowedHosts ?? [],
blockedHosts: raw.blockedHosts ?? [],
allowInternalIPs: raw.allowInternalIPs ?? false,
};

const allowedProtocols = new Set(refOpts.allowedProtocols);
const hasNetworkProtocol = allowedProtocols.size > 0 &&
!([...allowedProtocols].length === 1 && allowedProtocols.has('file'));

// If no protocols allowed at all, disable external resolution entirely
if (allowedProtocols.size === 0) {
return { resolve: { external: false } } as ParserOptions;
}

const resolveConfig: Record<string, unknown> = {
external: true,
file: allowedProtocols.has('file') ? undefined : false,
};

// Configure HTTP/HTTPS resolver with security filtering
if (hasNetworkProtocol) {
const hasHostAllowlist = refOpts.allowedHosts.length > 0;
const hostAllowSet = new Set(refOpts.allowedHosts);

resolveConfig['http'] = {
canRead: (file: { url: string }): boolean => {
try {
const parsed = new URL(file.url);
const protocol = parsed.protocol.replace(':', '');

// Check protocol allowlist
if (!allowedProtocols.has(protocol)) {
return false;
}

// Check host allowlist (if configured)
if (hasHostAllowlist && !hostAllowSet.has(parsed.hostname)) {
return false;
}

// Check blocked hosts (internal IPs, etc.)
if (this.isBlockedHost(parsed.hostname, refOpts)) {
return false;
}

return true;
} catch {
return false;
}
},
};
} else {
resolveConfig['http'] = false;
}

return { resolve: resolveConfig } as ParserOptions;
}

/**
* Initialize the generator (dereference if needed)
*/
Expand All @@ -177,8 +293,10 @@ export class OpenAPIToolGenerator {

if (this.options.dereference && !this.dereferencedDocument) {
try {
const refParserOptions = this.buildRefParserOptions();
this.dereferencedDocument = (await $RefParser.dereference(
JSON.parse(JSON.stringify(this.document)),
refParserOptions,
)) as OpenAPIDocument;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
Expand Down
Loading
Loading