fix: add inbound message rate limiting
This commit is contained in:
+1
-1
@@ -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"
|
||||
"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"
|
||||
},
|
||||
"keywords": [
|
||||
"p2p",
|
||||
|
||||
@@ -1486,6 +1486,47 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated receiver-side limiter. Keep separate from outbound quotas so a
|
||||
* noisy peer cannot consume local send capacity or force decrypt/render work.
|
||||
*/
|
||||
_checkInboundRateLimit(context = 'incoming_message') {
|
||||
const now = Date.now();
|
||||
|
||||
if (!this._inboundRateLimiter) {
|
||||
this._inboundRateLimiter = {
|
||||
messageCount: 0,
|
||||
lastReset: now,
|
||||
burstCount: 0,
|
||||
lastBurstReset: now
|
||||
};
|
||||
}
|
||||
|
||||
if (now - this._inboundRateLimiter.lastReset > 60000) {
|
||||
this._inboundRateLimiter.messageCount = 0;
|
||||
this._inboundRateLimiter.lastReset = now;
|
||||
}
|
||||
|
||||
if (now - this._inboundRateLimiter.lastBurstReset > 1000) {
|
||||
this._inboundRateLimiter.burstCount = 0;
|
||||
this._inboundRateLimiter.lastBurstReset = now;
|
||||
}
|
||||
|
||||
if (this._inboundRateLimiter.burstCount >= this._inputValidationLimits.rateLimitBurstSize) {
|
||||
this._secureLog('warn', '⚠️ Inbound message burst limit exceeded; dropping message', { context });
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._inboundRateLimiter.messageCount >= this._inputValidationLimits.rateLimitMessagesPerMinute) {
|
||||
this._secureLog('warn', '⚠️ Inbound message rate limit exceeded; dropping message', { context });
|
||||
return false;
|
||||
}
|
||||
|
||||
this._inboundRateLimiter.messageCount++;
|
||||
this._inboundRateLimiter.burstCount++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SECURE KEY STORAGE MANAGEMENT
|
||||
// ============================================
|
||||
@@ -6416,6 +6457,9 @@ async processMessage(data) {
|
||||
|
||||
if (parsed.type === 'enhanced_message') {
|
||||
this._secureLog('debug', '🔐 Enhanced message detected in processMessage');
|
||||
if (!this._checkInboundRateLimit('processMessage:enhanced_message')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt enhanced message
|
||||
@@ -6458,6 +6502,9 @@ async processMessage(data) {
|
||||
|
||||
if (parsed.type === 'message') {
|
||||
this._secureLog('debug', '📝 Regular user message detected in processMessage');
|
||||
if (!this._checkInboundRateLimit('processMessage:message')) {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage && parsed.data) {
|
||||
this.deliverMessageToUI(parsed.data, 'received');
|
||||
}
|
||||
@@ -6484,6 +6531,9 @@ async processMessage(data) {
|
||||
|
||||
} catch (jsonError) {
|
||||
// Not JSON — treat as text WITHOUT mutex
|
||||
if (!this._checkInboundRateLimit('processMessage:text')) {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage) {
|
||||
this.deliverMessageToUI(data, 'received');
|
||||
}
|
||||
@@ -7448,6 +7498,9 @@ async processMessage(data) {
|
||||
// ============================================
|
||||
|
||||
if (parsed.type === 'message' && parsed.data) {
|
||||
if (!this._checkInboundRateLimit('dataChannel:message')) {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage) {
|
||||
this.deliverMessageToUI(parsed.data, 'received');
|
||||
}
|
||||
@@ -7466,6 +7519,9 @@ async processMessage(data) {
|
||||
|
||||
} catch (jsonError) {
|
||||
// Not JSON — treat as regular text message
|
||||
if (!this._checkInboundRateLimit('dataChannel:text')) {
|
||||
return;
|
||||
}
|
||||
if (this.onMessage) {
|
||||
this.deliverMessageToUI(event.data, 'received');
|
||||
}
|
||||
@@ -7484,6 +7540,9 @@ async processMessage(data) {
|
||||
// FIX 4: New method for processing binary data WITHOUT mutex
|
||||
async _processBinaryDataWithoutMutex(data) {
|
||||
try {
|
||||
if (!this._checkInboundRateLimit('binary_message')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply security layers WITHOUT mutex
|
||||
let processedData = data;
|
||||
@@ -7546,6 +7605,9 @@ async processMessage(data) {
|
||||
// FIX 3: New method for processing enhanced messages WITHOUT mutex
|
||||
async _processEnhancedMessageWithoutMutex(parsedMessage) {
|
||||
try {
|
||||
if (!this._checkInboundRateLimit('enhanced_message')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.encryptionKey || !this.macKey || !this.metadataKey) {
|
||||
this._secureLog('error', 'Missing encryption keys for enhanced message');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.window = {
|
||||
EnhancedSecureCryptoUtils: {
|
||||
async decryptMessage() {
|
||||
return { message: JSON.stringify({ type: 'message', data: 'enhanced hello' }) };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||||
|
||||
function fakeManager({ perMinute = 60, burst = 10 } = {}) {
|
||||
return {
|
||||
delivered: [],
|
||||
logs: [],
|
||||
_inputValidationLimits: {
|
||||
rateLimitMessagesPerMinute: perMinute,
|
||||
rateLimitBurstSize: burst
|
||||
},
|
||||
_checkInboundRateLimit: EnhancedSecureWebRTCManager.prototype._checkInboundRateLimit,
|
||||
_secureLog(level, message, context) {
|
||||
this.logs.push({ level, message, context });
|
||||
},
|
||||
onMessage() {},
|
||||
deliverMessageToUI(message, type) {
|
||||
this.delivered.push({ message, type });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Normal inbound messages are delivered.
|
||||
{
|
||||
const manager = fakeManager();
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(
|
||||
manager,
|
||||
JSON.stringify({ type: 'message', data: 'hello' })
|
||||
);
|
||||
assert.deepEqual(manager.delivered, [{ message: 'hello', type: 'received' }]);
|
||||
}
|
||||
|
||||
// Burst floods are dropped safely and logged.
|
||||
{
|
||||
const manager = fakeManager({ burst: 1 });
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||
assert.match(manager.logs.at(-1).message, /Inbound message burst limit exceeded/);
|
||||
}
|
||||
|
||||
// Sustained-window floods are rejected independently of burst accounting.
|
||||
{
|
||||
const manager = fakeManager({ perMinute: 1, burst: 10 });
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'first' }));
|
||||
manager._inboundRateLimiter.lastBurstReset = Date.now() - 1001;
|
||||
await EnhancedSecureWebRTCManager.prototype.processMessage.call(manager, JSON.stringify({ type: 'message', data: 'second' }));
|
||||
assert.deepEqual(manager.delivered, [{ message: 'first', type: 'received' }]);
|
||||
assert.match(manager.logs.at(-1).message, /Inbound message rate limit exceeded/);
|
||||
}
|
||||
|
||||
// Binary and enhanced helpers are guarded before expensive processing.
|
||||
{
|
||||
const binaryManager = {
|
||||
...fakeManager({ burst: 0 }),
|
||||
securityFeatures: {
|
||||
hasNestedEncryption: false,
|
||||
hasPacketPadding: false,
|
||||
hasAntiFingerprinting: false
|
||||
}
|
||||
};
|
||||
await EnhancedSecureWebRTCManager.prototype._processBinaryDataWithoutMutex.call(
|
||||
binaryManager,
|
||||
new TextEncoder().encode('binary hello').buffer
|
||||
);
|
||||
assert.deepEqual(binaryManager.delivered, []);
|
||||
|
||||
const enhancedManager = {
|
||||
...fakeManager({ burst: 0 }),
|
||||
encryptionKey: {},
|
||||
macKey: {},
|
||||
metadataKey: {}
|
||||
};
|
||||
await EnhancedSecureWebRTCManager.prototype._processEnhancedMessageWithoutMutex.call(
|
||||
enhancedManager,
|
||||
{ data: 'ciphertext' }
|
||||
);
|
||||
assert.deepEqual(enhancedManager.delivered, []);
|
||||
}
|
||||
|
||||
// Outbound limiter remains a separate state machine.
|
||||
{
|
||||
const manager = {
|
||||
_inputValidationLimits: {
|
||||
rateLimitMessagesPerMinute: 1,
|
||||
rateLimitBurstSize: 1
|
||||
},
|
||||
_secureLog() {},
|
||||
_checkRateLimit: EnhancedSecureWebRTCManager.prototype._checkRateLimit,
|
||||
_checkInboundRateLimit: EnhancedSecureWebRTCManager.prototype._checkInboundRateLimit
|
||||
};
|
||||
assert.equal(manager._checkRateLimit('send'), true);
|
||||
assert.equal(manager._checkInboundRateLimit('receive'), true);
|
||||
assert.notEqual(manager._rateLimiter, manager._inboundRateLimiter);
|
||||
}
|
||||
|
||||
console.log('Inbound message rate limit tests passed');
|
||||
Reference in New Issue
Block a user