mirror of
https://github.com/DumbWareio/DumbDrop.git
synced 2025-11-15 19:31:25 +00:00
* Enhance IP spoofing protection and proxy trust config Adds secure IP extraction utility to prevent X-Forwarded-For header spoofing, updates rate limiter and authentication to use real client IP, and introduces TRUST_PROXY and TRUSTED_PROXY_IPS configuration options. Documentation and tests updated to reflect new security measures and usage guidance for reverse proxy deployments. * Update package.json
443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
/**
|
|
* Security tests
|
|
* Tests path traversal protection, file extension validation, and other security features
|
|
*/
|
|
|
|
// Disable batch cleanup for tests
|
|
process.env.DISABLE_BATCH_CLEANUP = 'true';
|
|
|
|
const { describe, it, before, after } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const http = require('node:http');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
// Import the app and utilities
|
|
const { app, initialize, config } = require('../src/app');
|
|
const { sanitizeFilenameSafe, sanitizePathPreserveDirsSafe } = require('../src/utils/fileUtils');
|
|
|
|
let server;
|
|
let baseUrl;
|
|
|
|
before(async () => {
|
|
// Initialize app
|
|
await initialize();
|
|
|
|
// Start server on random port
|
|
server = http.createServer(app);
|
|
await new Promise((resolve) => {
|
|
server.listen(0, () => {
|
|
const { port } = server.address();
|
|
baseUrl = `http://localhost:${port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
// Close server
|
|
if (server) {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
// Clean up test files
|
|
try {
|
|
const testFiles = await fs.readdir(config.uploadDir);
|
|
for (const file of testFiles) {
|
|
if (file !== '.metadata') {
|
|
const filePath = path.join(config.uploadDir, file);
|
|
const stat = await fs.stat(filePath);
|
|
if (stat.isFile()) {
|
|
await fs.unlink(filePath);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Helper function to make HTTP requests
|
|
*/
|
|
async function makeRequest(options, body = null) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(options, (res) => {
|
|
let data = '';
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
});
|
|
res.on('end', () => {
|
|
try {
|
|
const parsed = data ? JSON.parse(data) : {};
|
|
resolve({ status: res.statusCode, data: parsed, headers: res.headers });
|
|
} catch {
|
|
resolve({ status: res.statusCode, data, headers: res.headers });
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (body) {
|
|
req.write(JSON.stringify(body));
|
|
}
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
describe('Security Tests', () => {
|
|
describe('Path Traversal Protection', () => {
|
|
it('should block path traversal in file download', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/files/download/../../../etc/passwd',
|
|
method: 'GET',
|
|
});
|
|
|
|
assert.strictEqual(response.status, 403);
|
|
});
|
|
|
|
it('should block path traversal in file info', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/files/info/../../package.json',
|
|
method: 'GET',
|
|
});
|
|
|
|
assert.strictEqual(response.status, 403);
|
|
});
|
|
|
|
it('should block path traversal in file deletion', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/files/../../../important-file.txt',
|
|
method: 'DELETE',
|
|
});
|
|
|
|
assert.strictEqual(response.status, 403);
|
|
});
|
|
|
|
it('should block absolute paths in upload', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/upload/init',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}, {
|
|
filename: '/etc/passwd',
|
|
fileSize: 100,
|
|
});
|
|
|
|
// Should either succeed with sanitized name or reject
|
|
if (response.status === 200) {
|
|
// Verify it was sanitized
|
|
assert.ok(!response.data.uploadId.includes('/etc'));
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Filename Sanitization', () => {
|
|
it('should sanitize dangerous characters', () => {
|
|
const dangerous = '../../../etc/passwd';
|
|
const sanitized = sanitizeFilenameSafe(dangerous);
|
|
|
|
assert.ok(!sanitized.includes('..'));
|
|
assert.ok(!sanitized.includes('/'));
|
|
});
|
|
|
|
it('should handle null bytes', () => {
|
|
const nullByte = 'file\x00.txt';
|
|
const sanitized = sanitizeFilenameSafe(nullByte);
|
|
|
|
assert.ok(!sanitized.includes('\x00'));
|
|
});
|
|
|
|
it('should preserve safe filenames', () => {
|
|
const safe = 'my-file_123.txt';
|
|
const sanitized = sanitizeFilenameSafe(safe);
|
|
|
|
assert.strictEqual(sanitized, safe);
|
|
});
|
|
|
|
it('should handle Unicode characters', () => {
|
|
const unicode = 'файл.txt';
|
|
const sanitized = sanitizeFilenameSafe(unicode);
|
|
|
|
// Should be sanitized to ASCII-safe format
|
|
assert.ok(sanitized.length > 0);
|
|
});
|
|
});
|
|
|
|
describe('File Size Limits', () => {
|
|
it('should reject files exceeding size limit', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/upload/init',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}, {
|
|
filename: 'huge-file.bin',
|
|
fileSize: config.maxFileSize + 1,
|
|
});
|
|
|
|
assert.strictEqual(response.status, 413);
|
|
});
|
|
|
|
it('should accept files within size limit', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/upload/init',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}, {
|
|
filename: 'small-file.txt',
|
|
fileSize: 1024,
|
|
});
|
|
|
|
assert.strictEqual(response.status, 200);
|
|
});
|
|
});
|
|
|
|
describe('Content Type Validation', () => {
|
|
it('should handle various content types safely', async () => {
|
|
const contentTypes = [
|
|
'text/plain',
|
|
'application/json',
|
|
'image/png',
|
|
'application/pdf',
|
|
];
|
|
|
|
for (const contentType of contentTypes) {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/upload/init',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}, {
|
|
filename: `test.${contentType.split('/')[1]}`,
|
|
fileSize: 100,
|
|
});
|
|
|
|
// Should handle all content types (unless restricted by config)
|
|
assert.ok(response.status === 200 || response.status === 400);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Rate Limiting', () => {
|
|
it('should enforce rate limits on repeated requests', async () => {
|
|
// Make multiple rapid requests
|
|
const requests = [];
|
|
for (let i = 0; i < 50; i++) {
|
|
requests.push(
|
|
makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/upload/init',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
}, {
|
|
filename: `test-${i}.txt`,
|
|
fileSize: 100,
|
|
})
|
|
);
|
|
}
|
|
|
|
const responses = await Promise.all(requests);
|
|
|
|
// At least some should be rate limited (429)
|
|
const rateLimited = responses.filter((r) => r.status === 429);
|
|
|
|
// Rate limiting should kick in for excessive requests
|
|
assert.ok(rateLimited.length > 0 || responses[0].status === 200);
|
|
});
|
|
});
|
|
|
|
describe('CORS Protection', () => {
|
|
it('should include CORS headers', async () => {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/files',
|
|
method: 'GET',
|
|
});
|
|
|
|
// CORS headers should be present
|
|
assert.ok(response.headers['access-control-allow-origin'] !== undefined);
|
|
});
|
|
});
|
|
|
|
describe('Path Sanitization Functions', () => {
|
|
it('should sanitize paths while preserving directories', () => {
|
|
const dirPath = 'folder/subfolder/file.txt';
|
|
const sanitized = sanitizePathPreserveDirsSafe(dirPath);
|
|
|
|
// Should preserve structure but sanitize dangerous chars
|
|
assert.ok(!sanitized.includes('..'));
|
|
assert.ok(sanitized.includes('/') || sanitized.length > 0);
|
|
});
|
|
|
|
it('should block directory traversal attempts', () => {
|
|
const malicious = '../../etc/passwd';
|
|
const sanitized = sanitizePathPreserveDirsSafe(malicious);
|
|
|
|
// Should not allow traversal
|
|
assert.ok(!sanitized.startsWith('..'));
|
|
});
|
|
});
|
|
|
|
describe('IP Spoofing Protection', () => {
|
|
it('should not trust X-Forwarded-For header when TRUST_PROXY is false', async () => {
|
|
// Verify config.trustProxy is false by default
|
|
assert.strictEqual(config.trustProxy, false, 'TRUST_PROXY should be false by default');
|
|
|
|
// Make multiple requests with spoofed X-Forwarded-For headers
|
|
const spoofedIps = ['1.2.3.4', '5.6.7.8', '9.10.11.12', '13.14.15.16', '17.18.19.20', '21.22.23.24'];
|
|
const responses = [];
|
|
|
|
for (const spoofedIp of spoofedIps) {
|
|
const response = await makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/auth/verify-pin',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Forwarded-For': spoofedIp,
|
|
},
|
|
}, {
|
|
pin: '9999', // Wrong PIN
|
|
});
|
|
|
|
responses.push(response);
|
|
}
|
|
|
|
// Should be rate limited because all requests come from same real IP
|
|
// (spoofed headers should be ignored)
|
|
const rateLimitedOrLocked = responses.filter(
|
|
(r) => r.status === 429 || (r.status === 401 && r.data.error && r.data.error.includes('locked'))
|
|
);
|
|
|
|
// After 5 failed attempts, should be locked out
|
|
assert.ok(rateLimitedOrLocked.length > 0, 'Rate limiting should apply despite spoofed headers');
|
|
});
|
|
|
|
it('should use socket IP when proxy trust is disabled', () => {
|
|
const { getClientIp } = require('../src/utils/ipExtractor');
|
|
|
|
// Mock request with spoofed X-Forwarded-For
|
|
const mockReq = {
|
|
ip: '192.168.1.100', // This would be from X-Forwarded-For if trusted
|
|
socket: {
|
|
remoteAddress: '::ffff:127.0.0.1', // Real socket IP
|
|
},
|
|
headers: {
|
|
'x-forwarded-for': '192.168.1.100',
|
|
},
|
|
};
|
|
|
|
const extractedIp = getClientIp(mockReq);
|
|
|
|
// Should use socket IP, not req.ip (which comes from X-Forwarded-For when trusted)
|
|
assert.strictEqual(extractedIp, '127.0.0.1', 'Should extract from socket, not trust headers');
|
|
});
|
|
|
|
it('should normalize IPv6-mapped IPv4 addresses', () => {
|
|
const { normalizeIp } = require('../src/utils/ipExtractor');
|
|
|
|
const ipv6Mapped = '::ffff:192.168.1.1';
|
|
const normalized = normalizeIp(ipv6Mapped);
|
|
|
|
assert.strictEqual(normalized, '192.168.1.1', 'Should convert IPv6-mapped to IPv4');
|
|
});
|
|
|
|
it('should validate proxy chain when specific IPs are configured', () => {
|
|
const { validateProxyChain } = require('../src/utils/ipExtractor');
|
|
|
|
const trustedIps = ['172.17.0.1', '10.0.0.1'];
|
|
|
|
// Trusted proxy should pass
|
|
assert.strictEqual(validateProxyChain('172.17.0.1', trustedIps), true);
|
|
assert.strictEqual(validateProxyChain('10.0.0.1', trustedIps), true);
|
|
|
|
// Untrusted proxy should fail
|
|
assert.strictEqual(validateProxyChain('192.168.1.1', trustedIps), false);
|
|
assert.strictEqual(validateProxyChain('8.8.8.8', trustedIps), false);
|
|
});
|
|
|
|
it('should handle IPv6-mapped addresses in proxy validation', () => {
|
|
const { validateProxyChain } = require('../src/utils/ipExtractor');
|
|
|
|
const trustedIps = ['127.0.0.1'];
|
|
|
|
// IPv6-mapped localhost should match
|
|
assert.strictEqual(validateProxyChain('::ffff:127.0.0.1', trustedIps), true);
|
|
});
|
|
|
|
it('should prevent rate limit bypass via header spoofing', async () => {
|
|
// This test verifies the fix for the reported vulnerability
|
|
// Make 6 requests with different X-Forwarded-For headers but same real IP
|
|
const attempts = [];
|
|
|
|
for (let i = 0; i < 6; i++) {
|
|
attempts.push(
|
|
makeRequest({
|
|
host: 'localhost',
|
|
port: server.address().port,
|
|
path: '/api/auth/verify-pin',
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Forwarded-For': `${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
|
|
},
|
|
}, {
|
|
pin: '0000', // Wrong PIN
|
|
})
|
|
);
|
|
|
|
// Small delay between requests to avoid race conditions
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
}
|
|
|
|
const responses = await Promise.all(attempts);
|
|
|
|
// Count failures (401) and rate limits (429)
|
|
const failures = responses.filter(r => r.status === 401);
|
|
const rateLimited = responses.filter(r => r.status === 429);
|
|
|
|
// Should be locked out after 5 attempts, despite spoofed headers
|
|
// Either the 6th request is rate limited (429), or shows lockout message
|
|
const lastResponse = responses[responses.length - 1];
|
|
const isLockedOut =
|
|
lastResponse.status === 429 ||
|
|
(lastResponse.status === 401 && lastResponse.data.error &&
|
|
(lastResponse.data.error.includes('locked') || lastResponse.data.error.includes('Too many')));
|
|
|
|
assert.ok(
|
|
failures.length >= 5 || rateLimited.length > 0 || isLockedOut,
|
|
'Should enforce rate limiting despite header spoofing attempts'
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|