swagger-tools/scripts/self-testing/security-test.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

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');