Runtime Risk
Eleven runtime risk patterns that detect event-loop-blocking and unsafe operations inside request handlers, with scope-aware detection across five frameworks.
Runtime Risk Patterns
The runtime risk detector identifies 11 patterns that block the Node.js event loop or cause unsafe operations. Detection is scope-aware -- patterns are only flagged as violations when they occur inside request handlers. The same code in a CLI script, bootstrap function, or background job is reported as an unflagged pattern for cross-file analysis instead.
Supported Frameworks
The detector recognizes handler scope in five Node.js frameworks:
| Framework | Handler Detection |
|---|---|
| NestJS | @Get, @Post, @Put, @Patch, @Delete, @All, @Options, @Head decorators on methods in @Controller classes |
| Express | app.get('/path', handler), router.post('/path', handler) -- function passed as callback to route methods |
| Fastify | server.get('/path', handler), fastify.route() registrations |
| Koa | Functions with (ctx) or (ctx, next) parameter signature |
| Hapi | Functions with (request, h) parameter signature |
Tip: Background job decorators (
@Cron,@Process,@Interval,@Timeout) are explicitly excluded from handler scope. Code inside these methods is treated as background work, not request-bound.
Scope-Aware Detection
The same code gets different treatment depending on where it appears:
// startup.ts -- NOT flagged (bootstrap function, runs once at startup)
import { readFileSync } from 'fs';
function bootstrap() {
const config = readFileSync('./config.json', 'utf-8'); // Safe: runs once
startServer(JSON.parse(config));
}
// config.controller.ts -- FLAGGED (inside @Get handler, blocks event loop)
import { readFileSync } from 'fs';
@Controller('config')
export class ConfigController {
@Get()
getConfig() {
const config = readFileSync('./config.json', 'utf-8'); // VIOLATION
return JSON.parse(config);
}
}
When readFileSync is found outside a handler (e.g., in a utility module), it is reported as an UnflaggedPattern and passed to the cross-file analyzer, which traces the import graph to determine if any handler calls the utility.
Pattern Reference
1. sync-fs-in-handler
Synchronous filesystem operations (readFileSync, writeFileSync, existsSync, statSync, readdirSync, etc.) block the event loop while waiting for disk I/O.
Violation:
@Controller('reports')
export class ReportController {
@Get(':id')
getReport(@Param('id') id: string) {
// Blocks the entire server while reading from disk
const data = readFileSync(`/reports/${id}.json`, 'utf-8');
return JSON.parse(data);
}
}
Fix:
@Controller('reports')
export class ReportController {
@Get(':id')
async getReport(@Param('id') id: string) {
const data = await readFile(`/reports/${id}.json`, 'utf-8');
return JSON.parse(data);
}
}
Severity: Critical (blocks merge when in handler scope)
2. sync-crypto
Synchronous cryptographic operations (pbkdf2Sync, scryptSync, randomBytes with large sizes, createHash on large inputs) are CPU-intensive and block the event loop.
Violation:
@Post('auth/login')
async login(@Body() dto: LoginDto) {
// pbkdf2Sync takes 100-500ms, blocking ALL concurrent requests
const hash = crypto.pbkdf2Sync(dto.password, salt, 100000, 64, 'sha512');
return this.authService.validateHash(hash);
}
Fix:
@Post('auth/login')
async login(@Body() dto: LoginDto) {
const hash = await new Promise<Buffer>((resolve, reject) => {
crypto.pbkdf2(dto.password, salt, 100000, 64, 'sha512', (err, key) => {
err ? reject(err) : resolve(key);
});
});
return this.authService.validateHash(hash);
}
Severity: Critical
3. sync-compression
Synchronous compression (gzipSync, deflateSync, brotliCompressSync, inflateSync, gunzipSync) blocks the event loop for the duration of compression.
Violation:
app.get('/export', (req, res) => {
const data = generateLargeReport();
const compressed = zlib.gzipSync(data); // Blocks for large payloads
res.send(compressed);
});
Fix:
app.get('/export', async (req, res) => {
const data = generateLargeReport();
const compressed = await promisify(zlib.gzip)(data);
res.send(compressed);
});
Severity: Critical
4. redos-vulnerable-regex
Regular expressions with nested quantifiers or catastrophic backtracking patterns can cause exponential execution time on adversarial input.
Violation:
@Post('validate')
validate(@Body() dto: { email: string }) {
// Nested quantifiers: (a+)+ causes catastrophic backtracking
const emailRegex = /^([a-zA-Z0-9]+)*@[a-zA-Z0-9]+\.[a-zA-Z]+$/;
return { valid: emailRegex.test(dto.email) };
}
Fix:
@Post('validate')
validate(@Body() dto: { email: string }) {
// Linear-time regex without nested quantifiers
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
return { valid: emailRegex.test(dto.email) };
}
Severity: Critical
5. busy-wait-loop
A while(true) or while(condition) loop with no await, break, or setTimeout inside the body. Creates an infinite CPU spin that freezes the process.
Violation:
@Get('status')
async getStatus() {
let ready = false;
while (!ready) {
ready = checkIfReady(); // Synchronous polling -- infinite CPU spin
}
return { status: 'ready' };
}
Fix:
@Get('status')
async getStatus() {
let ready = false;
while (!ready) {
ready = checkIfReady();
if (!ready) {
await new Promise(resolve => setTimeout(resolve, 100)); // Yield to event loop
}
}
return { status: 'ready' };
}
Severity: Critical
6. unbounded-json-parse
JSON.parse() on request body or external input without size validation. Parsing a 100MB JSON string blocks the event loop for seconds.
Violation:
app.post('/import', (req, res) => {
const data = JSON.parse(req.body); // Unbounded input from client
processData(data);
res.json({ ok: true });
});
Fix:
app.post('/import', (req, res) => {
if (req.headers['content-length'] && parseInt(req.headers['content-length']) > 1_000_000) {
return res.status(413).json({ error: 'Payload too large' });
}
const data = JSON.parse(req.body);
processData(data);
res.json({ ok: true });
});
Severity: Warning (blocks on critical if explicitly configured)
7. large-json-stringify
JSON.stringify() on large objects (database results, collections) without size bounds. Serializing millions of rows blocks the event loop.
Violation:
@Get('export/all')
async exportAll() {
const allUsers = await this.userRepo.find(); // Could be millions
return JSON.stringify(allUsers); // Blocks event loop during serialization
}
Fix:
@Get('export/all')
async exportAll(@Res() res: Response) {
const stream = this.userRepo.createQueryStream();
res.setHeader('Content-Type', 'application/json');
stream.pipe(new JsonArrayStream()).pipe(res);
}
Severity: Warning
8. cpu-heavy-loop-in-handler
A for/while loop iterating over an unbounded collection (e.g., database results, request arrays) with CPU-intensive work inside. Detected when a loop body contains function calls, property access chains, or arithmetic on each iteration.
Violation:
@Post('process')
async processOrders(@Body() orderIds: string[]) {
const results = [];
for (const id of orderIds) { // Unbounded -- client controls array size
const order = await this.orderService.findById(id);
const enriched = this.enrichOrder(order); // CPU work per iteration
results.push(enriched);
}
return results;
}
Fix:
@Post('process')
async processOrders(@Body() orderIds: string[]) {
if (orderIds.length > 100) {
throw new BadRequestException('Maximum 100 orders per request');
}
return Promise.all(
orderIds.map(id => this.orderService.processOne(id))
);
}
Severity: Warning
9. unbounded-array-operation
Array operations (.map(), .filter(), .reduce(), .sort(), .forEach()) on unbounded collections inside handlers. These operations are synchronous and block the event loop proportionally to array size.
Violation:
@Get('analytics')
async getAnalytics() {
const events = await this.eventRepo.findAll(); // Unbounded
const grouped = events.reduce((acc, event) => { // O(n) blocking operation
acc[event.type] = (acc[event.type] || 0) + 1;
return acc;
}, {});
return grouped;
}
Fix:
@Get('analytics')
async getAnalytics() {
// Push aggregation to the database
const grouped = await this.eventRepo
.createQueryBuilder('event')
.select('event.type', 'type')
.addSelect('COUNT(*)', 'count')
.groupBy('event.type')
.getRawMany();
return grouped;
}
Severity: Warning
10. dynamic-buffer-alloc
Buffer.alloc() or Buffer.allocUnsafe() with a dynamic size derived from user input. An attacker can trigger OOM by requesting a multi-GB buffer.
Violation:
@Post('upload')
async handleUpload(@Body() dto: { size: number; data: string }) {
const buf = Buffer.alloc(dto.size); // Client controls allocation size
buf.write(dto.data);
return this.storageService.store(buf);
}
Fix:
const MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
@Post('upload')
async handleUpload(@Body() dto: { size: number; data: string }) {
if (dto.size > MAX_BUFFER_SIZE) {
throw new BadRequestException(`Maximum upload size is ${MAX_BUFFER_SIZE} bytes`);
}
const buf = Buffer.alloc(dto.size);
buf.write(dto.data);
return this.storageService.store(buf);
}
Severity: Warning
11. unhandled-promise
A promise that is neither awaited nor has a .catch() handler. Unhandled rejections crash the Node.js process in v15+.
Violation:
@Post('notify')
async notifyUsers(@Body() dto: NotifyDto) {
this.emailService.sendBulk(dto.userIds); // Fire-and-forget: no await, no .catch()
return { status: 'sent' };
}
Fix:
@Post('notify')
async notifyUsers(@Body() dto: NotifyDto) {
await this.emailService.sendBulk(dto.userIds);
return { status: 'sent' };
}
// Or, if intentionally async, handle errors:
@Post('notify')
async notifyUsers(@Body() dto: NotifyDto) {
this.emailService.sendBulk(dto.userIds).catch(err => {
this.logger.error('Failed to send notifications', err);
});
return { status: 'queued' };
}
Severity: Warning
Handler Detection Per Framework
The detector identifies handler scope differently for each framework:
NestJS
Methods with HTTP decorator on a @Controller or @Resolver class:
@Controller('users')
export class UserController {
@Get() // Handler scope: YES
findAll() { }
@Post() // Handler scope: YES
create() { }
}
@Processor('queue')
export class TaskProcessor {
@Process() // Handler scope: NO (background worker)
handleTask() { }
}
Express
Callbacks passed to route registration methods:
const router = express.Router();
router.get('/users', (req, res) => { // Handler scope: YES
// ...
});
router.use((req, res, next) => { // Handler scope: YES (middleware)
// ...
});
Fastify
fastify.get('/users', async (request, reply) => { // Handler scope: YES
// ...
});
Koa
Functions with ctx parameter:
router.get('/users', async (ctx) => { // Handler scope: YES
// ...
});
app.use(async (ctx, next) => { // Handler scope: YES
// ...
});
Hapi
Functions with (request, h) signature:
server.route({
method: 'GET',
path: '/users',
handler: (request, h) => { // Handler scope: YES
// ...
},
});
Configuration
Runtime risk rules are configured in radar.yml under runtime_rules:
runtime_rules:
block:
- sync-fs-in-handler
- sync-crypto
- sync-compression
- redos-vulnerable-regex
- busy-wait-loop
warn:
- unbounded-json-parse
- large-json-stringify
- cpu-heavy-loop-in-handler
- unbounded-array-operation
- dynamic-buffer-alloc
- unhandled-promise
Rules listed under block produce critical severity violations that block merge. Rules under warn produce warning severity violations that appear in the PR comment but allow merge.
Exceptions
Specific files or paths can be exempted:
exceptions:
- rule: sync-fs-in-handler
file: "src/health/**"
expires: "2026-06-01"
reason: "Health check reads a small file synchronously by design"