- 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>
206 lines
6.6 KiB
TypeScript
206 lines
6.6 KiB
TypeScript
/**
|
|
* Security validation tests for swagger-tools.
|
|
* Tests ReDoS, path traversal, and SSRF protection.
|
|
*/
|
|
|
|
import {
|
|
validateRegexPattern,
|
|
createSafeRegex,
|
|
validateFilePath,
|
|
validateUrl,
|
|
validateSpecPath,
|
|
} from '../../src/lib/input-validation.js';
|
|
|
|
interface TestResult {
|
|
name: string;
|
|
passed: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
const results: TestResult[] = [];
|
|
|
|
function test(name: string, fn: () => void): void {
|
|
try {
|
|
fn();
|
|
results.push({ name, passed: true });
|
|
console.log(`✅ ${name}`);
|
|
} catch (err) {
|
|
results.push({ name, passed: false, error: (err as Error).message });
|
|
console.log(`❌ ${name}: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
function assert(condition: boolean, message: string): void {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
console.log('\n=== ReDoS Protection Tests ===\n');
|
|
|
|
test('Rejects nested quantifier pattern (a+)+', () => {
|
|
const result = validateRegexPattern('(a+)+$');
|
|
assert(!result.valid, 'Should reject dangerous pattern');
|
|
assert(result.error?.includes('nested quantifiers'), 'Should mention nested quantifiers');
|
|
});
|
|
|
|
test('Rejects nested quantifier pattern (a*)+', () => {
|
|
const result = validateRegexPattern('(a*)+');
|
|
assert(!result.valid, 'Should reject dangerous pattern');
|
|
});
|
|
|
|
test('Rejects nested quantifier pattern ([a-zA-Z]+)*', () => {
|
|
const result = validateRegexPattern('([a-zA-Z]+)*$');
|
|
assert(!result.valid, 'Should reject dangerous pattern');
|
|
});
|
|
|
|
test('Rejects overly long regex patterns', () => {
|
|
const longPattern = 'a'.repeat(501);
|
|
const result = validateRegexPattern(longPattern);
|
|
assert(!result.valid, 'Should reject long pattern');
|
|
assert(result.error?.includes('maximum length'), 'Should mention length limit');
|
|
});
|
|
|
|
test('Allows safe regex patterns', () => {
|
|
const safePatterns = [
|
|
'/users/.*',
|
|
'/api/v[0-9]+/.*',
|
|
'^/pets$',
|
|
'/orders/[0-9]+$',
|
|
];
|
|
for (const pattern of safePatterns) {
|
|
const result = validateRegexPattern(pattern);
|
|
assert(result.valid, `Should allow safe pattern: ${pattern}`);
|
|
}
|
|
});
|
|
|
|
test('createSafeRegex returns null for dangerous patterns', () => {
|
|
const regex = createSafeRegex('(a+)+$');
|
|
assert(regex === null, 'Should return null for dangerous pattern');
|
|
});
|
|
|
|
test('createSafeRegex returns RegExp for safe patterns', () => {
|
|
const regex = createSafeRegex('/users/.*');
|
|
assert(regex instanceof RegExp, 'Should return RegExp for safe pattern');
|
|
assert(regex.test('/users/123'), 'Regex should work correctly');
|
|
});
|
|
|
|
console.log('\n=== Path Traversal Protection Tests ===\n');
|
|
|
|
test('Rejects path with ../', () => {
|
|
const result = validateFilePath('../../../etc/passwd');
|
|
assert(!result.valid, 'Should reject traversal pattern');
|
|
assert(result.error?.includes('traversal'), 'Should mention traversal');
|
|
});
|
|
|
|
test('Rejects path with encoded traversal %2e%2e', () => {
|
|
const result = validateFilePath('%2e%2e%2fetc/passwd');
|
|
assert(!result.valid, 'Should reject encoded traversal');
|
|
});
|
|
|
|
test('Rejects path with double encoded traversal', () => {
|
|
const result = validateFilePath('%252e%252e%252fetc/passwd');
|
|
assert(!result.valid, 'Should reject double encoded traversal');
|
|
});
|
|
|
|
test('Allows paths within current directory', () => {
|
|
const result = validateFilePath('./test/fixtures/petstore.yaml');
|
|
assert(result.valid, 'Should allow relative path within cwd');
|
|
});
|
|
|
|
test('Allows absolute paths within cwd', () => {
|
|
const cwd = process.cwd();
|
|
const result = validateFilePath(`${cwd}/test/fixtures/petstore.yaml`);
|
|
assert(result.valid, 'Should allow absolute path within cwd');
|
|
});
|
|
|
|
console.log('\n=== SSRF Protection Tests ===\n');
|
|
|
|
test('Rejects localhost URLs', () => {
|
|
const result = validateUrl('http://localhost/api/spec.json');
|
|
assert(!result.valid, 'Should reject localhost');
|
|
assert(result.error?.includes('blocked'), 'Should indicate blocked');
|
|
});
|
|
|
|
test('Rejects 127.0.0.1 URLs', () => {
|
|
const result = validateUrl('http://127.0.0.1/api/spec.json');
|
|
assert(!result.valid, 'Should reject loopback IP');
|
|
});
|
|
|
|
test('Rejects private IP 10.x.x.x', () => {
|
|
const result = validateUrl('http://10.0.0.1/api/spec.json');
|
|
assert(!result.valid, 'Should reject private IP');
|
|
assert(result.error?.includes('Private'), 'Should mention private IP');
|
|
});
|
|
|
|
test('Rejects private IP 172.16.x.x', () => {
|
|
const result = validateUrl('http://172.16.0.1/api/spec.json');
|
|
assert(!result.valid, 'Should reject private IP');
|
|
});
|
|
|
|
test('Rejects private IP 192.168.x.x', () => {
|
|
const result = validateUrl('http://192.168.1.1/api/spec.json');
|
|
assert(!result.valid, 'Should reject private IP');
|
|
});
|
|
|
|
test('Rejects file:// protocol', () => {
|
|
const result = validateUrl('file:///etc/passwd');
|
|
assert(!result.valid, 'Should reject file protocol');
|
|
assert(result.error?.includes('Protocol'), 'Should mention protocol');
|
|
});
|
|
|
|
test('Rejects ftp:// protocol', () => {
|
|
const result = validateUrl('ftp://example.com/spec.json');
|
|
assert(!result.valid, 'Should reject ftp protocol');
|
|
});
|
|
|
|
test('Allows public HTTPS URLs', () => {
|
|
const result = validateUrl('https://petstore.swagger.io/v2/swagger.json');
|
|
assert(result.valid, 'Should allow public HTTPS URL');
|
|
});
|
|
|
|
test('Allows public HTTP URLs', () => {
|
|
const result = validateUrl('http://api.example.com/spec.json');
|
|
assert(result.valid, 'Should allow public HTTP URL');
|
|
});
|
|
|
|
test('Allows private IPs when allowPrivateIPs is true', () => {
|
|
const result = validateUrl('http://192.168.1.1/spec.json', { allowPrivateIPs: true });
|
|
assert(result.valid, 'Should allow private IP with option');
|
|
});
|
|
|
|
console.log('\n=== Spec Path Validation Tests ===\n');
|
|
|
|
test('validateSpecPath correctly identifies URLs', () => {
|
|
const urlResult = validateSpecPath('https://example.com/spec.json');
|
|
assert(urlResult.valid, 'Should validate URL correctly');
|
|
|
|
const localResult = validateSpecPath('./spec.yaml');
|
|
assert(localResult.valid, 'Should validate local path correctly');
|
|
});
|
|
|
|
test('validateSpecPath rejects dangerous URLs', () => {
|
|
const result = validateSpecPath('http://localhost/internal/spec.json');
|
|
assert(!result.valid, 'Should reject localhost URL');
|
|
});
|
|
|
|
test('validateSpecPath rejects path traversal', () => {
|
|
const result = validateSpecPath('../../../etc/passwd');
|
|
assert(!result.valid, 'Should reject path traversal');
|
|
});
|
|
|
|
// Summary
|
|
console.log('\n=== Test Summary ===\n');
|
|
const passed = results.filter(r => r.passed).length;
|
|
const failed = results.filter(r => !r.passed).length;
|
|
console.log(`Total: ${results.length}, Passed: ${passed}, Failed: ${failed}`);
|
|
|
|
if (failed > 0) {
|
|
console.log('\nFailed tests:');
|
|
results.filter(r => !r.passed).forEach(r => {
|
|
console.log(` - ${r.name}: ${r.error}`);
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log('\n✅ All security tests passed!\n');
|