Skip to content

Commit 27d8f8a

Browse files
YashIIT0909cameri
andauthored
feat: add security load test and regression tooling (#462)
* feat: add security load testing script and npm test:load command * docs: add documentation for security and load testing procedures * feat: enhance CLI argument parsing and spammer logic for improved error handling * refactor: migrate security load test script from JavaScript to TypeScript --------- Co-authored-by: Ricardo Cabral <me@ricardocabral.io>
1 parent ab3abc2 commit 27d8f8a

3 files changed

Lines changed: 340 additions & 1 deletion

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,52 @@ To see the integration test coverage report open `.coverage/integration/lcov-rep
581581
open .coverage/integration/lcov-report/index.html
582582
```
583583
584+
585+
## Security & Load Testing
586+
587+
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.
588+
589+
### Running the Tester
590+
```bash
591+
# Simulates 5,000 idle "zombie" connections + 100 events/sec spam
592+
npm run test:load -- --zombies 5000 --spam-rate 100
593+
```
594+
595+
### Analyzing Memory (Heap Snapshots)
596+
To verify that connections are being correctly evicted and memory reclaimed:
597+
1. Ensure the relay is running with `--inspect` enabled (see `docker-compose.yml`).
598+
2. Open **Chrome DevTools** (`chrome://inspect`) and connect to the relay process.
599+
3. In the **Memory** tab, take a **Heap Snapshot** (Baseline).
600+
4. Run the load tester.
601+
5. Wait for the eviction cycle (default: 120s) and take a second **Heap Snapshot**.
602+
6. Switch the view to **Comparison** and select the Baseline snapshot.
603+
7. Verify that object counts (e.g., `WebSocketAdapter`, `SocketAddress`) return to baseline levels.
604+
605+
### Server-Side Monitoring
606+
To observe client and subscription counts in real-time during a test, you can instrument `src/adapters/web-socket-server-adapter.ts`:
607+
608+
1. Locate the `onHeartbeat()` method.
609+
2. Add the following logging logic:
610+
```typescript
611+
private onHeartbeat() {
612+
let totalSubs = 0;
613+
let totalClients = 0;
614+
this.webSocketServer.clients.forEach((webSocket) => {
615+
totalClients++;
616+
const webSocketAdapter = this.webSocketsAdapters.get(webSocket) as IWebSocketAdapter;
617+
if (webSocketAdapter) {
618+
webSocketAdapter.emit(WebSocketAdapterEvent.Heartbeat);
619+
totalSubs += webSocketAdapter.getSubscriptions().size;
620+
}
621+
});
622+
console.log(`[HEARTBEAT] Clients: ${totalClients} | Total subscriptions: ${totalSubs} | Heap Used: ${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(1)} MB`);
623+
}
624+
```
625+
3. View the live output via Docker logs:
626+
```bash
627+
docker compose logs -f nostream
628+
```
629+
=======
584630
## Export Events
585631

586632
Export all stored events to a [JSON Lines](https://jsonlines.org/) (`.jsonl`) file. Each line is a valid NIP-01 Nostr event JSON object. The export streams rows from the database using cursors, so it works safely on relays with millions of events without loading them into memory.
@@ -629,6 +675,7 @@ Delete only selected kinds older than N days:
629675
By default, the script asks for explicit confirmation (`Type 'DELETE' to confirm`).
630676
Use `--force` to skip the prompt.
631677

678+
632679
## Configuration
633680

634681
You can change the default folder by setting the `NOSTR_CONFIG_DIR` environment variable to a different path.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"cover:unit": "nyc --report-dir .coverage/unit npm run test:unit",
4949
"docker:build": "docker build -t nostream .",
5050
"pretest:integration": "mkdir -p .test-reports/integration",
51+
"test:load": "node -r ts-node/register ./scripts/security-load-test.ts",
5152
"test:integration": "cucumber-js",
5253
"cover:integration": "nyc --report-dir .coverage/integration npm run test:integration -- -p cover",
5354
"export": "node -r ts-node/register src/scripts/export-events.ts",
@@ -149,4 +150,4 @@
149150
"overrides": {
150151
"axios@<0.31.0": ">=0.31.0"
151152
}
152-
}
153+
}

