swagger-tools/src/tools/query.ts
rimskij d7f354b37d feat: add security hardening for ReDoS, path traversal, and SSRF
- Add input-validation.ts with regex, path, and URL validation utilities
- Validate regex patterns before RegExp creation to prevent ReDoS
- Block dangerous nested quantifiers (a+)+, (a*)+, etc.
- Prevent path traversal with directory escape detection
- Block localhost, private IPs, and non-http/https protocols for SSRF
- Add SecurityOptions for configurable validation (allowPrivateIPs, etc.)
- Include 33 security tests (unit + integration)

Fixes #362

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:20:26 +01:00

163 lines
5.3 KiB
TypeScript

import { z } from 'zod';
import { parseSpec } from '../lib/parser.js';
import { formatEndpoints } from '../utils/format.js';
import { HTTP_METHODS } from '../lib/types.js';
import { successResponse, errorResponse } from '../lib/tool-response.js';
import { createSafeRegex, validateRegexPattern } from '../lib/input-validation.js';
import type { ToolResponse } from '../lib/tool-response.js';
import type { EndpointInfo, EndpointFilter, ParameterInfo, ResponseInfo } from '../lib/types.js';
import type { OpenAPIV3 } from 'openapi-types';
export const queryToolName = 'query-endpoints';
export const queryToolDescription = 'Search and filter API endpoints in an OpenAPI spec. Filter by method, path pattern, tag, or operation ID.';
export const queryToolSchema = {
path: z.string().describe('Path to the OpenAPI/Swagger spec file'),
method: z.string().optional().describe('Filter by HTTP method (GET, POST, etc.)'),
pathPattern: z.string().optional().describe('Regex pattern to match path'),
tag: z.string().optional().describe('Filter by tag name'),
operationId: z.string().optional().describe('Filter by exact operation ID'),
noCache: z.boolean().optional().describe('Bypass cache and parse fresh'),
};
export async function queryToolHandler(args: {
path: string;
method?: string;
pathPattern?: string;
tag?: string;
operationId?: string;
noCache?: boolean;
}): Promise<ToolResponse> {
try {
// Validate regex pattern before use (ReDoS protection)
if (args.pathPattern) {
const regexValidation = validateRegexPattern(args.pathPattern);
if (!regexValidation.valid) {
return errorResponse(
regexValidation.error ?? 'Invalid path pattern',
'validating path pattern'
);
}
}
const { spec } = await parseSpec(args.path, { noCache: args.noCache });
const filter: EndpointFilter = {
method: args.method?.toLowerCase(),
pathPattern: args.pathPattern,
tag: args.tag,
operationId: args.operationId,
};
const endpoints = extractEndpoints(spec, filter);
const text = formatEndpoints(endpoints);
return successResponse(text, { count: endpoints.length, endpoints });
} catch (err) {
return errorResponse((err as Error).message, 'querying endpoints');
}
}
function extractEndpoints(spec: object, filter: EndpointFilter): EndpointInfo[] {
const endpoints: EndpointInfo[] = [];
const paths = (spec as { paths?: Record<string, object> }).paths || {};
for (const [pathName, pathItem] of Object.entries(paths)) {
if (!pathItem) continue;
for (const method of HTTP_METHODS) {
const operation = (pathItem as Record<string, unknown>)[method] as OpenAPIV3.OperationObject | undefined;
if (!operation) continue;
// Apply filters
if (filter.method && method !== filter.method) continue;
if (filter.pathPattern) {
// Use safe regex creation (already validated in handler)
const regex = createSafeRegex(filter.pathPattern);
if (regex && !regex.test(pathName)) continue;
}
if (filter.tag && (!operation.tags || !operation.tags.includes(filter.tag))) continue;
if (filter.operationId && operation.operationId !== filter.operationId) continue;
// Extract endpoint info
const endpoint: EndpointInfo = {
method,
path: pathName,
operationId: operation.operationId,
summary: operation.summary,
description: operation.description,
tags: operation.tags || [],
parameters: extractParameters(operation, pathItem as OpenAPIV3.PathItemObject),
requestBody: extractRequestBody(operation),
responses: extractResponses(operation),
};
endpoints.push(endpoint);
}
}
return endpoints;
}
function extractParameters(
operation: OpenAPIV3.OperationObject,
pathItem: OpenAPIV3.PathItemObject
): ParameterInfo[] {
const params: ParameterInfo[] = [];
// Combine path-level and operation-level parameters
const allParams = [
...(pathItem.parameters || []),
...(operation.parameters || []),
] as OpenAPIV3.ParameterObject[];
for (const param of allParams) {
if ('$ref' in param) continue; // Skip refs (should be dereferenced)
params.push({
name: param.name,
in: param.in as 'query' | 'header' | 'path' | 'cookie',
required: param.required || false,
description: param.description,
schema: param.schema as object | undefined,
});
}
return params;
}
function extractRequestBody(operation: OpenAPIV3.OperationObject): EndpointInfo['requestBody'] {
if (!operation.requestBody) return undefined;
const body = operation.requestBody as OpenAPIV3.RequestBodyObject;
if ('$ref' in body) return undefined; // Skip refs
return {
required: body.required || false,
description: body.description,
contentTypes: Object.keys(body.content || {}),
};
}
function extractResponses(operation: OpenAPIV3.OperationObject): ResponseInfo[] {
const responses: ResponseInfo[] = [];
if (!operation.responses) return responses;
for (const [statusCode, response] of Object.entries(operation.responses)) {
const resp = response as OpenAPIV3.ResponseObject;
if ('$ref' in resp) continue; // Skip refs
responses.push({
statusCode,
description: resp.description,
contentTypes: Object.keys(resp.content || {}),
});
}
return responses;
}