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
httpsmodule withrejectUnauthorized: 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
requestlibrary 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: trueenforces 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: falsein 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_UNAUTHORIZEDnever set to'0' - ESLint security plugin configured and passing
- All uses of deprecated
requestlibrary 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
- badssl.com - Test certificate validation
- Node.js HTTPS Module
- Node.js TLS Documentation
- ESLint Security Plugin
- OWASP Transport Layer Protection
- Certificate Pinning Guide
- mkcert for Development
- Let's Encrypt - Free SSL certificates
- Mozilla SSL Configuration Generator
- CWE-295 Details