Skip to content

CWE-209: Error Message Information Leak - JavaScript

Overview

Error Message Information Leak in JavaScript applications occurs when error stack traces, database query details, or internal system information is exposed through API responses, error pages, or client-side console output. Node.js applications with Express, Fastify, Koa, and Next.js each have different error handling mechanisms that must be properly configured to avoid information disclosure.

Primary Defence: Return generic error messages to clients while logging detailed errors server-side, use centralized error handling middleware, and check NODE_ENV to prevent stack traces in production.

Common Vulnerable Patterns

Returning Raw Error Messages in Express

// VULNERABLE - Exposes database errors and stack traces
const express = require('express');
const app = express();

app.get('/user/:id', async (req, res) => {
  try {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
    res.json(user);
  } catch (error) {
    // Returns: "table 'users' does not exist" or connection errors
    res.status(500).json({ error: error.message });
  }
});

Why this is vulnerable:

  • Reveals table/column names, SQL syntax, and schema structure.
  • Exposes database vendor details and error codes.
  • Leaks connection details and internal topology.
  • Enables targeted SQL/NoSQL injection planning.

Exposing Full Stack Traces

// VULNERABLE - Returns complete stack trace
app.post('/process', async (req, res) => {
  try {
    const result = await complexOperation(req.body);
    res.json(result);
  } catch (error) {
    // Exposes: file paths, function names, line numbers, dependencies
    res.status(500).json({
      error: error.message,
      stack: error.stack,
      name: error.name
    });
  }
});

Why this is vulnerable:

  • Exposes absolute paths, function names, and line numbers.
  • Reveals module structure and third-party dependencies.
  • Helps attackers fingerprint versions with known CVEs.
  • Maps internal code paths for exploitation.

Default Express Error Handler

// VULNERABLE - Express default error handler shows stack traces
const express = require('express');
const app = express();

app.get('/data', async (req, res) => {
  // If error thrown here, Express default handler exposes details
  const data = await fetchData();
  res.json(data);
});

// No custom error handler - uses Express default which shows:
// - Full stack trace in development
// - Error message in production
// - Internal paths and structure

Why this is vulnerable:

  • Default handler leaks stack traces or error messages.
  • Reveals middleware chain and routing structure.
  • Exposes framework internals and file layout.
  • Unhandled exceptions leak details by default.

Unhandled Promise Rejections

// VULNERABLE - Unhandled rejections may log sensitive data
app.get('/async-data', (req, res) => {
  // Promise rejection not caught
  fetchDataFromAPI(req.params.id)
    .then(data => res.json(data));
  // If fetchDataFromAPI rejects, error details may be logged or exposed
});

// Unhandled rejection logging
process.on('unhandledRejection', (reason, promise) => {
  // May log sensitive data to console or logs
  console.error('Unhandled Rejection:', reason);
});

Why this is vulnerable:

  • Unhandled rejections log full error details to stderr.
  • Error text can include secrets or connection strings.
  • Logs may be accessible via container/monitoring systems.
  • Attackers can trigger errors to probe behavior.

Next.js Development Mode Errors

// VULNERABLE - Next.js dev mode shows detailed error overlay
// next.config.js
module.exports = {
  // No production optimization
  reactStrictMode: true,
}

// pages/api/users/[id].js
export default async function handler(req, res) {
  try {
    const user = await prisma.user.findUnique({
      where: { id: req.query.id }
    });
    res.json(user);
  } catch (error) {
    // In dev mode, Next.js shows full error overlay with stack trace
    throw error;
  }
}

Why this is vulnerable:

  • Dev overlays show stack traces and source snippets.
  • Exposes file paths, schema details, and configs.
  • Misconfigured prod deploys leak full internals.
  • Attackers can trigger errors to view details.

Logging Sensitive Data to Console

// VULNERABLE - Console logs may be visible
app.post('/login', async (req, res) => {
  const { username, password } = req.body;

  // Logs plaintext password!
  console.log('Login attempt:', { username, password });

  try {
    const token = await authenticateUser(username, password);
    // Logs sensitive token!
    console.log('Generated token:', token);
    res.json({ token });
  } catch (error) {
    console.error('Auth error:', error);
    res.status(401).json({ error: 'Authentication failed' });
  }
});

