fix: throttle inbound file chunks
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"dev": "npm run build && python -m http.server 8000",
|
"dev": "npm run build && python -m http.server 8000",
|
||||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||||
"serve": "npx http-server -p 8000",
|
"serve": "npx http-server -p 8000",
|
||||||
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs"
|
"test": "node tests/sas-verification.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"p2p",
|
"p2p",
|
||||||
|
|||||||
@@ -336,6 +336,9 @@ class EnhancedSecureFileTransfer {
|
|||||||
this.transferQueue = []; // Queue for pending transfers
|
this.transferQueue = []; // Queue for pending transfers
|
||||||
this.pendingChunks = new Map();
|
this.pendingChunks = new Map();
|
||||||
this.incomingOfferLimiter = new RateLimiter(5, 60000);
|
this.incomingOfferLimiter = new RateLimiter(5, 60000);
|
||||||
|
this.incomingChunkLimiter = new RateLimiter(240, 60000);
|
||||||
|
this.incomingTransferChunkLimiters = new Map();
|
||||||
|
this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 120;
|
||||||
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
||||||
|
|
||||||
// Session key derivation
|
// Session key derivation
|
||||||
@@ -1225,6 +1228,12 @@ class EnhancedSecureFileTransfer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this._isIncomingChunkAllowed(chunkMessage.fileId)) {
|
||||||
|
console.warn('⚠️ Incoming file chunk rate limit exceeded; cleaning up transfer:', chunkMessage.fileId);
|
||||||
|
this.cleanupReceivingTransfer(chunkMessage.fileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update last chunk time
|
// Update last chunk time
|
||||||
receivingState.lastChunkTime = Date.now();
|
receivingState.lastChunkTime = Date.now();
|
||||||
|
|
||||||
@@ -1310,6 +1319,35 @@ class EnhancedSecureFileTransfer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isIncomingChunkAllowed(fileId) {
|
||||||
|
const clientId = this.getClientIdentifier();
|
||||||
|
if (!this.incomingChunkLimiter.isAllowed(clientId)) {
|
||||||
|
SecurityErrorHandler.logSecurityEvent('incoming_chunk_aggregate_rate_limit_exceeded', {
|
||||||
|
clientId,
|
||||||
|
fileId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.incomingTransferChunkLimiters.has(fileId)) {
|
||||||
|
this.incomingTransferChunkLimiters.set(
|
||||||
|
fileId,
|
||||||
|
new RateLimiter(this.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE, 60000)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferLimiter = this.incomingTransferChunkLimiters.get(fileId);
|
||||||
|
if (!transferLimiter.isAllowed(fileId)) {
|
||||||
|
SecurityErrorHandler.logSecurityEvent('incoming_chunk_transfer_rate_limit_exceeded', {
|
||||||
|
clientId,
|
||||||
|
fileId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async assembleFile(receivingState) {
|
async assembleFile(receivingState) {
|
||||||
try {
|
try {
|
||||||
receivingState.status = 'assembling';
|
receivingState.status = 'assembling';
|
||||||
@@ -1651,6 +1689,7 @@ class EnhancedSecureFileTransfer {
|
|||||||
this.activeTransfers.delete(fileId);
|
this.activeTransfers.delete(fileId);
|
||||||
this.sessionKeys.delete(fileId);
|
this.sessionKeys.delete(fileId);
|
||||||
this.transferNonces.delete(fileId);
|
this.transferNonces.delete(fileId);
|
||||||
|
this.incomingTransferChunkLimiters.delete(fileId);
|
||||||
|
|
||||||
// Remove processed chunk IDs for this transfer
|
// Remove processed chunk IDs for this transfer
|
||||||
for (const chunkId of this.processedChunks) {
|
for (const chunkId of this.processedChunks) {
|
||||||
@@ -1751,6 +1790,7 @@ class EnhancedSecureFileTransfer {
|
|||||||
// Удаляем из основных коллекций
|
// Удаляем из основных коллекций
|
||||||
this.receivingTransfers.delete(fileId);
|
this.receivingTransfers.delete(fileId);
|
||||||
this.sessionKeys.delete(fileId);
|
this.sessionKeys.delete(fileId);
|
||||||
|
this.incomingTransferChunkLimiters.delete(fileId);
|
||||||
|
|
||||||
// ✅ БЕЗОПАСНАЯ очистка финального буфера файла
|
// ✅ БЕЗОПАСНАЯ очистка финального буфера файла
|
||||||
const fileBuffer = this.receivedFileBuffers.get(fileId);
|
const fileBuffer = this.receivedFileBuffers.get(fileId);
|
||||||
@@ -1903,6 +1943,10 @@ class EnhancedSecureFileTransfer {
|
|||||||
if (this.rateLimiter) {
|
if (this.rateLimiter) {
|
||||||
this.rateLimiter.requests.clear();
|
this.rateLimiter.requests.clear();
|
||||||
}
|
}
|
||||||
|
if (this.incomingChunkLimiter) {
|
||||||
|
this.incomingChunkLimiter.requests.clear();
|
||||||
|
}
|
||||||
|
this.incomingTransferChunkLimiters.clear();
|
||||||
|
|
||||||
// Clear all state
|
// Clear all state
|
||||||
this.pendingChunks.clear();
|
this.pendingChunks.clear();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EnhancedSecureFileTransfer } from '../src/transfer/EnhancedSecureFileTransfer.js';
|
||||||
|
|
||||||
|
function createSystem() {
|
||||||
|
const manager = {
|
||||||
|
dataChannel: { onmessage: null, send() {}, readyState: 'open' },
|
||||||
|
isVerified: true,
|
||||||
|
fileTransferSystem: null,
|
||||||
|
isConnected: () => true,
|
||||||
|
connectionId: 'peer-1'
|
||||||
|
};
|
||||||
|
const system = new EnhancedSecureFileTransfer(manager);
|
||||||
|
system.sendSecureMessage = async () => {};
|
||||||
|
system._storeReceivedFileBuffer = () => {};
|
||||||
|
system.calculateFileHashFromData = async () => 'hash';
|
||||||
|
return system;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReceivingState(fileId = 'file_1', totalChunks = 10) {
|
||||||
|
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 128 }, false, ['encrypt', 'decrypt']);
|
||||||
|
return {
|
||||||
|
fileId,
|
||||||
|
fileName: 'report.pdf',
|
||||||
|
fileSize: totalChunks,
|
||||||
|
fileType: 'application/pdf',
|
||||||
|
fileHash: 'hash',
|
||||||
|
totalChunks,
|
||||||
|
receivedChunks: new Map(),
|
||||||
|
receivedCount: 0,
|
||||||
|
sessionKey: key,
|
||||||
|
salt: new Array(32).fill(1),
|
||||||
|
startTime: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptedChunk(system, receivingState, chunkIndex) {
|
||||||
|
const nonce = new Uint8Array(12);
|
||||||
|
nonce[11] = chunkIndex;
|
||||||
|
const plaintext = new Uint8Array([chunkIndex + 1]);
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: nonce },
|
||||||
|
receivingState.sessionKey,
|
||||||
|
plaintext
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fileId: receivingState.fileId,
|
||||||
|
chunkIndex,
|
||||||
|
nonce: Array.from(nonce),
|
||||||
|
encryptedData: Array.from(new Uint8Array(encrypted)),
|
||||||
|
chunkSize: plaintext.byteLength
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal transfer pace is accepted.
|
||||||
|
{
|
||||||
|
const system = createSystem();
|
||||||
|
const state = await createReceivingState('normal');
|
||||||
|
system.receivingTransfers.set(state.fileId, state);
|
||||||
|
await system.handleFileChunk(await encryptedChunk(system, state, 0));
|
||||||
|
assert.equal(state.receivedCount, 1);
|
||||||
|
assert.equal(system.receivingTransfers.has(state.fileId), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-transfer floods are rejected and cleaned up.
|
||||||
|
{
|
||||||
|
const system = createSystem();
|
||||||
|
system.MAX_INCOMING_CHUNKS_PER_TRANSFER_PER_MINUTE = 1;
|
||||||
|
const state = await createReceivingState('per-transfer');
|
||||||
|
system.receivingTransfers.set(state.fileId, state);
|
||||||
|
await system.handleFileChunk(await encryptedChunk(system, state, 0));
|
||||||
|
await system.handleFileChunk(await encryptedChunk(system, state, 1));
|
||||||
|
assert.equal(system.receivingTransfers.has(state.fileId), false);
|
||||||
|
assert.equal(system.incomingTransferChunkLimiters.has(state.fileId), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate floods across transfers are rejected and clean only the affected transfer.
|
||||||
|
{
|
||||||
|
const system = createSystem();
|
||||||
|
system.incomingChunkLimiter.maxRequests = 1;
|
||||||
|
const first = await createReceivingState('aggregate-a');
|
||||||
|
const second = await createReceivingState('aggregate-b');
|
||||||
|
system.receivingTransfers.set(first.fileId, first);
|
||||||
|
system.receivingTransfers.set(second.fileId, second);
|
||||||
|
await system.handleFileChunk(await encryptedChunk(system, first, 0));
|
||||||
|
await system.handleFileChunk(await encryptedChunk(system, second, 0));
|
||||||
|
assert.equal(system.receivingTransfers.has(first.fileId), true);
|
||||||
|
assert.equal(system.receivingTransfers.has(second.fileId), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consent flow still rejects pre-acceptance chunks without allocating buffers.
|
||||||
|
{
|
||||||
|
const system = createSystem();
|
||||||
|
await system.handleFileChunk({ fileId: 'not-accepted', chunkIndex: 0 });
|
||||||
|
assert.equal(system.pendingChunks.size, 0);
|
||||||
|
assert.equal(system.incomingTransferChunkLimiters.has('not-accepted'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('File transfer chunk rate limit tests passed');
|
||||||
Reference in New Issue
Block a user