481 lines
14 KiB
TypeScript
481 lines
14 KiB
TypeScript
|
import { tap } from '@git.zone/tstest/tapbundle';
|
||
|
import * as plugins from '../plugins.js';
|
||
|
import { EInvoice } from '../../../ts/index.js';
|
||
|
import { PerformanceTracker } from '../performance.tracker.js';
|
||
|
import * as path from 'path';
|
||
|
import * as os from 'os';
|
||
|
|
||
|
const performanceTracker = new PerformanceTracker('SEC-05: Path Traversal Prevention');
|
||
|
|
||
|
tap.test('SEC-05: Path Traversal Prevention - should prevent directory traversal attacks', async (t) => {
|
||
|
const einvoice = new EInvoice();
|
||
|
|
||
|
// Test 1: Basic path traversal attempts
|
||
|
const basicPathTraversal = await performanceTracker.measureAsync(
|
||
|
'basic-path-traversal-prevention',
|
||
|
async () => {
|
||
|
const traversalPaths = [
|
||
|
'../../../etc/passwd',
|
||
|
'..\\..\\..\\windows\\system32\\config\\sam',
|
||
|
'../../../../../../../../etc/shadow',
|
||
|
'./../.../.././../etc/hosts',
|
||
|
'..%2F..%2F..%2Fetc%2Fpasswd',
|
||
|
'..%252f..%252f..%252fetc%252fpasswd'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const maliciousPath of traversalPaths) {
|
||
|
try {
|
||
|
// Test file read operation
|
||
|
const canRead = await einvoice.canAccessFile(maliciousPath);
|
||
|
|
||
|
// Test file write operation
|
||
|
const canWrite = await einvoice.canWriteFile(maliciousPath);
|
||
|
|
||
|
// Test path resolution
|
||
|
const resolvedPath = await einvoice.resolvePath(maliciousPath);
|
||
|
|
||
|
results.push({
|
||
|
path: maliciousPath,
|
||
|
blocked: !canRead && !canWrite,
|
||
|
resolved: resolvedPath,
|
||
|
containsTraversal: resolvedPath?.includes('..') || false
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
path: maliciousPath,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
basicPathTraversal.forEach(result => {
|
||
|
t.ok(result.blocked, `Path traversal blocked: ${result.path}`);
|
||
|
t.notOk(result.containsTraversal, 'Resolved path does not contain traversal sequences');
|
||
|
});
|
||
|
|
||
|
// Test 2: Unicode and encoding bypass attempts
|
||
|
const encodingBypass = await performanceTracker.measureAsync(
|
||
|
'encoding-bypass-attempts',
|
||
|
async () => {
|
||
|
const encodedPaths = [
|
||
|
'..%c0%af..%c0%afetc%c0%afpasswd', // Overlong UTF-8
|
||
|
'..%25c0%25af..%25c0%25afetc%25c0%25afpasswd', // Double encoding
|
||
|
'..%c1%9c..%c1%9cetc%c1%9cpasswd', // Invalid UTF-8
|
||
|
'\u002e\u002e/\u002e\u002e/etc/passwd', // Unicode dots
|
||
|
'..%u002f..%u002fetc%u002fpasswd', // IIS Unicode
|
||
|
'..%255c..%255c..%255cwindows%255csystem32' // Double encoded backslash
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const encodedPath of encodedPaths) {
|
||
|
try {
|
||
|
const normalized = await einvoice.normalizePath(encodedPath);
|
||
|
const isSafe = await einvoice.isPathSafe(normalized);
|
||
|
|
||
|
results.push({
|
||
|
original: encodedPath,
|
||
|
normalized,
|
||
|
safe: isSafe,
|
||
|
blocked: !isSafe
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
original: encodedPath,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
encodingBypass.forEach(result => {
|
||
|
t.ok(result.blocked || !result.safe, `Encoded path traversal blocked: ${result.original.substring(0, 30)}...`);
|
||
|
});
|
||
|
|
||
|
// Test 3: Null byte injection
|
||
|
const nullByteInjection = await performanceTracker.measureAsync(
|
||
|
'null-byte-injection',
|
||
|
async () => {
|
||
|
const nullBytePaths = [
|
||
|
'invoice.pdf\x00.txt',
|
||
|
'report.xml\x00.exe',
|
||
|
'document\x00../../../etc/passwd',
|
||
|
'file.pdf%00.jsp',
|
||
|
'data\u0000../../../../sensitive.dat'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const nullPath of nullBytePaths) {
|
||
|
try {
|
||
|
const cleaned = await einvoice.cleanPath(nullPath);
|
||
|
const hasNullByte = cleaned.includes('\x00') || cleaned.includes('%00');
|
||
|
|
||
|
results.push({
|
||
|
original: nullPath.replace(/\x00/g, '\\x00'),
|
||
|
cleaned,
|
||
|
nullByteRemoved: !hasNullByte,
|
||
|
safe: !hasNullByte && !cleaned.includes('..')
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
original: nullPath.replace(/\x00/g, '\\x00'),
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
nullByteInjection.forEach(result => {
|
||
|
t.ok(result.nullByteRemoved || result.blocked, `Null byte injection prevented: ${result.original}`);
|
||
|
});
|
||
|
|
||
|
// Test 4: Symbolic link attacks
|
||
|
const symlinkAttacks = await performanceTracker.measureAsync(
|
||
|
'symlink-attack-prevention',
|
||
|
async () => {
|
||
|
const symlinkPaths = [
|
||
|
'/tmp/invoice_link -> /etc/passwd',
|
||
|
'C:\\temp\\report.lnk',
|
||
|
'./uploads/../../sensitive/data',
|
||
|
'invoices/current -> /home/user/.ssh/id_rsa'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const linkPath of symlinkPaths) {
|
||
|
try {
|
||
|
const isSymlink = await einvoice.detectSymlink(linkPath);
|
||
|
const followsSymlinks = await einvoice.followsSymlinks();
|
||
|
|
||
|
results.push({
|
||
|
path: linkPath,
|
||
|
isSymlink,
|
||
|
followsSymlinks,
|
||
|
safe: !isSymlink || !followsSymlinks
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
path: linkPath,
|
||
|
safe: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
symlinkAttacks.forEach(result => {
|
||
|
t.ok(result.safe, `Symlink attack prevented: ${result.path}`);
|
||
|
});
|
||
|
|
||
|
// Test 5: Absolute path injection
|
||
|
const absolutePathInjection = await performanceTracker.measureAsync(
|
||
|
'absolute-path-injection',
|
||
|
async () => {
|
||
|
const absolutePaths = [
|
||
|
'/etc/passwd',
|
||
|
'C:\\Windows\\System32\\config\\SAM',
|
||
|
'\\\\server\\share\\sensitive.dat',
|
||
|
'file:///etc/shadow',
|
||
|
os.platform() === 'win32' ? 'C:\\Users\\Admin\\Documents' : '/home/user/.ssh/'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const absPath of absolutePaths) {
|
||
|
try {
|
||
|
const isAllowed = await einvoice.isAbsolutePathAllowed(absPath);
|
||
|
const normalized = await einvoice.normalizeToSafePath(absPath);
|
||
|
|
||
|
results.push({
|
||
|
path: absPath,
|
||
|
allowed: isAllowed,
|
||
|
normalized,
|
||
|
blocked: !isAllowed
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
path: absPath,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
absolutePathInjection.forEach(result => {
|
||
|
t.ok(result.blocked, `Absolute path injection blocked: ${result.path}`);
|
||
|
});
|
||
|
|
||
|
// Test 6: Archive extraction path traversal (Zip Slip)
|
||
|
const zipSlipAttacks = await performanceTracker.measureAsync(
|
||
|
'zip-slip-prevention',
|
||
|
async () => {
|
||
|
const maliciousEntries = [
|
||
|
'../../../../../../tmp/evil.sh',
|
||
|
'../../../.bashrc',
|
||
|
'..\\..\\..\\windows\\system32\\evil.exe',
|
||
|
'invoice/../../../etc/cron.d/backdoor'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const entry of maliciousEntries) {
|
||
|
try {
|
||
|
const safePath = await einvoice.extractToSafePath(entry, '/tmp/safe-extract');
|
||
|
const isWithinBounds = safePath.startsWith('/tmp/safe-extract');
|
||
|
|
||
|
results.push({
|
||
|
entry,
|
||
|
extractedTo: safePath,
|
||
|
safe: isWithinBounds,
|
||
|
blocked: !isWithinBounds
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
entry,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
zipSlipAttacks.forEach(result => {
|
||
|
t.ok(result.safe || result.blocked, `Zip slip attack prevented: ${result.entry}`);
|
||
|
});
|
||
|
|
||
|
// Test 7: UNC path injection (Windows)
|
||
|
const uncPathInjection = await performanceTracker.measureAsync(
|
||
|
'unc-path-injection',
|
||
|
async () => {
|
||
|
const uncPaths = [
|
||
|
'\\\\attacker.com\\share\\payload.exe',
|
||
|
'//attacker.com/share/malware',
|
||
|
'\\\\127.0.0.1\\C$\\Windows\\System32',
|
||
|
'\\\\?\\C:\\Windows\\System32\\drivers\\etc\\hosts'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const uncPath of uncPaths) {
|
||
|
try {
|
||
|
const isUNC = await einvoice.isUNCPath(uncPath);
|
||
|
const blocked = await einvoice.blockUNCPaths(uncPath);
|
||
|
|
||
|
results.push({
|
||
|
path: uncPath,
|
||
|
isUNC,
|
||
|
blocked
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
path: uncPath,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
uncPathInjection.forEach(result => {
|
||
|
if (result.isUNC) {
|
||
|
t.ok(result.blocked, `UNC path blocked: ${result.path}`);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Test 8: Special device files
|
||
|
const deviceFiles = await performanceTracker.measureAsync(
|
||
|
'device-file-prevention',
|
||
|
async () => {
|
||
|
const devices = os.platform() === 'win32'
|
||
|
? ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'LPT1', 'CON.txt', 'PRN.pdf']
|
||
|
: ['/dev/null', '/dev/zero', '/dev/random', '/dev/tty', '/proc/self/environ'];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const device of devices) {
|
||
|
try {
|
||
|
const isDevice = await einvoice.isDeviceFile(device);
|
||
|
const allowed = await einvoice.allowDeviceAccess(device);
|
||
|
|
||
|
results.push({
|
||
|
path: device,
|
||
|
isDevice,
|
||
|
blocked: isDevice && !allowed
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
path: device,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
deviceFiles.forEach(result => {
|
||
|
if (result.isDevice) {
|
||
|
t.ok(result.blocked, `Device file access blocked: ${result.path}`);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// Test 9: Mixed technique attacks
|
||
|
const mixedAttacks = await performanceTracker.measureAsync(
|
||
|
'mixed-technique-attacks',
|
||
|
async () => {
|
||
|
const complexPaths = [
|
||
|
'../%2e%2e/%2e%2e/etc/passwd',
|
||
|
'..\\..\\..%00.pdf',
|
||
|
'/var/www/../../etc/shadow',
|
||
|
'C:../../../windows/system32',
|
||
|
'\\\\?\\..\\..\\..\\windows\\system32',
|
||
|
'invoices/2024/../../../../../../../etc/passwd',
|
||
|
'./valid/../../invalid/../../../etc/hosts'
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const complexPath of complexPaths) {
|
||
|
try {
|
||
|
// Apply all security checks
|
||
|
const normalized = await einvoice.normalizePath(complexPath);
|
||
|
const hasTraversal = normalized.includes('..') || normalized.includes('../');
|
||
|
const hasNullByte = normalized.includes('\x00');
|
||
|
const isAbsolute = path.isAbsolute(normalized);
|
||
|
const isUNC = normalized.startsWith('\\\\') || normalized.startsWith('//');
|
||
|
|
||
|
const safe = !hasTraversal && !hasNullByte && !isAbsolute && !isUNC;
|
||
|
|
||
|
results.push({
|
||
|
original: complexPath,
|
||
|
normalized,
|
||
|
checks: {
|
||
|
hasTraversal,
|
||
|
hasNullByte,
|
||
|
isAbsolute,
|
||
|
isUNC
|
||
|
},
|
||
|
safe,
|
||
|
blocked: !safe
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
original: complexPath,
|
||
|
blocked: true,
|
||
|
error: error.message
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
mixedAttacks.forEach(result => {
|
||
|
t.ok(result.blocked, `Mixed attack technique blocked: ${result.original}`);
|
||
|
});
|
||
|
|
||
|
// Test 10: Real-world scenarios with invoice files
|
||
|
const realWorldScenarios = await performanceTracker.measureAsync(
|
||
|
'real-world-path-scenarios',
|
||
|
async () => {
|
||
|
const scenarios = [
|
||
|
{
|
||
|
description: 'Save invoice to uploads directory',
|
||
|
basePath: '/var/www/uploads',
|
||
|
userInput: 'invoice_2024_001.pdf',
|
||
|
expected: '/var/www/uploads/invoice_2024_001.pdf'
|
||
|
},
|
||
|
{
|
||
|
description: 'Malicious filename in upload',
|
||
|
basePath: '/var/www/uploads',
|
||
|
userInput: '../../../etc/passwd',
|
||
|
expected: 'blocked'
|
||
|
},
|
||
|
{
|
||
|
description: 'Extract attachment from invoice',
|
||
|
basePath: '/tmp/attachments',
|
||
|
userInput: 'attachment_1.xml',
|
||
|
expected: '/tmp/attachments/attachment_1.xml'
|
||
|
},
|
||
|
{
|
||
|
description: 'Malicious attachment path',
|
||
|
basePath: '/tmp/attachments',
|
||
|
userInput: '../../home/user/.ssh/id_rsa',
|
||
|
expected: 'blocked'
|
||
|
}
|
||
|
];
|
||
|
|
||
|
const results = [];
|
||
|
|
||
|
for (const scenario of scenarios) {
|
||
|
try {
|
||
|
const safePath = await einvoice.createSafePath(
|
||
|
scenario.basePath,
|
||
|
scenario.userInput
|
||
|
);
|
||
|
|
||
|
const isWithinBase = safePath.startsWith(scenario.basePath);
|
||
|
const matchesExpected = scenario.expected === 'blocked'
|
||
|
? !isWithinBase
|
||
|
: safePath === scenario.expected;
|
||
|
|
||
|
results.push({
|
||
|
description: scenario.description,
|
||
|
result: safePath,
|
||
|
success: matchesExpected
|
||
|
});
|
||
|
} catch (error) {
|
||
|
results.push({
|
||
|
description: scenario.description,
|
||
|
result: 'blocked',
|
||
|
success: scenario.expected === 'blocked'
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return results;
|
||
|
}
|
||
|
);
|
||
|
|
||
|
realWorldScenarios.forEach(result => {
|
||
|
t.ok(result.success, result.description);
|
||
|
});
|
||
|
|
||
|
// Print performance summary
|
||
|
performanceTracker.printSummary();
|
||
|
});
|
||
|
|
||
|
// Run the test
|
||
|
tap.start();
|