From 59a41ec1d6dab0e3e65023925176235704f33375 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 17:37:07 +0530 Subject: [PATCH 1/3] feat: add security load testing script and npm test:load command --- package.json | 3 +- scripts/security-load-test.js | 196 ++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100755 scripts/security-load-test.js diff --git a/package.json b/package.json index 59cf9877..26949271 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cover:unit": "nyc --report-dir .coverage/unit npm run test:unit", "docker:build": "docker build -t nostream .", "pretest:integration": "mkdir -p .test-reports/integration", + "test:load": "node ./scripts/security-load-test.js", "test:integration": "cucumber-js", "cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover", "docker:compose:start": "./scripts/start", @@ -139,4 +140,4 @@ "path": "./node_modules/cz-conventional-changelog" } } -} +} \ No newline at end of file diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js new file mode 100755 index 00000000..5aa579c9 --- /dev/null +++ b/scripts/security-load-test.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node +/** + * security-load-test.js + * + * A generalized load testing and security emulation tool for Nostream. + * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. + * + * Features: + * 1. Zombie Connections: Opens connections, subscibes, and silences pongs. + * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). + * + * Usage: + * node scripts/security-load-test.js [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100] + * + * Alternate (via npm): + * npm run test:load -- --zombies 5000 + */ + +const WebSocket = require('ws'); +const crypto = require('crypto'); +const secp256k1 = require('@noble/secp256k1'); + +// ── CLI Args ───────────────────────────────────────────────────────────────── +const args = process.argv.slice(2).reduce((acc, arg, i, arr) => { + if (arg.startsWith('--')) acc[arg.slice(2)] = arr[i + 1]; + return acc; +}, {}); + +const RELAY_URL = args.url || 'ws://localhost:8008'; +const TOTAL_ZOMBIES = parseInt(args.zombies || '5000', 10); +const SPAM_RATE = parseInt(args['spam-rate'] || '0', 10); +const BATCH_SIZE = 100; +const BATCH_DELAY_MS = 50; + +// ── State ──────────────────────────────────────────────────────────────────── +const zombies = []; +let opened = 0; +let errors = 0; +let subsSent = 0; +let spamSent = 0; + +// ── Shared Helpers ─────────────────────────────────────────────────────────── +function randomHex(bytes = 16) { + return crypto.randomBytes(bytes).toString('hex'); +} + +async function sha256(string) { + const hash = crypto.createHash('sha256').update(string).digest('hex'); + return hash; +} + +// ── Spammer Logic ──────────────────────────────────────────────────────────── +async function createValidEvent(privateKeyHex) { + const pubkey = secp256k1.utils.bytesToHex(secp256k1.schnorr.getPublicKey(privateKeyHex)); + const created_at = Math.floor(Date.now() / 1000); + const kind = 1; + const content = `Load Test Event ${created_at}-${randomHex(4)}`; + + const serialized = JSON.stringify([0, pubkey, created_at, kind, [], content]); + const id = await sha256(serialized); + const sigBytes = await secp256k1.schnorr.sign(id, privateKeyHex); + const sig = secp256k1.utils.bytesToHex(sigBytes); + + return { id, pubkey, created_at, kind, tags: [], content, sig }; +} + +function startSpammer() { + if (SPAM_RATE <= 0) return; + + const ws = new WebSocket(RELAY_URL); + const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()); + const intervalMs = 1000 / SPAM_RATE; + + ws.on('open', () => { + console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`); + setInterval(async () => { + const event = await createValidEvent(spammerPrivKey); + ws.send(JSON.stringify(['EVENT', event])); + spamSent++; + }, intervalMs); + }); + + ws.on('close', () => { + console.log('[SPAMMER] Disconnected. Reconnecting...'); + setTimeout(startSpammer, 1000); + }); + + ws.on('error', () => { }); +} + +// ── Zombie Logic ───────────────────────────────────────────────────────────── +function openZombie() { + return new Promise((resolve) => { + const ws = new WebSocket(RELAY_URL, { + followRedirects: false, + perMessageDeflate: false, + handshakeTimeout: 30000, + }); + + ws.on('open', () => { + opened++; + const subscriptionId = randomHex(8); + ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }])); + subsSent++; + + // Suppress the automatic internal pong handling + if (ws._receiver) { + ws._receiver.removeAllListeners('ping'); + ws._receiver.on('ping', () => { }); + } + ws.pong = function () { }; + + zombies.push(ws); + if (opened % 500 === 0) logProgress(); + resolve(ws); + }); + + ws.on('error', (err) => { + errors++; + resolve(null); + }); + + ws.on('message', () => { }); // Discard broadcast data + }); +} + +function logProgress() { + const mem = process.memoryUsage(); + console.log( + `[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` + + `Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB` + ); +} + +// ── Main Execution ─────────────────────────────────────────────────────────── +async function main() { + console.log('╔══════════════════════════════════════════════════════════════╗'); + console.log('║ NOSTREAM SECURITY LOAD TESTER ║'); + console.log('╠══════════════════════════════════════════════════════════════╣'); + console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`); + console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`); + console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`); + console.log('╚══════════════════════════════════════════════════════════════╝\n'); + + // Launch Zombies + for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) { + const batch = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i); + const promises = Array.from({ length: batch }).map(() => openZombie()); + await Promise.all(promises); + if (i + BATCH_SIZE < TOTAL_ZOMBIES) { + await new Promise(r => setTimeout(r, BATCH_DELAY_MS)); + } + } + + if (TOTAL_ZOMBIES > 0) { + console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`); + } + + // Launch Spammer + if (SPAM_RATE > 0) { + startSpammer(); + } + + // Monitor Output + const statsInterval = setInterval(() => { + const alive = zombies.filter(ws => ws && ws.readyState === WebSocket.OPEN).length; + const closed = zombies.filter(ws => ws && ws.readyState === WebSocket.CLOSED).length; + + console.log( + `[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` + + `Spam Sent: ${spamSent}` + ); + + // Auto-exit if all zombies have been correctly evicted by the server + if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) { + console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!'); + console.log(' The heartbeat memory leak fix is working correctly.'); + process.exit(0); + } + }, 15000); + + // Graceful Teardown + process.on('SIGINT', () => { + console.log('\n[SHUTDOWN] Exiting and closing connections...'); + clearInterval(statsInterval); + for (const ws of zombies) { + if (ws && ws.readyState === WebSocket.OPEN) ws.close(); + } + setTimeout(() => process.exit(0), 1000); + }); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); From 35eaa50c0b9e8a7a6658c7c7ab4e8b136df9f7f3 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 20:15:43 +0530 Subject: [PATCH 2/3] docs: add documentation for security and load testing procedures --- README.md | 45 +++++++++++++++++++++++++++++++++++ scripts/security-load-test.js | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d55d3f0..beb160d8 100644 --- a/README.md +++ b/README.md @@ -570,6 +570,51 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep open .coverage/integration/lcov-report/index.html ``` +## Security & Load Testing + +Nostream includes a specialized security tester to simulate Slowloris-style connection holding and event flood (spam) attacks. This is used to verify relay resilience and prevent memory leaks. + +### Running the Tester + ```bash + # Simulates 5,000 idle "zombie" connections + 100 events/sec spam + npm run test:load -- --zombies 5000 --spam-rate 100 + ``` + +### Analyzing Memory (Heap Snapshots) +To verify that connections are being correctly evicted and memory reclaimed: +1. Ensure the relay is running with `--inspect` enabled (see `docker-compose.yml`). +2. Open **Chrome DevTools** (`chrome://inspect`) and connect to the relay process. +3. In the **Memory** tab, take a **Heap Snapshot** (Baseline). +4. Run the load tester. +5. Wait for the eviction cycle (default: 120s) and take a second **Heap Snapshot**. +6. Switch the view to **Comparison** and select the Baseline snapshot. +7. Verify that object counts (e.g., `WebSocketAdapter`, `SocketAddress`) return to baseline levels. + +### Server-Side Monitoring +To observe client and subscription counts in real-time during a test, you can instrument `src/adapters/web-socket-server-adapter.ts`: + +1. Locate the `onHeartbeat()` method. +2. Add the following logging logic: + ```typescript + private onHeartbeat() { + let totalSubs = 0; + let totalClients = 0; + this.webSocketServer.clients.forEach((webSocket) => { + totalClients++; + const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter; + if (webSocketAdapter) { + webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat); + totalSubs += webSocketAdapter.getSubscriptions().size; + } + }); + console.log(`[HEARTBEAT] Clients: ${totalClients} | Total subscriptions: ${totalSubs} | Heap Used: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)} MB`); + } + ``` +3. View the live output via Docker logs: + ```bash + docker compose logs -f nostream + ``` + ## Configuration You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path. diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js index 5aa579c9..7bdc6721 100755 --- a/scripts/security-load-test.js +++ b/scripts/security-load-test.js @@ -6,7 +6,7 @@ * Simulates a combined Slowloris (Zombie) attack and an Event Flood attack. * * Features: - * 1. Zombie Connections: Opens connections, subscibes, and silences pongs. + * 1. Zombie Connections: Opens connections, subscribes, and silences pongs. * 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1). * * Usage: From 84792e40fdeaaea4edcaa4b1a7f96d79d1048108 Mon Sep 17 00:00:00 2001 From: YashIIT0909 <24je0721@iitism.ac.in> Date: Sat, 11 Apr 2026 21:40:50 +0530 Subject: [PATCH 3/3] feat: enhance CLI argument parsing and spammer logic for improved error handling --- scripts/security-load-test.js | 64 ++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/scripts/security-load-test.js b/scripts/security-load-test.js index 7bdc6721..da742503 100755 --- a/scripts/security-load-test.js +++ b/scripts/security-load-test.js @@ -21,14 +21,41 @@ const crypto = require('crypto'); const secp256k1 = require('@noble/secp256k1'); // ── CLI Args ───────────────────────────────────────────────────────────────── -const args = process.argv.slice(2).reduce((acc, arg, i, arr) => { - if (arg.startsWith('--')) acc[arg.slice(2)] = arr[i + 1]; +function parseCliArgs(argv) { + const acc = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + + const key = arg.slice(2); + const value = argv[i + 1]; + + if (value === undefined || value.startsWith('--')) { + console.error(`Missing value for --${key}`); + process.exit(1); + } + + acc[key] = value; + i++; + } return acc; -}, {}); +} + +function parseIntegerArg(value, defaultValue, flagName) { + if (value === undefined) return defaultValue; + const parsed = parseInt(value, 10); + if (isNaN(parsed)) { + console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`); + process.exit(1); + } + return parsed; +} + +const args = parseCliArgs(process.argv.slice(2)); const RELAY_URL = args.url || 'ws://localhost:8008'; -const TOTAL_ZOMBIES = parseInt(args.zombies || '5000', 10); -const SPAM_RATE = parseInt(args['spam-rate'] || '0', 10); +const TOTAL_ZOMBIES = parseIntegerArg(args.zombies, 5000, 'zombies'); +const SPAM_RATE = parseIntegerArg(args['spam-rate'], 0, 'spam-rate'); const BATCH_SIZE = 100; const BATCH_DELAY_MS = 50; @@ -70,22 +97,38 @@ function startSpammer() { const ws = new WebSocket(RELAY_URL); const spammerPrivKey = secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()); const intervalMs = 1000 / SPAM_RATE; + let spammerInterval = null; + + function clearSpammerInterval() { + if (spammerInterval !== null) { + clearInterval(spammerInterval); + spammerInterval = null; + } + } ws.on('open', () => { console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`); - setInterval(async () => { + clearSpammerInterval(); + spammerInterval = setInterval(async () => { + if (ws.readyState !== WebSocket.OPEN) return; + const event = await createValidEvent(spammerPrivKey); - ws.send(JSON.stringify(['EVENT', event])); - spamSent++; + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(['EVENT', event])); + spamSent++; + } }, intervalMs); }); ws.on('close', () => { + clearSpammerInterval(); console.log('[SPAMMER] Disconnected. Reconnecting...'); setTimeout(startSpammer, 1000); }); - ws.on('error', () => { }); + ws.on('error', () => { + clearSpammerInterval(); + }); } // ── Zombie Logic ───────────────────────────────────────────────────────────── @@ -107,6 +150,8 @@ function openZombie() { if (ws._receiver) { ws._receiver.removeAllListeners('ping'); ws._receiver.on('ping', () => { }); + } else { + console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.'); } ws.pong = function () { }; @@ -117,6 +162,7 @@ function openZombie() { ws.on('error', (err) => { errors++; + ws.terminate(); resolve(null); });