Skip to content

CWE-295: Improper Certificate Validation - JavaScript/Node.js

Overview

Improper certificate validation in JavaScript/Node.js applications creates vulnerabilities that allow man-in-the-middle (MITM) attacks where attackers intercept and modify HTTPS communications. This occurs when applications disable TLS/SSL certificate verification, fail to validate certificate chains, don't check hostname matching, or accept expired/self-signed certificates in production environments.

Key Security Issues:

  • Man-in-the-Middle Attacks: Attackers intercept encrypted communications on networks
  • Credential Theft: Login credentials, API keys, and tokens exposed during transit
  • Session Hijacking: Authentication sessions stolen and replayed
  • Data Exfiltration: Sensitive data extracted from supposedly encrypted channels
  • API Security: Internal and external API calls vulnerable to interception

Common Node.js/JavaScript Scenarios:

  • Node.js applications using https module with rejectUnauthorized: false
  • Express/Fastify apps making internal API calls with disabled validation
  • axios/node-fetch clients configured to bypass certificate checks
  • WebSocket/Socket.io connections without TLS validation
  • AWS SDK and cloud service clients with custom HTTPS agents
  • Browser applications making fetch() calls (validation handled by browser)
  • Development configurations with disabled validation leaking to production

Why This Matters in JavaScript:

  • Node.js provides low-level TLS control allowing dangerous bypasses
  • Many HTTP client libraries default to secure settings, but allow easy overrides
  • Development environments often use self-signed certificates, leading to bad practices
  • Microservices architectures with internal CAs require careful configuration
  • Browser security model differs from Node.js requiring different approaches

Primary Defence: Never set rejectUnauthorized: false in production; instead, use proper CA certificates with https.Agent({ ca: fs.readFileSync('ca-cert.pem') }) for internal CAs, or use the system's default certificate store.

Common Vulnerable Patterns

Node.js https Module with rejectUnauthorized: false

const https = require('https');

// Completely disables certificate validation
const options = {
  hostname: 'api.example.com',
  port: 443,
  path: '/data',
  method: 'GET',
  rejectUnauthorized: false  // DANGEROUS: Allows any certificate
};

https.request(options, (res) => {
  let data = '';
  res.on('data', (chunk) => { data += chunk; });
  res.on('end', () => console.log(data));
}).end();

Why this is vulnerable: Setting rejectUnauthorized: false completely disables TLS certificate validation, allowing attackers to perform man-in-the-middle attacks by presenting any certificate (self-signed, expired, or attacker-controlled) to intercept and modify HTTPS traffic, steal credentials, or exfiltrate data.

axios with Custom Agent Disabling Validation

const axios = require('axios');
const https = require('https');

// Create axios client with disabled validation
const api = axios.create({
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // DANGEROUS: Bypasses all validation
  })
});

// All requests with this client are vulnerable
async function fetchUserData(userId) {
  const response = await api.get(`https://api.example.com/users/${userId}`);
  return response.data;
}

Why this is vulnerable: Configuring axios with an httpsAgent that has rejectUnauthorized: false affects all requests made with that client instance, is easily forgotten in production deployments, and provides no visual indication in individual request code that certificate validation is disabled.

node-fetch with Agent Disabling Validation

const fetch = require('node-fetch');
const https = require('https');

const agent = new https.Agent({
  rejectUnauthorized: false  // DANGEROUS: No certificate checks
});

async function getData() {
  const response = await fetch('https://api.example.com/data', {
    agent: agent  // Vulnerable agent used
  });
  return await response.json();
}

Why this is vulnerable: Using an HTTPS agent with rejectUnauthorized: false in node-fetch allows man-in-the-middle attacks to intercept responses, modify API data, or inject malicious content, and the agent can be reused across multiple requests spreading the vulnerability.

tls.connect() Without Proper Validation

const tls = require('tls');

// Direct TLS connection without validation
const socket = tls.connect(443, 'api.example.com', {
  rejectUnauthorized: false,  // DANGEROUS
  checkServerIdentity: () => undefined  // DANGEROUS: Disables hostname check
}, () => {
  socket.write('GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n');
});

socket.on('data', (data) => {
  console.log(data.toString());
});

Why this is vulnerable: Disabling both rejectUnauthorized and checkServerIdentity in tls.connect() removes all certificate validation including hostname verification, allowing attackers to intercept direct socket connections and present fraudulent certificates without detection.

Environment Variable Controlled Validation

const https = require('https');

// Validation controlled by environment variable
const strictSSL = process.env.VERIFY_SSL !== 'false';

function makeRequest(url) {
  return https.get(url, {
    rejectUnauthorized: strictSSL  // DANGEROUS if env var set
  });
}

Why this is vulnerable:

  • Production systems may have VERIFY_SSL=false set during troubleshooting
  • Environment variables can be modified by attackers with access
  • No code review catches runtime configuration

Request Library with strictSSL: false (Deprecated but Common)

const request = require('request');  // Note: request is deprecated

request({
  url: 'https://api.example.com/data',
  strictSSL: false  // DANGEROUS: Legacy syntax but still used
}, (error, response, body) => {
  console.log(body);
});

Why this is vulnerable:

  • Legacy code still in production systems
  • request library deprecated but widely deployed
  • Migration to modern libraries may preserve the vulnerability

Express App with Disabled Validation for Internal Calls

const express = require('express');
const axios = require('axios');
const https = require('https');

const app = express();

// Internal service client with disabled validation
const internalApi = axios.create({
  baseURL: 'https://internal-service.local',
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // DANGEROUS even for internal services
  })
});

app.get('/user/:id', async (req, res) => {
  // Vulnerable to MITM on internal network
  const user = await internalApi.get(`/users/${req.params.id}`);
  res.json(user.data);
});

Why this is vulnerable:

  • "Internal only" doesn't mean "trusted network"
  • Insider threats and network compromises still possible
  • Microservices should use mutual TLS, not disabled validation

WebSocket/Socket.io with Disabled TLS Validation

const io = require('socket.io-client');

// WebSocket connection without certificate validation
const socket = io('https://realtime.example.com', {
  rejectUnauthorized: false,  // DANGEROUS
  transports: ['websocket']
});

socket.on('data', (data) => {
  console.log('Received:', data);
});

Why this is vulnerable:

  • Long-lived WebSocket connections provide extended attack window
  • Real-time data streams exposed to interception
  • Bidirectional communication allows injection attacks

Secure Patterns

Node.js https Module with Default ValidationCORRECT

const https = require('https');

// Uses default secure validation - no options needed
const options = {
  hostname: 'api.example.com',
  port: 443,
  path: '/data',
  method: 'GET'
  // rejectUnauthorized defaults to true
  // System CA certificates used automatically
};

https.request(options, (res) => {
  let data = '';
  res.on('data', (chunk) => { data += chunk; });
  res.on('end', () => {
    console.log('Secure response received');
    console.log(data);
  });
}).on('error', (err) => {
  console.error('HTTPS request failed:', err.message);
  // Fails fast if certificate validation fails
}).end();

Why this works:

  • Default rejectUnauthorized: true enforces validation
  • System CA bundle used automatically
  • Certificate chain verified
  • Hostname matching enforced
  • Expired certificates rejected

axios with Proper ConfigurationCORRECT

const axios = require('axios');
const https = require('https');

// Default axios instance uses secure settings
const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000
  // No httpsAgent needed - defaults are secure
});

async function fetchUserData(userId) {
  try {
    const response = await api.get(`/users/${userId}`);
    return response.data;
  } catch (error) {
    if (error.code === 'CERT_HAS_EXPIRED') {
      console.error('Certificate validation failed:', error.message);
      // Handle certificate errors appropriately
    }
    throw error;
  }
}

Why this works:

  • No custom agent = default secure validation
  • Certificate errors properly caught and logged
  • Fails safely if validation fails

Custom CA Bundle for Internal ServicesCORRECT

const https = require('https');
const fs = require('fs');
const path = require('path');

// Load custom CA certificate for internal corporate CA
const ca = fs.readFileSync(path.join(__dirname, 'certs', 'internal-ca.pem'));

const options = {
  hostname: 'internal-api.corp.local',
  port: 443,
  path: '/data',
  method: 'GET',
  ca: ca  // Custom CA bundle
  // rejectUnauthorized still true by default
};

https.request(options, (res) => {
  console.log('Validated with internal CA');
  // Process response
}).end();

Why this works:

  • Validation still enforced with internal CA
  • Certificate chain verified against trusted CA
  • Hostname matching still enforced
  • Better than disabling validation

axios with Custom CA for Internal ServicesCORRECT

const axios = require('axios');
const https = require('https');
const fs = require('fs');

// Load internal CA certificate
const internalCA = fs.readFileSync('./certs/internal-ca.pem');

const internalApi = axios.create({
  baseURL: 'https://internal-service.corp.local',
  httpsAgent: new https.Agent({
    ca: internalCA
    // rejectUnauthorized defaults to true
  })
});

async function getInternalData() {
  const response = await internalApi.get('/data');
  return response.data;
}

Why this works:

  • Custom CA supports internal infrastructure
  • Validation still fully enforced
  • Certificate chain verified
  • Hostname matching enforced

Certificate Pinning for High SecurityCORRECT

const https = require('https');
const crypto = require('crypto');

// Expected SHA-256 fingerprint of the server's certificate
const EXPECTED_FINGERPRINT = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99';

function makeSecureRequest(hostname, path) {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: hostname,
      port: 443,
      path: path,
      method: 'GET',
      checkServerIdentity: (host, cert) => {
        // Standard validation first
        const err = https.globalAgent.options.checkServerIdentity(host, cert);
        if (err) return err;

        // Then check certificate pinning
        const fingerprint = crypto.createHash('sha256')
          .update(cert.raw)
          .digest('hex')
          .toUpperCase()
          .match(/.{2}/g)
          .join(':');

        if (fingerprint !== EXPECTED_FINGERPRINT) {
          return new Error(`Certificate fingerprint mismatch. Expected: ${EXPECTED_FINGERPRINT}, Got: ${fingerprint}`);
        }
      }
    };

    https.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => resolve(data));
    }).on('error', reject).end();
  });
}

Why this works:

  • Standard validation plus additional fingerprint check
  • Protects against compromised CAs
  • Detects certificate rotation attempts
  • Fails securely if fingerprint doesn't match

tls.connect() with Proper ValidationCORRECT

const tls = require('tls');

function secureTLSConnect(host, port) {
  return new Promise((resolve, reject) => {
    const options = {
      host: host,
      port: port,
      servername: host  // Enables SNI
      // rejectUnauthorized defaults to true
      // checkServerIdentity defaults to proper validation
    };

    const socket = tls.connect(options, () => {
      console.log('TLS connection established');
      console.log('Authorized:', socket.authorized);
      console.log('Peer certificate:', socket.getPeerCertificate());
      resolve(socket);
    });

    socket.on('error', (err) => {
      console.error('TLS connection failed:', err.message);
      reject(err);
    });
  });
}

Why this works:

  • All default validations enabled
  • SNI properly configured with servername
  • Certificate information logged for monitoring
  • Errors properly handled

Express with Secure Internal Service CallsCORRECT

const express = require('express');
const axios = require('axios');
const fs = require('fs');
const https = require('https');

const app = express();

// Load internal CA for corporate services
const internalCA = fs.readFileSync('./certs/internal-ca.pem');

const internalApi = axios.create({
  baseURL: 'https://internal-service.corp.local',
  httpsAgent: new https.Agent({
    ca: internalCA  // Validation enforced with internal CA
  }),
  timeout: 5000
});

app.get('/user/:id', async (req, res) => {
  try {
    const user = await internalApi.get(`/users/${req.params.id}`);
    res.json(user.data);
  } catch (error) {
    if (error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
      console.error('Certificate validation failed for internal service');
      res.status(503).json({ error: 'Service unavailable' });
    } else {
      throw error;
    }
  }
});

Why this works:

  • Internal services use proper CA validation
  • Certificate errors handled gracefully
  • No validation bypasses
  • Secure even on potentially compromised networks

WebSocket/Socket.io with Proper ValidationCORRECT

const io = require('socket.io-client');
const fs = require('fs');

// For custom CA (if needed)
const ca = fs.readFileSync('./certs/ca-bundle.pem');

const socket = io('https://realtime.example.com', {
  // rejectUnauthorized defaults to true
  ca: ca,  // Only if custom CA needed
  transports: ['websocket']
});

socket.on('connect', () => {
  console.log('Secure WebSocket connection established');
});

socket.on('connect_error', (error) => {
  console.error('Connection failed:', error.message);
  // Handle certificate validation failures
});

socket.on('data', (data) => {
  console.log('Received:', data);
});

Why this works:

  • Default validation enforced
  • Custom CA only if required for internal services
  • Connection errors properly handled
  • Long-lived connections protected

Key Security Functions

HTTPS Request Wrapper with Enforced Validation

const https = require('https');
const { URL } = require('url');

/**
 * Wrapper around https.request that enforces validation
 * @param {string} urlString - Full URL to request
 * @param {object} options - Additional options (method, headers, etc.)
 * @returns {Promise} Promise resolving to response data
 */
function secureHttpsRequest(urlString, options = {}) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlString);

    // Ensure we're using HTTPS
    if (url.protocol !== 'https:') {
      return reject(new Error('Only HTTPS URLs allowed'));
    }

    const requestOptions = {
      hostname: url.hostname,
      port: url.port || 443,
      path: url.pathname + url.search,
      method: options.method || 'GET',
      headers: options.headers || {},
      // Explicitly enforce validation (redundant but clear)
      rejectUnauthorized: true
    };

    // If custom CA provided
    if (options.ca) {
      requestOptions.ca = options.ca;
    }

    const req = https.request(requestOptions, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve({ data, statusCode: res.statusCode, headers: res.headers });
        } else {
          reject(new Error(`HTTP ${res.statusCode}: ${data}`));
        }
      });
    });

    req.on('error', (err) => {
      if (err.code === 'CERT_HAS_EXPIRED') {
        reject(new Error('Certificate has expired - validation failed'));
      } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
        reject(new Error('Unable to verify certificate - validation failed'));
      } else {
        reject(err);
      }
    });

    if (options.body) {
      req.write(JSON.stringify(options.body));
    }

    req.end();
  });
}

// Usage
secureHttpsRequest('https://api.example.com/data')
  .then(response => console.log(response.data))
  .catch(err => console.error('Request failed:', err.message));

Certificate Information Extractor

const https = require('https');
const { URL } = require('url');

/**
 * Get certificate information from an HTTPS endpoint
 * @param {string} urlString - HTTPS URL to check
 * @returns {Promise} Promise resolving to certificate details
 */
function getCertificateInfo(urlString) {
  return new Promise((resolve, reject) => {
    const url = new URL(urlString);

    const options = {
      hostname: url.hostname,
      port: url.port || 443,
      method: 'GET',
      rejectUnauthorized: true  // Validation enforced
    };

    const req = https.request(options, (res) => {
      const cert = res.socket.getPeerCertificate();

      if (!res.socket.authorized) {
        return reject(new Error(`Certificate validation failed: ${res.socket.authorizationError}`));
      }

      const certInfo = {
        subject: cert.subject,
        issuer: cert.issuer,
        validFrom: cert.valid_from,
        validTo: cert.valid_to,
        serialNumber: cert.serialNumber,
        fingerprint: cert.fingerprint,
        fingerprint256: cert.fingerprint256,
        authorized: res.socket.authorized,
        subjectAltNames: cert.subjectaltname
      };

      resolve(certInfo);
      res.resume(); // Drain response
    });

    req.on('error', reject);
    req.end();
  });
}

