diff --git a/.gitignore b/.gitignore index 99d2715..0714ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ yarn-error.log* tmp/ temp/ *.tmp +/.claude/settings.local.json diff --git a/package.json b/package.json index 5b5b485..d4f093d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/__tests__/generator.spec.ts b/src/__tests__/generator.spec.ts index 748db26..b5a4737 100644 --- a/src/__tests__/generator.spec.ts +++ b/src/__tests__/generator.spec.ts @@ -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); + }); +}); diff --git a/src/generator.ts b/src/generator.ts index 532484c..4cb1224 100644 --- a/src/generator.ts +++ b/src/generator.ts @@ -5,6 +5,7 @@ import $RefParser from '@apidevtools/json-schema-ref-parser'; import type { OpenAPIDocument, LoadOptions, + RefResolutionOptions, GenerateOptions, McpOpenAPITool, ValidationResult, @@ -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'; @@ -43,6 +45,7 @@ export class OpenAPIToolGenerator { timeout: options.timeout ?? 30000, validate: options.validate ?? true, followRedirects: options.followRedirects ?? true, + refResolution: options.refResolution ?? {}, }; } @@ -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 = [ + '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): 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 = { + 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 = { + 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) */ @@ -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); diff --git a/src/index.ts b/src/index.ts index a670802..1465e7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export type { ServerInfo, // Configuration types + RefResolutionOptions, LoadOptions, GenerateOptions, NamingStrategy, diff --git a/src/types.ts b/src/types.ts index c11efbb..f3b7aa6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -479,6 +479,41 @@ export interface ServerInfo { variables?: Record; } +/** + * Controls how external $ref pointers are resolved during dereferencing. + * By default, only http/https protocols are allowed and internal/private + * IP addresses are blocked to prevent SSRF attacks. + */ +export interface RefResolutionOptions { + /** + * Protocols allowed for external $ref resolution. + * Any protocol string is accepted (http, https, ftp, ws, wss, etc.). + * @default ['http', 'https'] + */ + allowedProtocols?: string[]; + + /** + * Hostnames allowed for external $ref resolution (network protocols only). + * When set, only refs pointing to these hosts are resolved. + * When not set, all hosts are allowed except blocked internal ranges. + */ + allowedHosts?: string[]; + + /** + * Additional hostnames/IPs to block. Applied on top of the built-in + * internal IP block list (localhost, 169.254.x.x, 10.x.x.x, etc.). + */ + blockedHosts?: string[]; + + /** + * Disable the built-in internal/private IP block list. + * WARNING: Enabling this may expose your application to SSRF attacks + * against cloud metadata endpoints and internal services. + * @default false + */ + allowInternalIPs?: boolean; +} + /** * Options for loading OpenAPI specifications */ @@ -517,6 +552,13 @@ export interface LoadOptions { * @default true */ followRedirects?: boolean; + + /** + * Controls external $ref resolution security. + * By default, file:// is blocked and internal IPs are blocked. + * @see RefResolutionOptions + */ + refResolution?: RefResolutionOptions; } /** diff --git a/yarn.lock b/yarn.lock index c84905d..0987b7c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2407,10 +2407,10 @@ yallist@^3.0.2: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yaml@^2.8.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" - integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== +yaml@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== yargs-parser@^21.1.1: version "21.1.1"