vulnforge-mcp / src /fuzz /api_fuzz.ts
mason-keller
feat: AI dynamic API fuzzing & mutation resilience auditor (v1.6.0)
36e5a71
Raw History Blame Contribute Delete
16.7 kB
/**
* VulnForge MCP - Dynamic API Fuzzing & Mutation Resilience Auditor
* @module fuzz/api_fuzz
*/
import {
FuzzAuditInput,
FuzzAuditResult,
FuzzAuditSummary,
FuzzMutationCase,
FuzzMutationType,
FuzzStrategy,
SecurityFinding
} from '../types/index.js';
export const BOUNDARY_INTEGER_PAYLOADS = [
0,
-1,
1,
2147483647, // 32-bit signed MaxInt
-2147483648, // 32-bit signed MinInt
4294967295, // 32-bit unsigned MaxInt
Number.MAX_SAFE_INTEGER, // 9007199254740991
Number.MIN_SAFE_INTEGER, // -9007199254740991
1e20,
NaN,
Infinity,
-Infinity
];
export const PROTOTYPE_POLLUTION_PAYLOADS: Array<Record<string, any>> = [
{ __proto__: { polluted: true } },
{ constructor: { prototype: { admin: true } } },
{ 'prototype': { 'isAdmin': true } },
{ '__proto__.polluted': 'true' }
];
export const TYPE_CONFUSION_PAYLOADS = [
null,
undefined,
true,
false,
[],
{},
{ $gt: '' }, // NoSQL operator injection probe
{ $ne: null },
[1, 2, 3],
[['nested']]
];
export const MALFORMED_ENCODING_PAYLOADS = [
'%00', // Null byte injection
'\u0000',
'\r\nSet-Cookie: vulnforge_session=hijacked', // CRLF header injection probe
'../../../../etc/passwd', // Path traversal probe
'<script>alert(1)</script>', // XSS probe
"' OR 1=1 --", // SQLi probe
'\uFEFF', // Zero-width non-breaking space
'A'.repeat(10000) // Oversized string DoS payload
];
/**
* Generates an automated suite of feedback-guided mutations for API endpoint fields.
*/
export function generateMutationSuite(
fields: Record<string, string>,
strategy: FuzzStrategy = 'ALL',
rounds: number = 10
): FuzzMutationCase[] {
const mutations: FuzzMutationCase[] = [];
let caseId = 1;
for (const [field, expectedType] of Object.entries(fields)) {
// 1. Boundary Integer Mutations
if (
(strategy === 'ALL' || strategy === 'BOUNDARY_STRESS' || strategy === 'GENETIC_MUTATION') &&
(expectedType === 'number' || expectedType === 'integer')
) {
for (const val of BOUNDARY_INTEGER_PAYLOADS.slice(0, rounds)) {
mutations.push({
id: `FUZZ-INT-${caseId++}`,
field,
mutationType: 'BOUNDARY_INT',
payload: val,
description: `Boundary integer mutation probe: ${String(val)} for field '${field}'`,
expectedStatus: 400
});
}
}
// 2. Type Confusion Mutations
if (strategy === 'ALL' || strategy === 'TYPE_CONFUSION' || strategy === 'GENETIC_MUTATION') {
for (const val of TYPE_CONFUSION_PAYLOADS.slice(0, Math.min(rounds, TYPE_CONFUSION_PAYLOADS.length))) {
mutations.push({
id: `FUZZ-TYPE-${caseId++}`,
field,
mutationType: 'TYPE_CONFUSION',
payload: val,
description: `Type confusion mutation probe with payload type '${typeof val}' for field '${field}'`,
expectedStatus: 400
});
}
}
// 3. Prototype Pollution Probes
if (strategy === 'ALL' || strategy === 'PROTOTYPE_POLLUTION') {
for (const val of PROTOTYPE_POLLUTION_PAYLOADS) {
mutations.push({
id: `FUZZ-PROTO-${caseId++}`,
field,
mutationType: 'PROTOTYPE_POLLUTION',
payload: val,
description: `Prototype pollution injection probe into field '${field}'`,
expectedStatus: 400
});
}
}
// 4. Malformed Encoding & Special Characters
if (strategy === 'ALL' || strategy === 'BOUNDARY_STRESS') {
for (const val of MALFORMED_ENCODING_PAYLOADS.slice(0, rounds)) {
mutations.push({
id: `FUZZ-ENC-${caseId++}`,
field,
mutationType:
typeof val === 'string' && val.length > 500
? 'OVERSIZED_PAYLOAD'
: 'MALFORMED_ENCODING',
payload: val,
description:
typeof val === 'string' && val.length > 500
? `Oversized string buffer overflow / DoS stress test on field '${field}'`
: `Encoding anomaly and delimiter injection probe on field '${field}'`,
expectedStatus: 400
});
}
}
}
return mutations;
}
/**
* Audits API route source code and schemas for dynamic fuzzing resilience,
* missing input validation, unhandled exceptions, prototype pollution, and DoS vectors.
*/
export function auditApiFuzzResilience(input: FuzzAuditInput): FuzzAuditResult {
const code = input.routeCode || '';
const findings: SecurityFinding[] = [];
const targetFile = input.filePath || 'api_handler.ts';
let totalMutationsGenerated = 0;
let unhandledExceptionRisks = 0;
let prototypePollutionRisks = 0;
let missingSchemaValidationCount = 0;
let oversizedPayloadDosRisks = 0;
let typeConfusionRisks = 0;
let endpointCount = 0;
// 1. Detect API Route Handlers
const routeRegex = /(?:app|router|server|fastify)\.(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
const routeMatches = [...code.matchAll(routeRegex)];
endpointCount = routeMatches.length || (code.trim() ? 1 : 0);
// 2. Check for Missing Schema Validation (Zod, Joi, Yup, Celebrate, Schema Validator)
const hasSchemaValidation =
/\b(?:z\.object|Joi\.object|yup\.object|validate\(|validator\.|schema\.parse|schema\.safeParse|checkSchema)\b/i.test(
code
);
// Check if endpoints read req.body, req.query, or req.params without validation
const readsRequestBody = /\breq\.body(?:\.[a-zA-Z0-9_$]+)?\b/.test(code);
const readsRequestQuery = /\breq\.query(?:\.[a-zA-Z0-9_$]+)?\b/.test(code);
if ((readsRequestBody || readsRequestQuery) && !hasSchemaValidation) {
missingSchemaValidationCount++;
findings.push({
id: 'API-FUZZ-MISSING-SCHEMA-VALIDATION',
title: 'Missing Request Body & Parameter Schema Validation (CWE-20)',
severity: 'HIGH',
category: 'api_fuzzing',
cwe: 'CWE-20: Improper Input Validation',
target: targetFile,
description:
'The API route handler extracts untrusted properties directly from `req.body` or `req.query` without strict schema-level validation (e.g. Zod, Joi). Mutated payloads, type-confused inputs, or unexpected boundary primitives will bypass business assumptions.',
evidence: readsRequestBody ? 'Direct read of `req.body` without Zod/Joi validation' : 'Direct read of `req.query` without validation',
impact:
'Allows attackers to trigger unexpected control flow, type errors, null-pointer dereferences, or logical bypasses by submitting mutated JSON types.',
remediation:
'Implement strict Zod/Joi schema parsing on incoming request bodies and reject undeclared fields using `.strict()`.',
patchRecommendation: {
patchType: 'VALIDATION_SCHEMA_INJECTION',
targetFile,
vulnerableSnippet: 'const { username, amount } = req.body;',
patchedSnippet:
'const RequestSchema = z.object({ username: z.string().min(1).max(100), amount: z.number().int().positive() }).strict();\nconst { username, amount } = RequestSchema.parse(req.body);',
diff: '+ const RequestSchema = z.object({ ... }).strict();\n+ const validated = RequestSchema.parse(req.body);',
explanation: 'Enforce runtime Zod schema parsing and type coercion to reject mutated payloads.'
}
});
}
// 3. Check for Prototype Pollution Risks (CWE-1321)
const hasUnsafeMerge =
/Object\.assign\s*\(\s*(?:\{\}|this|[a-zA-Z0-9_$]+)\s*,\s*(?:req\.body|data|input)\s*\)/.test(code) ||
/(?:merge|cloneDeep|extend)\s*\(\s*(?:\{\}|[a-zA-Z0-9_$]+)\s*,\s*req\.body\s*\)/.test(code) ||
/for\s*\(\s*(?:let|const|var)\s+([a-zA-Z0-9_$]+)\s+in\s+req\.body\s*\)\s*\{[\s\S]*?\[\1\]\s*=/.test(code);
const hasProtoFilter =
code.includes('__proto__') && (code.includes('continue') || code.includes('delete') || code.includes('hasOwn'));
if (hasUnsafeMerge && !hasProtoFilter) {
prototypePollutionRisks++;
findings.push({
id: 'API-FUZZ-PROTOTYPE-POLLUTION',
title: 'Potential Prototype Pollution via Unsafe Object Mutation (CWE-1321)',
severity: 'CRITICAL',
category: 'api_fuzzing',
cwe: 'CWE-1321: Improperly Controlled Modification of Object Prototype Attributes',
target: targetFile,
description:
'The API endpoint recursively merges or copies properties from untrusted request data directly into objects without sanitizing `__proto__`, `constructor`, or `prototype` keys.',
evidence: 'Unchecked `Object.assign` or iterative merge with untrusted `req.body`',
impact:
'Enables attackers to poison `Object.prototype`, leading to remote code execution, authentication bypass, or denial of service across all server operations.',
remediation:
'Explicitly reject or strip `__proto__`, `constructor`, and `prototype` keys, or use `Object.create(null)` for dictionary lookups.',
patchRecommendation: {
patchType: 'PROTOTYPE_POLLUTION_GUARD',
targetFile,
vulnerableSnippet: 'Object.assign(target, req.body);',
patchedSnippet:
'const safeBody = JSON.parse(JSON.stringify(req.body, (key, value) => (key === "__proto__" || key === "constructor" ? undefined : value)));\nObject.assign(target, safeBody);',
diff: '+ const safeBody = JSON.parse(JSON.stringify(req.body, (k, v) => (k === "__proto__" || k === "constructor" ? undefined : v)));\n- Object.assign(target, req.body);\n+ Object.assign(target, safeBody);',
explanation: 'Filter out __proto__ and constructor properties during JSON deserialization.'
}
});
}
// 4. Check for Unhandled Async Exceptions / Missing Error Boundary (CWE-754 / CWE-248)
const isAsyncRoute = /async\s*\(\s*req\s*,\s*res\b/.test(code) || /async\s+function\s*\(\s*req\s*,\s*res\b/.test(code);
const hasTryCatch = /try\s*\{[\s\S]*?\}\s*catch\b/.test(code);
const hasAsyncWrapper = /asyncHandler\s*\(/.test(code) || /wrapAsync\s*\(/.test(code);
if (isAsyncRoute && !hasTryCatch && !hasAsyncWrapper) {
unhandledExceptionRisks++;
findings.push({
id: 'API-FUZZ-UNHANDLED-ASYNC-EXCEPTION',
title: 'Unhandled Async Exception Risk in Route Handler (CWE-754)',
severity: 'MEDIUM',
category: 'api_fuzzing',
cwe: 'CWE-754: Improper Check for Unusual or Exceptional Conditions',
target: targetFile,
description:
'The route handler executes asynchronous operations without a `try/catch` block or an `asyncHandler` wrapper. An unhandled promise rejection caused by malformed input will terminate the Node.js process or cause an unhandled 500 error leaking internals.',
evidence: '`async (req, res)` handler lacking `try/catch` or `asyncHandler` boundary',
impact:
'Process crash or unhandled 500 internal server error leading to denial of service upon receiving fuzzing mutation vectors.',
remediation:
'Wrap asynchronous endpoint logic in a `try/catch` block forwarding errors to `next(err)`, or use `express-async-errors`.',
patchRecommendation: {
patchType: 'WRAPPER',
targetFile,
vulnerableSnippet: 'app.post("/api", async (req, res) => {\n const data = await service.process(req.body);\n res.json(data);\n});',
patchedSnippet:
'app.post("/api", async (req, res, next) => {\n try {\n const data = await service.process(req.body);\n res.json(data);\n } catch (err) {\n next(err);\n }\n});',
diff: '+ try {\n ...\n+ } catch (err) { next(err); }',
explanation: 'Enclose asynchronous operations in a structured error boundary.'
}
});
}
// 5. Check for Missing Body Parser Size Limit / DoS Risk (CWE-400)
const hasBodyParserWithoutLimit =
/(?:express\.json|bodyParser\.json|bodyParser\.urlencoded)\s*\(\s*\)/.test(code) &&
!code.includes('limit:');
if (hasBodyParserWithoutLimit) {
oversizedPayloadDosRisks++;
findings.push({
id: 'API-FUZZ-BODY-PARSER-UNRESTRICTED-SIZE',
title: 'Unrestricted JSON Body Parser Size Limit (CWE-400)',
severity: 'MEDIUM',
category: 'api_fuzzing',
cwe: 'CWE-400: Uncontrolled Resource Consumption',
target: targetFile,
description:
'The body parser middleware is initialized with default unconstrained or high limits without an explicit maximum request size (e.g. `limit: "100kb"`). Oversized mutated payloads will exhaust heap memory and cause denial of service.',
evidence: '`express.json()` or `bodyParser.json()` initialized without explicit `limit` option',
impact:
'Allows attackers to trigger Node.js Out-Of-Memory (OOM) process crashes using bloated JSON buffers.',
remediation: 'Configure an explicit payload limit on body parsing middlewares (e.g. `express.json({ limit: "100kb" })`).',
patchRecommendation: {
patchType: 'CONFIG_UPDATE',
targetFile,
vulnerableSnippet: 'app.use(express.json());',
patchedSnippet: "app.use(express.json({ limit: '100kb' }));",
diff: "- app.use(express.json());\n+ app.use(express.json({ limit: '100kb' }));",
explanation: 'Restrict maximum JSON request body payload size to 100kb.'
}
});
}
// 6. Check for Missing Numeric Bounds / Integer Overflow (CWE-190)
const hasUnboundedInt =
/parseInt\s*\(\s*(?:req\.body|req\.query|req\.params)\.[a-zA-Z0-9_$]+\s*\)/.test(code) &&
!code.includes('Number.isSafeInteger') &&
!code.includes('isNaN') &&
!code.includes('Math.min');
if (hasUnboundedInt) {
typeConfusionRisks++;
findings.push({
id: 'API-FUZZ-UNBOUNDED-INTEGER-PARSE',
title: 'Unbounded Integer Parsing and Lack of Range Validation (CWE-190)',
severity: 'LOW',
category: 'api_fuzzing',
cwe: 'CWE-190: Integer Overflow or Wraparound',
target: targetFile,
description:
'Untrusted input is parsed with `parseInt()` without verifying `Number.isSafeInteger()` or enforcing minimum/maximum numerical boundaries.',
evidence: '`parseInt(req.body.id)` called without range bounds or NaN check',
impact:
'Large boundary numbers (`1e308`, negative values, NaN) can distort downstream financial logic, pagination offsets, or buffer indexing.',
remediation: 'Enforce boundary limits using `Math.max(1, Math.min(val, 1000))` and verify `Number.isSafeInteger()`.',
patchRecommendation: {
patchType: 'VALIDATION_RULE',
targetFile,
vulnerableSnippet: 'const limit = parseInt(req.query.limit);',
patchedSnippet:
'const rawLimit = parseInt(req.query.limit as string, 10);\nconst limit = Number.isSafeInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 100)) : 20;',
diff: '+ const rawLimit = parseInt(req.query.limit, 10);\n+ const limit = Number.isSafeInteger(rawLimit) ? Math.max(1, Math.min(rawLimit, 100)) : 20;',
explanation: 'Clamp parsed integer within safe operational boundaries.'
}
});
}
// Generate Sample Mutation Suite for Reporting
const inferredFields: Record<string, string> = {};
const propMatches = [...code.matchAll(/req\.(?:body|query)\.([a-zA-Z0-9_$]+)/g)];
for (const m of propMatches) {
const name = m[1];
inferredFields[name] = /(?:id|count|amount|limit|offset|page|size)/i.test(name) ? 'number' : 'string';
}
if (Object.keys(inferredFields).length === 0) {
inferredFields['payload'] = 'string';
inferredFields['id'] = 'number';
}
const mutationsSample = generateMutationSuite(
inferredFields,
input.fuzzStrategy || 'ALL',
input.mutationRounds || 5
);
totalMutationsGenerated = mutationsSample.length;
const criticalCount = findings.filter((f) => f.severity === 'CRITICAL').length;
const highCount = findings.filter((f) => f.severity === 'HIGH').length;
const mediumCount = findings.filter((f) => f.severity === 'MEDIUM').length;
const lowCount = findings.filter((f) => f.severity === 'LOW').length;
const complianceStatus: 'PASS' | 'WARN' | 'FAIL' =
criticalCount > 0 || highCount > 0 ? 'FAIL' : mediumCount > 0 || lowCount > 0 ? 'WARN' : 'PASS';
const summary: FuzzAuditSummary = {
endpointsAudited: endpointCount,
totalMutationsGenerated,
unhandledExceptionRisks,
prototypePollutionRisks,
missingSchemaValidationCount,
oversizedPayloadDosRisks,
typeConfusionRisks
};
return {
endpointCount,
totalFindings: findings.length,
criticalCount,
highCount,
mediumCount,
lowCount,
findings,
summary,
mutationsSample: mutationsSample.slice(0, 15),
complianceStatus,
timestamp: new Date().toISOString()
};
}