Why this is vulnerable:

  • Logs may capture passwords, tokens, and session IDs.
  • Aggregation systems make logs widely accessible.
  • Plaintext credentials enable account takeover.
  • Secrets persist long after the request.

Secure Patterns

Express Global Error Handler

// SECURE - Generic errors to users, detailed logs server-side
const express = require('express');
const winston = require('winston');
const { v4: uuidv4 } = require('uuid');

const app = express();

// Configure Winston logger
const logger = winston.createLogger({
  level: 'error',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: '/var/log/app/error.log' })
  ]
});

// Route handlers
app.get('/user/:id', async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'Resource not found' });
    }
    res.json(user);
  } catch (error) {
    next(error); // Pass to error handler
  }
});

// Global error handler (must be last middleware)
app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Log full details server-side
  logger.error({
    errorId,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    timestamp: new Date().toISOString()
  });

  // Return generic message to client
  res.status(500).json({
    error: 'An error occurred',
    errorId,
    message: 'Please contact support with this error ID'
  });
});

module.exports = app;

Why this works:

  • Error handler runs last and catches upstream failures.
  • Full details are logged server-side only.
  • Clients receive generic messages with error IDs.
  • Error IDs correlate support tickets to logs.
  • Logs stay outside the web root with OS permissions.

Environment-Aware Error Handling

// SECURE - Different error handling for dev vs production
const isDevelopment = process.env.NODE_ENV !== 'production';

app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Always log server-side
  logger.error({
    errorId,
    error: err.message,
    stack: err.stack,
    path: req.path
  });

  // Conditional response based on environment
  const response = {
    error: 'An error occurred',
    errorId
  };

  // Only include details in development
  if (isDevelopment) {
    response.message = err.message;
    response.stack = err.stack;
  }

  res.status(err.status || 500).json(response);
});

Why this works:

  • Production responses omit stack traces and internals.
  • Development keeps details for debugging.
  • Full errors are always logged server-side.
  • Error IDs provide consistent traceability.
  • Requires NODE_ENV=production in deploys.

Async Error Wrapper

// SECURE - Wrapper to handle async errors consistently
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

// Usage
app.get('/user/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) {
    throw new NotFoundError('User not found');
  }
  res.json(user);
}));

