What this module covers: A blockchain indexer processing 50,000 events/second is a valuable target. An attacker who can send one malicious payload that blocks the event loop for 30 seconds can effectively take the service offline. A ReDoS attack requires only an HTTP request. A memory exhaustion attack requires only a cleverly crafted JSON payload. This module covers the exact attack surfaces in high-throughput Node.js applications, the defensive patterns that prevent them, and the runbooks your team needs written down before the incident happens — because the worst time to write a runbook is during an outage.
ReDoS: Regular Expression Denial of Service
ReDoS exploits catastrophic backtracking in certain regular expression patterns. When these patterns are given carefully crafted input, the regex engine's backtracking algorithm takes exponential time.
The Vulnerable Pattern
javascript
Identifying Vulnerable Patterns
Vulnerable patterns share common characteristics:
Nested quantifiers: (a+)+, (a|aa)+
Alternation with common prefixes: (abc|abcd)+
Overlapping quantifiers: (\w+\s*)+
javascript
Safe Validation Patterns
javascript
Runtime Protection: statement_timeout for Regex
For regexes you cannot replace, enforce time limits:
javascript
JSON Payload Bombs: Memory Exhaustion via Deserialization
A crafted JSON payload can expand exponentially in memory after parsing.
javascript
Defense: Request Size Limits and Depth Limits
javascript
Schema-First Validation: Reject Before Parsing
The most effective defense: use ajv's compiled schema to validate structure before your application code runs.
javascript
Event Loop Blocking Attacks
Any synchronous operation on the event loop is an attack surface: if an attacker can cause your code to execute a long synchronous operation, the entire service is blocked.
JSON.parse on Large Payloads
Even with size limits, a 1MB JSON payload takes ~8ms to parse synchronously. At 50K req/sec, if even 1% of requests are 1MB payloads: 500 req/sec × 8ms = 4,000ms/sec of blocking — event loop ELU 400% (impossible — starvation).
javascript
Synchronous Crypto in Hot Paths
javascript
Circuit Breakers: Preventing Cascade Failures
When an upstream service (database, external API, blockchain RPC node) becomes slow or unavailable, requests back up — each waiting for a timeout. This cascades: slow upstream → slow application → slow everything else → OOM.
A circuit breaker short-circuits failing calls immediately after a failure threshold, giving the upstream time to recover.
javascript
Bulkhead Pattern: Isolating Failure Domains
If your service makes calls to multiple upstream services, a slow upstream should not exhaust the connection pool for all upstreams.
javascript
The Five Runbooks
Every team running Node.js in production needs these five runbooks written before they need them.
Runbook 1: Event Loop Saturation
Symptoms: ELU > 0.90, high latency, db_pool_waiting_count > 0 but CPU looks idle.
bash
Runbook 2: Memory Leak
Symptoms: Heap memory growing continuously over hours, GC running but memory not dropping.
bash
Runbook 3: Database Connection Pool Exhaustion
Symptoms:db_pool_waiting_count > 0, P99 latency spike to connectionTimeoutMillis.
Exact versions in production. npm audit. Node.js Permission Model to restrict runtime capabilities.
The system is secure and resilient. The remaining modules cover the advanced Node.js features that eliminate entire categories of deployment, security, and performance problems: zero-trust runtime isolation, single executable deployment, native Rust integration, the Web Standards shift, and automated post-mortem diagnostics.
Next: Module 16 — Zero-Trust Runtime Architecture & The Node.js Permission Model →
Knowledge Check
A Node.js application receives a massive JSON payload with a deeply nested structure (e.g., hundreds of levels of nesting). Which of the following is the most robust, schema-first defense mechanism against memory exhaustion (JSON bombs) before the application logic even runs?
How does the Bulkhead pattern differ from a Circuit Breaker when designing resilient Node.js services?
During an incident, the Event Loop Utilization (ELU) spikes above 0.90, latency is high, but CPU usage appears idle. Following the runbook, what is the most appropriate next step to diagnose the root cause?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// This looks harmless:const emailValidator =/^([a-zA-Z0-9])(([a-zA-Z0-9])|(\.))+@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/;// Input that causes catastrophic backtracking:const maliciousInput ='aaaaaaaaaaaaaaaaaaaaaaaaaaaa@';// The regex engine tries all possible ways to match the repeating groups// before concluding there's no match — exponential time
// VULNERABLE patterns (never use in hot paths with untrusted input):/^(a+)+$/// nested quantifier/^([a-z]+)*$/// nested quantifier with alternation/^(.*)(foo)(.*)(bar)(.*)$/// polynomial backtracking on large strings/(\w+\s)+\w+/// matching whitespace-separated words// V8 (and therefore Node.js) does NOT support atomic groups or possessive quantifiers.// `(?:pattern)` is a plain non-capturing group — it does nothing to prevent backtracking.// `(?>pattern)` is the real atomic-group syntax, but it's an unshipped TC39 proposal;// using it in standard JavaScript throws a SyntaxError. Don't reach for either as a fix.// SAFE approach 1: restructure the pattern to remove the nested quantifier// (the vulnerable email regex from above rewritten with a single bounded quantifier// and no ambiguity between the two alternatives that can both match the same input)const emailValidatorSafe =/^[a-zA-Z0-9](?:[a-zA-Z0-9.]){0,63}@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/;// SAFE approach 2: enforce a length cap before the regex ever runs — most catastrophic// backtracking is only exploitable because the input is unboundedfunctionvalidateBounded(pattern, input, maxLength =254){if(input.length> maxLength)returnfalse;return pattern.test(input);}// SAFE approach 3: for patterns you can't safely rewrite, run them through the `re2`// npm package instead of the native regex engine. RE2 uses a linear-time automaton// and cannot exhibit catastrophic backtracking, by construction:importRE2from're2';const reDosProofPattern =newRE2(/^([a-zA-Z0-9])(([a-zA-Z0-9])|(\.))+@[a-zA-Z0-9]+\.[a-zA-Z]{2,4}$/);console.log(reDosProofPattern.test('aaaaaaaaaaaaaaaaaaaaaaaaaaaa@'));// returns false in linear time, no hang
// For payment systems: validate wallet addresses with bounded patternsconstEVM_ADDRESS=/^0x[0-9a-fA-F]{40}$/;// exact length, no backtrackingconstTX_HASH=/^0x[0-9a-fA-F]{64}$/;// exact length, no backtrackingconstAMOUNT=/^\d{1,20}$/;// bounded digits, linear// For UPI payment IDs:constUPI_ID=/^[\w.\-]{1,64}@[\w.\-]{1,64}$/;// bounded, no nested quantifiers// Test all validators against adversarial input before deployingimport{ safe as safeRegex }from'safe-regex';console.log(safeRegex(/^(a+)+$/));// false — unsafe!console.log(safeRegex(EVM_ADDRESS));// true — safe
// Wrap regex execution in a timeoutfunctionsafeMatch(pattern, input, timeoutMs =10){returnnewPromise((resolve, reject)=>{const timer =setTimeout(()=>{reject(newError(`Regex timeout after ${timeoutMs}ms`));}, timeoutMs);try{const result = pattern.test(input);clearTimeout(timer);resolve(result);}catch(err){clearTimeout(timer);reject(err);}});}// Usage: if validation takes > 10ms, it's a ReDoS attacktry{const isValid =awaitsafeMatch(complexEmailRegex, userInput,10);}catch(err){ logger.warn({input: userInput.slice(0,100)},'Potential ReDoS detected');return res.status(400).json({error:'Invalid input'});}
// Deeply nested object: 10KB JSON → 50MB in memory// Each nesting level multiplies reference overheadconst bomb ='{"a":{"a":{"a":{"a":{"a":{"a":...{"a":true}...}}}}}}}';// At 500 levels deep: V8 object overhead × 500 = significant memory// Repeated keys object: forces V8 to store all duplicatesconst repeated ='{"key": "val", "key": "val", "key": "val", ...}';// 100K repetitions// JSON.parse keeps only last value, but parses ALL entries// Large arrays of objects: straightforward amplificationconst explosion =JSON.stringify({data:Array(1_000_000).fill({id:1,value:'x'})});// 10KB JSON → 800MB JavaScript array in memory
// Fastify: set request body size limit before accepting any dataconst fastify =Fastify({bodyLimit:1*1024*1024,// 1MB max body size});// For high-security endpoints: even tighter limitsfastify.post('/api/v2/payments',{config:{bodyLimit:10*1024},// 10KB max for payment endpoint}, paymentHandler);// Validate JSON depth before processingfunctionvalidateDepth(obj, maxDepth =10, currentDepth =0){if(currentDepth > maxDepth)thrownewError('JSON depth limit exceeded');if(obj !==null&&typeof obj ==='object'){for(const value ofObject.values(obj)){validateDepth(value, maxDepth, currentDepth +1);}}}fastify.addHook('preHandler',async(request)=>{if(request.body&&typeof request.body==='object'){validateDepth(request.body,10);}});
// Fastify schema validation runs BEFORE your handler// Invalid payloads are rejected by the compiled ajv functionfastify.post('/api/v2/payments',{schema:{body:{type:'object',maxProperties:10,// max 10 keys at rootadditionalProperties:false,// no unknown keysrequired:['amount','senderId','recipientId'],properties:{amount:{type:'integer',minimum:1,maximum:1_000_000_000},senderId:{type:'string',maxLength:64},recipientId:{type:'string',maxLength:64},}}}}, paymentHandler);// Payloads with extra keys, wrong types, or out-of-range values// are rejected with 400 before the handler runs// No JSON bomb can make it past the schema validator
// Protection: move large payload parsing to worker threadsconstLARGE_PAYLOAD_THRESHOLD=50*1024;// 50KBfastify.addContentTypeParser('application/json',{parseAs:'buffer'},async(req, body)=>{if(body.length>LARGE_PAYLOAD_THRESHOLD){// Offload to worker thread — main event loop unaffectedreturnawaitparseJsonInWorker(body);}returnJSON.parse(body.toString('utf8'));});
// DANGEROUS: crypto.*Sync operations block the event loopconst hash = crypto.createHash('sha256').update(data).digest('hex');// synchronous, fast (< 1ms)const key = crypto.scryptSync(password, salt,64);// synchronous, SLOW (100ms+)// SAFE: use async crypto APIsconst key =awaitnewPromise((resolve, reject)=>{ crypto.scrypt(password, salt,64,(err, key)=>{if(err)reject(err);elseresolve(key);});});// This runs in the libuv thread pool, not on the event loop
classCircuitBreaker{ #state ='CLOSED';// CLOSED = normal, OPEN = failing fast, HALF_OPEN = testing #failures =0; #successes =0; #lastFailureTime =0; #FAILURE_THRESHOLD=5; #SUCCESS_THRESHOLD=2; #RECOVERY_TIMEOUT=30_000;// 30s before trying againasyncexecute(operation){if(this.#state==='OPEN'){// Check if recovery timeout has elapsedif(Date.now()-this.#lastFailureTime>this.#RECOVERY_TIMEOUT){this.#state='HALF_OPEN';this.#successes=0;}else{thrownewCircuitOpenError('Circuit is OPEN — failing fast');}}try{const result =awaitoperation();this.#onSuccess();return result;}catch(err){this.#onFailure();throw err;}}#onSuccess(){this.#failures=0;if(this.#state==='HALF_OPEN'){this.#successes++;if(this.#successes>=this.#SUCCESS_THRESHOLD){this.#state='CLOSED'; logger.info('Circuit CLOSED — service recovered');}}}#onFailure(){this.#failures++;this.#lastFailureTime=Date.now();if(this.#failures>=this.#FAILURE_THRESHOLD){this.#state='OPEN'; logger.error({failures:this.#failures},'Circuit OPENED — failing fast');}}getstate(){returnthis.#state;}}// Wrap upstream calls with circuit breakerconst blockchainRpcBreaker =newCircuitBreaker();const databaseBreaker =newCircuitBreaker();asyncfunctiongetBlockFromRpc(height){return blockchainRpcBreaker.execute(async()=>{returnawait rpcClient.getBlockByHeight(height);});}
// Separate connection pools per upstream (bulkhead isolation)const pools ={postgresql:newpg.Pool({max:30}),// 30 connections for DBanalyticsDb:newpg.Pool({max:10}),// 10 separate connections for analyticsexternalRpc:newAgent({maxSockets:20}),// 20 connections to blockchain RPC};// If analyticsDb is slow, it can exhaust its 10 connections// but never affects the 30 PostgreSQL connections// The main write path is isolated from analytics slowness
# Step 1: Confirm ELU via metrics# ELU metric: nodejs_event_loop_utilization > 0.90# Step 2: Generate CPU profile to find the blocking functionclinic flame -- node indexer.js &SERVER_PID=$!autocannon -c50-d20 http://localhost:3000/health
kill$SERVER_PID# Opens flamegraph: look for wide plateau in non-I/O functions# Step 3: If production emergency, reduce load# Scale up replicas immediately (buy time)kubectl scale deployment indexer --replicas=16# Step 4: Identify and fix# Common causes: JSON.parse in hot path, sync crypto, RegEx on large input# Fix: move to worker thread or replace with async equivalent
# Step 1: Confirm via heap metric# nodejs_heap_used_bytes growing without leveling off# Step 2: Take two heap snapshotskill-SIGUSR2$PID# snapshot 1 (configured in app startup)sleep300kill-SIGUSR2$PID# snapshot 2# Step 3: Load snapshots in Chrome DevTools# Memory tab → Load heap snapshot → Switch to Comparison view# Sort by Delta (objects that increased between snapshots)# Common leak sources: EventEmitter listeners, Map/Set without eviction, closures# Step 4: Emergency mitigation while fix is deployed# PM2: set max_memory_restart to trigger automatic restart before OOM# pm2 set max_memory_restart 2G
# Step 1: Confirm via metrics# db_pool_waiting_count > 0# Step 2: Check what's holding connections# In PostgreSQL:psql -c"SELECT pid, state, query_start, left(query,100) FROM pg_stat_activity
WHERE state IN ('active','idle in transaction') ORDER BY query_start;"# Step 3: Kill long-running queries holding connectionspsql -c"SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction' AND query_start < NOW() - INTERVAL '30s';"# Step 4: Temporary relief# Increase pool size if database can handle more connections# Update and reload (zero-downtime with PM2 reload):# pool.options.max = 60; (requires restart to take effect)# Step 5: Find root cause# Was there a spike in traffic? → Size pool for peak# Did a query get slow? → Check EXPLAIN ANALYZE for plan regression
# Step 1: Confirm and measurekafka-consumer-groups.sh --bootstrap-server kafka:9092 \--group transaction-processor --describe# Shows lag per partition# Step 2: Identify the slow partition (hotspot?)# If one partition has 80% of total lag: partition key imbalance# If all partitions have equal lag: consumer is generally too slow# Step 3: Scale consumerskubectl scale deployment analytics-service --replicas=12# Kafka will rebalance partitions to new consumers# Step 4: Check consumer processing time# If avg processing > 10ms per message at 50K msg/sec: can't keep up# Fix: batch processing, optimize DB writes (use bulk INSERT)# Step 5: If lag is from historical event replay (deployment with fromBeginning):# Reset consumer offset to currentkafka-consumer-groups.sh --reset-offsets --to-latest \--group transaction-processor --topic transactions --execute
# Step 1: Identify the failing upstream# Check circuit breaker state metrics: circuit_state{service="blockchain-rpc"} = OPEN# Step 2: Verify circuit breaker is isolating the failure# If circuit is OPEN: failing fast → good, cascade is contained# If circuit is CLOSED but service is slow: trigger manual circuit open# In application code:blockchainRpcBreaker.forceOpen();# expose this via admin endpoint# Step 3: Enable degraded mode# Return cached/stale data while upstream recovers# Defer non-critical operations (analytics, notifications)# Prioritize critical path (transaction validation, DB writes)# Step 4: Monitor upstream recovery# Watch circuit breaker half-open probes# circuit_state will transition: OPEN → HALF_OPEN → CLOSED when upstream recovers# Step 5: Gradually restore traffic# Circuit breaker handles this automatically via HALF_OPEN state# Verify: watch success rate of operations through the recovered circuit
# Audit known vulnerabilitiesnpm audit
yarnnpm audit
# Check for suspicious packages (typosquatting, malicious injections)npx @socket.dev/cli check # socket.dev analyzes package behavior# Lock exact versions in production# Never use ^ or ~ in production package.json{"dependencies":{"fastify":"4.28.1", # exact, not ^4.28.1"pg":"8.12.0"# exact}}
// Restrict what packages can do at runtime using Node.js Permission Model.// --permission is the stable flag (stabilized in v23.5.0; older Node versions used// --experimental-permission). Without it, --allow-fs-read etc. are no-ops.// node --permission --allow-fs-read=/app/config indexer.js// Any attempt to read files outside /app/config → throws an ERR_ACCESS_DENIED error// Note: the Permission Model governs fs access, child_process, worker_threads, and// native addons — it does NOT govern outbound network connections. There is no// --allow-net flag in Node.js (that's a Deno feature). Restricting which hosts a// compromised dependency can reach requires network-layer controls instead — e.g.// egress firewall rules, a service mesh policy, or a Kubernetes NetworkPolicy.// This protects against compromised dependencies that try to:// - Exfiltrate environment variables (fs read outside allowed paths is blocked)// - Write files to disk (ransomware) — fs write outside allowed paths is blocked// - Spawn child processes or worker threads — blocked unless explicitly allowed// Network exfiltration must be stopped at the infrastructure layer, not by this flag.