analysis

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:

FrameworkHandler Detection
NestJS@Get, @Post, @Put, @Patch, @Delete, @All, @Options, @Head decorators on methods in @Controller classes
Expressapp.get('/path', handler), router.post('/path', handler) -- function passed as callback to route methods
Fastifyserver.get('/path', handler), fastify.route() registrations
KoaFunctions with (ctx) or (ctx, next) parameter signature
HapiFunctions 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"
Technical Debt Radar Documentation