// Custom error classes
class AppError extends Error {
  constructor(message, statusCode = 500, isOperational = true) {
    super(message);
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

class NotFoundError extends AppError {
  constructor(message = 'Resource not found') {
    super(message, 404);
  }
}

class ValidationError extends AppError {
  constructor(message = 'Invalid input') {
    super(message, 400);
  }
}

// Error handler with custom errors
app.use((err, req, res, next) => {
  const errorId = uuidv4();

  // Log all errors
  logger.error({
    errorId,
    message: err.message,
    stack: err.stack,
    isOperational: err.isOperational
  });

  // Return appropriate response
  if (err.isOperational) {
    // Known operational error - safe to send message
    res.status(err.statusCode).json({
      error: err.message,
      errorId
    });
  } else {
    // Unknown error - return generic message
    res.status(500).json({
      error: 'An unexpected error occurred',
      errorId
    });
  }
});

Why this works:

  • Async errors are forwarded to centralized middleware.
  • Prevents unhandled rejection leakage.
  • Error classes separate safe vs. unsafe messages.
  • isOperational gates what is safe to expose.
  • Full details logged with generic client responses.

Fastify Error Handler

// SECURE - Fastify with custom error handling
const fastify = require('fastify')({ logger: true });
const { v4: uuidv4 } = require('uuid');

// Custom error handler
fastify.setErrorHandler((error, request, reply) => {
  const errorId = uuidv4();

  // Log full error details
  fastify.log.error({
    errorId,
    error: error.message,
    stack: error.stack,
    path: request.url,
    method: request.method
  });

  // Determine status code
  const statusCode = error.statusCode || 500;

  // Return generic error
  reply.status(statusCode).send({
    error: statusCode === 404 ? 'Resource not found' : 'An error occurred',
    errorId
  });
});

// Route with error
fastify.get('/user/:id', async (request, reply) => {
  const user = await User.findById(request.params.id);
  if (!user) {
    return reply.code(404).send({ error: 'User not found' });
  }
  return user;
});

Why this works:

  • Global handler blocks Fastify default detail exposure.
  • Structured logging keeps full details server-side.
  • Error IDs correlate client reports to logs.
  • Status codes are preserved without leaking internals.
  • Responses are serialized safely by Fastify.

Next.js API Error Handling

// SECURE - Next.js API routes with secure error handling
// pages/api/users/[id].js
import { v4 as uuidv4 } from 'uuid';
import logger from '../../../lib/logger';

export default async function handler(req, res) {
  const errorId = uuidv4();

  try {
    const user = await prisma.user.findUnique({
      where: { id: req.query.id }
    });

    if (!user) {
      return res.status(404).json({ error: 'Resource not found' });
    }

    res.status(200).json(user);
  } catch (error) {
    // Log full error server-side
    logger.error({
      errorId,
      message: error.message,
      stack: error.stack,
      path: req.url
    });

    // Return generic error
    res.status(500).json({
      error: 'An error occurred',
      errorId
    });
  }
}

// next.config.js - Production configuration
module.exports = {
  reactStrictMode: true,
  productionBrowserSourceMaps: false, // Disable source maps in production
  compiler: {
    removeConsole: process.env.NODE_ENV === 'production', // Remove console logs
  }
}

Why this works:

  • API errors are caught and sanitized before response.
  • Full details are logged with an error ID.
  • Source maps are disabled in production.
  • Console output is removed from production builds.
  • 404s are handled explicitly to avoid DB errors.

Structured Logging with Redaction

// SECURE - Winston logger with sensitive data redaction
const winston = require('winston');

// Custom format to redact sensitive data
const redactSensitiveData = winston.format((info) => {
  const sensitivePatterns = [
    { pattern: /password["']?\s*[:=]\s*["']?([^"'}\s]+)/gi, replacement: 'password=***REDACTED***' },
    { pattern: /token["']?\s*[:=]\s*["']?([^"'}\s]+)/gi, replacement: 'token=***REDACTED***' },
    { pattern: /\b\d{13,19}\b/g, replacement: '***CARD***' }, // Credit card numbers
    { pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, replacement: '***EMAIL***' },
  ];

  let message = typeof info.message === 'string' ? info.message : JSON.stringify(info.message);

  sensitivePatterns.forEach(({ pattern, replacement }) => {
    message = message.replace(pattern, replacement);
  });

  info.message = message;
  return info;
});

const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    redactSensitiveData(),
    winston.format.timestamp(),
    winston.format.json()
  ),
  transports: [
    new winston.transports.File({
      filename: '/var/log/app/error.log',
      level: 'error'
    }),
    new winston.transports.File({
      filename: '/var/log/app/combined.log'
    })
  ]
});

// Don't log to console in production
if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

module.exports = logger;

Why this works:

  • Redaction runs before writing to log files.
  • Regex patterns catch common secret formats.
  • Redaction protects even if devs log sensitive data.
  • Console logging is disabled in production.
  • Separate files support log rotation and access control.

Validation Error Sanitization

// SECURE - Sanitized validation errors
const { validationResult } = require('express-validator');

app.post('/register', [
  body('email').isEmail(),
  body('password').isLength({ min: 8 })
], (req, res) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    // Log detailed errors server-side
    logger.warn('Validation failed', { errors: errors.array() });

    // Return generic error to client
    return res.status(400).json({
      error: 'Invalid input provided'
    });
  }

  // Process valid request
  res.json({ status: 'success' });
});

Why this works:

  • Detailed validation errors stay server-side.
  • Clients receive generic messages only.
  • Prevents leakage of field names and rules.
  • 400 status is preserved without extra detail.
  • Logs retain full context for debugging.

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources