diff --git a/package.json b/package.json index f5d29f2..e7d788c 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "npm run build && python -m http.server 8000", "watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch", "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": [ "p2p", diff --git a/src/transfer/EnhancedSecureFileTransfer.js b/src/transfer/EnhancedSecureFileTransfer.js index 38526f0..19be152 100644 --- a/src/transfer/EnhancedSecureFileTransfer.js +++ b/src/transfer/EnhancedSecureFileTransfer.js @@ -336,6 +336,9 @@ class EnhancedSecureFileTransfer { this.transferQueue = []; // Queue for pending transfers this.pendingChunks = new Map(); 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; // Session key derivation @@ -1224,6 +1227,12 @@ class EnhancedSecureFileTransfer { if (!receivingState) { 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 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) { try { receivingState.status = 'assembling'; @@ -1651,6 +1689,7 @@ class EnhancedSecureFileTransfer { this.activeTransfers.delete(fileId); this.sessionKeys.delete(fileId); this.transferNonces.delete(fileId); + this.incomingTransferChunkLimiters.delete(fileId); // Remove processed chunk IDs for this transfer for (const chunkId of this.processedChunks) { @@ -1751,6 +1790,7 @@ class EnhancedSecureFileTransfer { // Удаляем из основных коллекций this.receivingTransfers.delete(fileId); this.sessionKeys.delete(fileId); + this.incomingTransferChunkLimiters.delete(fileId); // ✅ БЕЗОПАСНАЯ очистка финального буфера файла const fileBuffer = this.receivedFileBuffers.get(fileId); @@ -1903,6 +1943,10 @@ class EnhancedSecureFileTransfer { if (this.rateLimiter) { this.rateLimiter.requests.clear(); } + if (this.incomingChunkLimiter) { + this.incomingChunkLimiter.requests.clear(); + } + this.incomingTransferChunkLimiters.clear(); // Clear all state this.pendingChunks.clear(); diff --git a/tests/file-transfer-chunk-rate-limit.test.mjs b/tests/file-transfer-chunk-rate-limit.test.mjs new file mode 100644 index 0000000..eae841a --- /dev/null +++ b/tests/file-transfer-chunk-rate-limit.test.mjs @@ -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');