fix: throttle inbound file chunks

This commit is contained in:
lockbitchat
2026-05-17 23:05:43 -04:00
parent 0fbcc240be
commit a04a70eb97
3 changed files with 143 additions and 1 deletions
+1 -1
View File
@@ -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
@@ -1224,6 +1227,12 @@ class EnhancedSecureFileTransfer {
if (!receivingState) { if (!receivingState) {
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');