// Usage - check certificate expiration
async function checkCertificateExpiration(url) {
  try {
    const info = await getCertificateInfo(url);
    const expiryDate = new Date(info.validTo);
    const daysUntilExpiry = Math.floor((expiryDate - new Date()) / (1000 * 60 * 60 * 24));

    console.log(`Certificate for ${url}:`);
    console.log(`  Expires: ${info.validTo}`);
    console.log(`  Days until expiry: ${daysUntilExpiry}`);

    if (daysUntilExpiry < 30) {
      console.warn('  ⚠️ Certificate expiring soon!');
    }

    return info;
  } catch (error) {
    console.error(`Certificate check failed: ${error.message}`);
    throw error;
  }
}

Validation Configuration Auditor

const https = require('https');

/**
 * Audit HTTPS agent configuration to detect validation bypasses
 * @param {https.Agent} agent - HTTPS agent to audit
 * @returns {object} Audit results with warnings
 */
function auditHttpsAgent(agent) {
  const warnings = [];
  const options = agent.options || {};

  // Check for disabled validation
  if (options.rejectUnauthorized === false) {
    warnings.push({
      severity: 'CRITICAL',
      message: 'rejectUnauthorized set to false - certificate validation disabled',
      remediation: 'Remove rejectUnauthorized: false or set to true'
    });
  }

  // Check for custom checkServerIdentity that might bypass validation
  if (options.checkServerIdentity && options.checkServerIdentity.toString().includes('undefined')) {
    warnings.push({
      severity: 'CRITICAL',
      message: 'checkServerIdentity appears to bypass hostname validation',
      remediation: 'Remove custom checkServerIdentity or ensure it performs proper validation'
    });
  }

  // Check if custom CA is provided (not a warning, just info)
  if (options.ca) {
    warnings.push({
      severity: 'INFO',
      message: 'Custom CA bundle configured',
      remediation: 'Ensure CA bundle is kept up-to-date and from trusted source'
    });
  }

  return {
    secure: warnings.filter(w => w.severity === 'CRITICAL').length === 0,
    warnings: warnings
  };
}

// Usage - audit axios client
const axios = require('axios');

const client = axios.create({
  httpsAgent: new https.Agent({
    rejectUnauthorized: false  // Will be caught by audit
  })
});

const audit = auditHttpsAgent(client.defaults.httpsAgent);
if (!audit.secure) {
  console.error('WARNING: HTTPS configuration is insecure:');
  audit.warnings.forEach(w => {
    console.error(`  [${w.severity}] ${w.message}`);
    console.error(`    → ${w.remediation}`);
  });
  process.exit(1);
}

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

Security Checklist

  • All HTTPS requests use default certificate validation (rejectUnauthorized: true)
  • No rejectUnauthorized: false in codebase (search with grep/semgrep)
  • Internal services use custom CA bundles instead of disabled validation
  • Certificate expiration monitored (30-day warning minimum)
  • Environment variables don't control validation bypass
  • NODE_TLS_REJECT_UNAUTHORIZED never set to '0'
  • ESLint security plugin configured and passing
  • All uses of deprecated request library migrated to modern alternatives
  • WebSocket/Socket.io connections validate certificates
  • Certificate pinning implemented for high-value targets (optional)
  • Development environments use proper certificates (mkcert, etc.)
  • CI/CD pipeline includes certificate validation checks
  • Error handling distinguishes certificate failures from network errors
  • Certificate information logged for security monitoring
  • Team trained on proper TLS configuration practices

Additional Resources