scripts/security-load-test.ts

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
#!/usr/bin/env node
2+
/**
3+
* security-load-test.ts
4+
*
5+
* A generalized load testing and security emulation tool for Nostream.
6+
* Simulates a combined Slowloris (Zombie) attack and an Event Flood attack.
7+
*
8+
* Features:
9+
* 1. Zombie Connections: Opens connections, subscribes, and silences pongs.
10+
* 2. Active Spammer: Generates and publishes valid NOSTR events (signed via secp256k1).
11+
*
12+
* Usage:
13+
* npx ts-node scripts/security-load-test.ts [--url ws://localhost:8008] [--zombies 5000] [--spam-rate 100]
14+
*
15+
* Alternate (via npm):
16+
* npm run test:load -- --zombies 5000
17+
*/
18+
19+
import WebSocket from 'ws'
20+
import * as crypto from 'crypto'
21+
import * as secp256k1 from '@noble/secp256k1'
22+
23+
// ── Types ─────────────────────────────────────────────────────────────────────
24+
25+
/** Parsed key-value map from CLI --flag value pairs. */
26+
type CliArgs = Record<string, string>
27+
28+
/** A valid serialised Nostr event (NIP-01). */
29+
interface NostrEvent {
30+
id: string
31+
pubkey: string
32+
created_at: number
33+
kind: number
34+
tags: string[][]
35+
content: string
36+
sig: string
37+
}
38+
39+
/**
40+
* The `ws` package exposes a private `_receiver` property on WebSocket
41+
* instances that is used internally for frame parsing and ping/pong handling.
42+
* We cast to this interface to suppress pong responses in zombie connections.
43+
*/
44+
interface WebSocketWithReceiver extends WebSocket {
45+
_receiver?: {
46+
removeAllListeners(event: string): void
47+
on(event: string, listener: () => void): void
48+
}
49+
/** Override the built-in pong helper to become a no-op. */
50+
pong: (...args: unknown[]) => void
51+
}
52+
53+
// ── CLI Args ─────────────────────────────────────────────────────────────────
54+
55+
function parseCliArgs(argv: string[]): CliArgs {
56+
const acc: CliArgs = {}
57+
for (let i = 0; i < argv.length; i++) {
58+
const arg = argv[i]
59+
if (!arg.startsWith('--')) continue
60+
61+
const key: string = arg.slice(2)
62+
const value: string | undefined = argv[i + 1]
63+
64+
if (value === undefined || value.startsWith('--')) {
65+
console.error(`Missing value for --${key}`)
66+
process.exit(1)
67+
}
68+
69+
acc[key] = value
70+
i++
71+
}
72+
return acc
73+
}
74+
75+
function parseIntegerArg(
76+
value: string | undefined,
77+
defaultValue: number,
78+
flagName: string,
79+
): number {
80+
if (value === undefined) return defaultValue
81+
const parsed = parseInt(value, 10)
82+
if (isNaN(parsed)) {
83+
console.error(`Invalid value for --${flagName}: ${value}. Expected an integer.`)
84+
process.exit(1)
85+
}
86+
return parsed
87+
}
88+
89+
const args: CliArgs = parseCliArgs(process.argv.slice(2))
90+
91+
const RELAY_URL: string = args.url ?? 'ws://localhost:8008'
92+
const TOTAL_ZOMBIES: number = parseIntegerArg(args.zombies, 5000, 'zombies')
93+
const SPAM_RATE: number = parseIntegerArg(args['spam-rate'], 0, 'spam-rate')
94+
const BATCH_SIZE: number = 100
95+
const BATCH_DELAY_MS: number = 50
96+
97+
// ── State ────────────────────────────────────────────────────────────────────
98+
99+
const zombies: WebSocketWithReceiver[] = []
100+
let opened: number = 0
101+
let errors: number = 0
102+
let subsSent: number = 0
103+
let spamSent: number = 0
104+
105+
// ── Shared Helpers ───────────────────────────────────────────────────────────
106+
107+
function randomHex(bytes: number = 16): string {
108+
return crypto.randomBytes(bytes).toString('hex')
109+
}
110+
111+
async function sha256(input: string): Promise<string> {
112+
return crypto.createHash('sha256').update(input).digest('hex')
113+
}
114+
115+
// ── Spammer Logic ────────────────────────────────────────────────────────────
116+
117+
async function createValidEvent(privateKeyHex: string): Promise<NostrEvent> {
118+
const pubkey: string = secp256k1.utils.bytesToHex(
119+
secp256k1.schnorr.getPublicKey(privateKeyHex),
120+
)
121+
const created_at: number = Math.floor(Date.now() / 1000)
122+
const kind: number = 1
123+
const content: string = `Load Test Event ${created_at}-${randomHex(4)}`
124+
125+
const serialized: string = JSON.stringify([0, pubkey, created_at, kind, [], content])
126+
const id: string = await sha256(serialized)
127+
const sigBytes: Uint8Array = await secp256k1.schnorr.sign(id, privateKeyHex)
128+
const sig: string = secp256k1.utils.bytesToHex(sigBytes)
129+
130+
return { id, pubkey, created_at, kind, tags: [], content, sig }
131+
}
132+
133+
function startSpammer(): void {
134+
if (SPAM_RATE <= 0) return
135+
136+
const ws = new WebSocket(RELAY_URL)
137+
const spammerPrivKey: string = secp256k1.utils.bytesToHex(
138+
secp256k1.utils.randomPrivateKey(),
139+
)
140+
const intervalMs: number = 1000 / SPAM_RATE
141+
let spammerInterval: ReturnType<typeof setInterval> | null = null
142+
143+
function clearSpammerInterval(): void {
144+
if (spammerInterval !== null) {
145+
clearInterval(spammerInterval)
146+
spammerInterval = null
147+
}
148+
}
149+
150+
ws.on('open', () => {
151+
console.log(`\n[SPAMMER] Connected. Flooding ${SPAM_RATE} events/sec...`)
152+
clearSpammerInterval()
153+
spammerInterval = setInterval(async () => {
154+
if (ws.readyState !== WebSocket.OPEN) return
155+
156+
const event: NostrEvent = await createValidEvent(spammerPrivKey)
157+
if (ws.readyState === WebSocket.OPEN) {
158+
ws.send(JSON.stringify(['EVENT', event]))
159+
spamSent++
160+
}
161+
}, intervalMs)
162+
})
163+
164+
ws.on('close', () => {
165+
clearSpammerInterval()
166+
console.log('[SPAMMER] Disconnected. Reconnecting...')
167+
setTimeout(startSpammer, 1000)
168+
})
169+
170+
ws.on('error', () => {
171+
clearSpammerInterval()
172+
})
173+
}
174+
175+
// ── Zombie Logic ─────────────────────────────────────────────────────────────
176+
177+
function openZombie(): Promise<WebSocketWithReceiver | null> {
178+
return new Promise((resolve) => {
179+
const ws = new WebSocket(RELAY_URL, {
180+
followRedirects: false,
181+
perMessageDeflate: false,
182+
handshakeTimeout: 30000,
183+
}) as WebSocketWithReceiver
184+
185+
ws.on('open', () => {
186+
opened++
187+
const subscriptionId: string = randomHex(8)
188+
ws.send(JSON.stringify(['REQ', subscriptionId, { kinds: [1], limit: 1 }]))
189+
subsSent++
190+
191+
// Suppress the automatic internal pong handling
192+
if (ws._receiver) {
193+
ws._receiver.removeAllListeners('ping')
194+
ws._receiver.on('ping', () => { })
195+
} else {
196+
console.warn('[ZOMBIES] Warning: ws._receiver not found. Pong suppression might fail.')
197+
}
198+
ws.pong = function (): void { }
199+
200+
zombies.push(ws)
201+
if (opened % 500 === 0) logProgress()
202+
resolve(ws)
203+
})
204+
205+
ws.on('error', (_err: Error) => {
206+
errors++
207+
ws.terminate()
208+
resolve(null)
209+
})
210+
211+
ws.on('message', () => { }) // Discard broadcast data
212+
})
213+
}
214+
215+
function logProgress(): void {
216+
const mem: NodeJS.MemoryUsage = process.memoryUsage()
217+
console.log(
218+
`[ZOMBIES] Opened: ${opened}/${TOTAL_ZOMBIES} | ` +
219+
`Client RSS: ${(mem.rss / 1024 / 1024).toFixed(1)} MB`,
220+
)
221+
}
222+
223+
// ── Main Execution ───────────────────────────────────────────────────────────
224+
225+
async function main(): Promise<void> {
226+
console.log('╔══════════════════════════════════════════════════════════════╗')
227+
console.log('║ NOSTREAM SECURITY LOAD TESTER ║')
228+
console.log('╠══════════════════════════════════════════════════════════════╣')
229+
console.log(`║ Target: ${RELAY_URL.padEnd(46)}║`)
230+
console.log(`║ Zombies: ${String(TOTAL_ZOMBIES).padEnd(46)}║`)
231+
console.log(`║ Spam Rate: ${String(SPAM_RATE).padEnd(41)}eps ║`)
232+
console.log('╚══════════════════════════════════════════════════════════════╝\n')
233+
234+
// Launch Zombies
235+
for (let i = 0; i < TOTAL_ZOMBIES; i += BATCH_SIZE) {
236+
const batch: number = Math.min(BATCH_SIZE, TOTAL_ZOMBIES - i)
237+
const promises: Promise<WebSocketWithReceiver | null>[] = Array.from({ length: batch }).map(
238+
() => openZombie(),
239+
)
240+
await Promise.all(promises)
241+
if (i + BATCH_SIZE < TOTAL_ZOMBIES) {
242+
await new Promise<void>((r) => setTimeout(r, BATCH_DELAY_MS))
243+
}
244+
}
245+
246+
if (TOTAL_ZOMBIES > 0) {
247+
console.log(`\n✅ Finished generating ${TOTAL_ZOMBIES} zombies.`)
248+
}
249+
250+
// Launch Spammer
251+
if (SPAM_RATE > 0) {
252+
startSpammer()
253+
}
254+
255+
// Monitor Output
256+
const statsInterval: ReturnType<typeof setInterval> = setInterval(() => {
257+
const alive: number = zombies.filter(
258+
(ws) => ws && ws.readyState === WebSocket.OPEN,
259+
).length
260+
const closed: number = zombies.filter(
261+
(ws) => ws && ws.readyState === WebSocket.CLOSED,
262+
).length
263+
264+
console.log(
265+
`[STATS] Zombies Alive: ${alive} | Closed: ${closed} | ` +
266+
`Spam Sent: ${spamSent}`,
267+
)
268+
269+
// Auto-exit if all zombies have been correctly evicted by the server
270+
if (TOTAL_ZOMBIES > 0 && closed > 0 && alive === 0) {
271+
console.log('\n✅ ALL ZOMBIES WERE EVICTED BY THE SERVER!')
272+
console.log(' The heartbeat memory leak fix is working correctly.')
273+
process.exit(0)
274+
}
275+
}, 15000)
276+
277+
// Graceful Teardown
278+
process.on('SIGINT', () => {
279+
console.log('\n[SHUTDOWN] Exiting and closing connections...')
280+
clearInterval(statsInterval)
281+
for (const ws of zombies) {
282+
if (ws && ws.readyState === WebSocket.OPEN) ws.close()
283+
}
284+
setTimeout(() => process.exit(0), 1000)
285+
})
286+
}
287+
288+
main().catch((err: unknown) => {
289+
console.error('Fatal error:', err)
290+
process.exit(1)
291+
})

0 commit comments

Comments
 (0)