CWE-295: Improper Certificate Validation - Python
Overview
Improper certificate validation in Python occurs when applications disable or incorrectly implement SSL/TLS certificate verification when making HTTPS requests. This is commonly seen with the requests library (verify=False), urllib3 (cert_reqs='CERT_NONE'), or custom ssl context configurations. Disabling certificate validation defeats the purpose of HTTPS encryption by allowing man-in-the-middle (MITM) attacks where attackers can intercept, read, and modify encrypted traffic. Python's default behavior is secure - problems arise when developers explicitly disable validation for development/testing and forget to re-enable it for production.
Primary Defence: Never use verify=False in production; instead, use verify=True (default) or specify custom CA bundle path with verify='/path/to/ca-bundle.crt' for internal CAs.
Common Vulnerable Patterns
requests with verify=False
import requests
# VULNERABLE - Certificate validation completely disabled
response = requests.get('https://api.example.com/data', verify=False)
# urllib3 warnings suppressed - even worse!
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get('https://api.example.com/data', verify=False)
# Attacker on network can intercept this request
Why this is vulnerable: verify=False completely disables SSL/TLS certificate validation, allowing attackers on the network to perform man-in-the-middle attacks by presenting self-signed or fraudulent certificates, intercepting sensitive data like credentials, API keys, and user information transmitted over supposedly secure HTTPS connections.
urllib3 with cert_reqs='CERT_NONE'
import urllib3
# VULNERABLE - No certificate validation
http = urllib3.PoolManager(cert_reqs='CERT_NONE')
response = http.request('GET', 'https://api.example.com/data')
# Also vulnerable
http = urllib3.PoolManager(assert_hostname=False, cert_reqs='CERT_NONE')
Why this is vulnerable: cert_reqs='CERT_NONE' instructs urllib3 to skip all certificate validation checks including chain verification and hostname matching, making MITM attacks trivial as attackers can present any certificate and intercept all traffic without detection.
ssl.create_default_context() with check_hostname=False
import ssl
import urllib.request
# VULNERABLE - Hostname verification disabled
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
response = urllib.request.urlopen('https://api.example.com/data', context=context)
# Certificate not validated, hostname not checked
Why this is vulnerable: Even though using ssl.create_default_context() which should be secure, explicitly setting check_hostname=False and verify_mode=ssl.CERT_NONE disables all validation, defeating HTTPS security and allowing attackers to intercept encrypted communications.
socket with Wrapped SSL (No Validation)
import socket
import ssl
# VULNERABLE - Raw SSL socket without validation
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped_socket = ssl.wrap_socket(sock) # No certificate validation!
wrapped_socket.connect(('api.example.com', 443))
wrapped_socket.send(b'GET / HTTP/1.1\r\nHost: api.example.com\r\n\r\n')
data = wrapped_socket.recv(4096)
Why this is vulnerable: ssl.wrap_socket() without specifying cert_reqs or an SSL context performs no certificate validation by default, allowing attackers to intercept the connection with any certificate, making the encryption useless against man-in-the-middle attacks.
No cert validation performed
import socket
import ssl
# VULNERABLE - ssl.wrap_socket() without cert_reqs defaults to no validation
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped = ssl.wrap_socket(sock)
wrapped.connect(('api.example.com', 443))
# Also vulnerable: explicitly setting no validation
wrapped = ssl.wrap_socket(
sock,
cert_reqs=ssl.CERT_NONE # No certificate validation
)
wrapped.connect(('api.example.com', 443))
# Send sensitive data over connection with no cert validation
wrapped.send(b'POST /api/login HTTP/1.1\r\n...\r\n')
Why this is vulnerable: ssl.wrap_socket() without the cert_reqs parameter defaults to ssl.CERT_NONE, performing no certificate validation whatsoever. This means the connection will accept any certificate - self-signed, expired, revoked, or issued for a different domain - making man-in-the-middle attacks trivial. An attacker on the network can intercept the connection, present their own certificate, and decrypt all traffic including passwords, API keys, and sensitive data. Even though the connection uses SSL/TLS encryption, without certificate validation there's no authentication of the server's identity, defeating the core security purpose of HTTPS. This pattern is particularly dangerous because it "looks" secure (using SSL), but provides no protection against active attackers.
httpx with verify=False
import httpx
# VULNERABLE - Modern HTTP client with validation disabled
async with httpx.AsyncClient(verify=False) as client:
response = await client.get('https://api.example.com/data')
# Synchronous version also vulnerable
client = httpx.Client(verify=False)
response = client.get('https://api.example.com/data')
Why this is vulnerable: Same as requests - verify=False disables certificate validation.
aiohttp without SSL Verification
import aiohttp
import ssl
# VULNERABLE - aiohttp with disabled SSL verification
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/data', ssl=ssl_context) as response:
data = await response.text()
# Also vulnerable: ssl=False
async with session.get('https://api.example.com/data', ssl=False) as response:
data = await response.text()
Why this is vulnerable: Custom SSL context disables validation. ssl=False bypasses all SSL checks.
Custom Hostname Verification Bypass
import ssl
import certifi
import urllib3
# VULNERABLE - Custom match_hostname that accepts anything
def insecure_match_hostname(cert, hostname):
return True # Always accepts - DANGEROUS
# Used with urllib3
class InsecureHTTPSConnectionPool(urllib3.HTTPSConnectionPool):
def _validate_conn(self, conn):
super()._validate_conn(conn)
# Override with insecure validation
conn.assert_hostname = False
# Bypasses hostname verification
Why this is vulnerable: Custom validation logic that always returns True defeats the security mechanism.
Environment Variable Control
import os
import requests
# VULNERABLE - Allowing env var to disable validation
verify_ssl = os.getenv('VERIFY_SSL', 'true').lower() == 'true'
response = requests.get('https://api.example.com/data', verify=verify_ssl)
# Attacker can set VERIFY_SSL=false to disable validation
Why this is vulnerable: External control over security settings. Attacker with environment access can disable validation.
Secure Patterns
SECURE: requests with Default Validation
import requests
# SECURE - Default certificate validation (verify=True is default)
response = requests.get('https://api.example.com/data')
# Explicit verification (recommended for clarity)
response = requests.get('https://api.example.com/data', verify=True)
# Certificate validated against system CA bundle
# Hostname verified to match certificate
Why this works: The requests library's default verify=True performs comprehensive certificate validation including verifying the certificate chain against the system's Certificate Authority (CA) bundle, checking certificate expiration dates, and validating that the certificate's Common Name (CN) or Subject Alternative Name (SAN) matches the requested hostname. This three-layer validation prevents man-in-the-middle attacks by ensuring the certificate was issued by a trusted CA (preventing forged certificates), is currently valid (preventing replay attacks with expired certificates), and actually belongs to the intended server (preventing attacks where a valid certificate for a different domain is presented). The system CA bundle typically contains root certificates from well-known CAs like Let's Encrypt, DigiCert, and others, maintained by the operating system or the certifi package. Making verify=True explicit in code (even though it's the default) documents security intent and prevents accidental changes.
SECURE: requests with Custom CA Bundle
import requests
# SECURE - Verify against specific CA bundle
response = requests.get(
'https://api.example.com/data',
verify='/path/to/ca-bundle.crt'
)
# Or use certifi for Mozilla's CA bundle
import certifi
response = requests.get(
'https://api.example.com/data',
verify=certifi.where()
)
Why this works: Specifying a custom CA bundle path via the verify parameter allows validation against internal or private Certificate Authorities that aren't included in the system's default trust store, which is essential for corporate environments with internal PKI infrastructure. The certifi package provides Mozilla's curated CA bundle, which is regularly updated with trusted root certificates and is more consistent across platforms than relying on system trust stores (which vary between Windows, macOS, and Linux distributions). When connecting to internal services signed by a corporate CA, pointing verify to the internal CA certificate file (in PEM format) enables secure HTTPS while maintaining full certificate validation. This approach is superior to verify=False because it maintains the security properties of HTTPS (authentication, encryption, integrity) while accommodating non-public CAs. The validation process is identical to the default behavior - verifying the chain of trust, expiration, and hostname - but uses the specified CA bundle instead of the system default.
SECURE: urllib3 with Proper Validation
import urllib3
import certifi
# SECURE - Proper certificate validation
http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED',
ca_certs=certifi.where()
)
response = http.request('GET', 'https://api.example.com/data')
# Certificate fully validated
Why this works: The urllib3 library's cert_reqs='CERT_REQUIRED' parameter enforces strict certificate validation by requiring the server to present a valid certificate that chains to a trusted CA, with ca_certs=certifi.where() specifying which CA certificates are considered trustworthy. Unlike CERT_NONE (no validation) or CERT_OPTIONAL (validation attempted but connection proceeds even if it fails), CERT_REQUIRED causes the connection to fail if the certificate cannot be validated, implementing a "fail secure" approach. The PoolManager handles connection pooling and reuse, with the certificate validation occurring during the initial TLS handshake for each new connection. Hostname verification happens automatically when cert_reqs='CERT_REQUIRED' is set, matching the requested hostname against the certificate's CN and SAN fields. This configuration is equivalent to requests.get(verify=certifi.where()) but provides more direct control over the underlying urllib3 connection pool, which is useful when you need custom connection management or are using urllib3 directly rather than through the higher-level requests library.
SECURE: ssl.create_default_context() with Validation
import ssl
import urllib.request
# SECURE - Default SSL context (validation enabled)
context = ssl.create_default_context()
# Explicitly ensure validation (default, but clear intent)
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
response = urllib.request.urlopen('https://api.example.com/data', context=context)
Why this works: Python's ssl.create_default_context() function returns an SSLContext object pre-configured with security best practices including verify_mode=ssl.CERT_REQUIRED (require valid certificate), check_hostname=True (verify hostname matches certificate), and a default set of trusted CA certificates loaded from the system's certificate store. This function was introduced in Python 3.4 specifically to provide a secure-by-default SSL configuration, preventing common mistakes like forgetting to enable hostname verification or certificate validation. The explicit setting of check_hostname=True and verify_mode=ssl.CERT_REQUIRED (even though they're defaults) serves as documentation and defense-in-depth, ensuring these critical security settings remain enabled even if future Python versions change defaults. When passed to urllib.request.urlopen(), this context is used for the TLS handshake, automatically rejecting connections with invalid, expired, self-signed, or hostname-mismatched certificates. This lower-level approach gives more control than requests but requires more careful handling of the SSL context.
SECURE: httpx with Validation
import httpx
import certifi
# SECURE - httpx with default validation
async with httpx.AsyncClient() as client:
response = await client.get('https://api.example.com/data')
# Or with specific CA bundle
client = httpx.Client(verify=certifi.where())
response = client.get('https://api.example.com/data')
Why this works: The httpx library is a modern HTTP client designed as a successor to requests, with certificate validation enabled by default through verify=True, providing the same security guarantees as requests (certificate chain validation, expiration checking, hostname verification) but with additional features like async support and HTTP/2. The AsyncClient() constructor without arguments uses the default secure configuration, automatically loading the system's CA bundle (or certifi if installed) for certificate validation. Explicitly passing verify=certifi.where() to the synchronous Client() ensures validation uses Mozilla's maintained CA bundle, which provides consistency across platforms and regular updates to trusted root certificates. The httpx library's design principle is "secure by default," making it difficult to accidentally create insecure configurations - you must explicitly pass verify=False to disable validation, which is intentionally verbose to discourage the practice. The library also provides better error messages than requests when certificate validation fails, helping developers diagnose issues without resorting to disabling validation.
SECURE: aiohttp with Proper SSL
import aiohttp
import ssl
import certifi
# SECURE - aiohttp with proper SSL context
ssl_context = ssl.create_default_context(cafile=certifi.where())
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/data', ssl=ssl_context) as response:
data = await response.text()
# Or use default (recommended)
async with aiohttp.ClientSession() as session:
async with session.get('https://api.example.com/data') as response:
data = await response.text()
Why this works: The aiohttp library for async HTTP requires explicit SSL context configuration, and using ssl.create_default_context(cafile=certifi.where()) ensures secure defaults (certificate validation, hostname checking, trusted CA bundle) are applied to async connections. The cafile parameter loads the CA bundle from certifi, providing consistent certificate validation across platforms. Passing this ssl_context to session.get() applies the validation to that specific request, while omitting the ssl parameter (using the default behavior) also performs validation using the system's default SSL context. The ClientSession manages connection pooling for async requests, with the SSL context applied during the initial TLS handshake for each connection in the pool. This async pattern is essential for high-performance applications making many concurrent HTTPS requests, and maintaining certificate validation in async code is critical because the performance benefits of async I/O would be meaningless if the connections are vulnerable to MITM attacks. The async with context managers ensure proper cleanup of connections and sessions even if errors occur.
SECURE: Certificate Pinning for High Security
import ssl
import hashlib
import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.poolmanager import PoolManager
# SECURE - Certificate pinning
class PinnedHTTPSAdapter(HTTPAdapter):
def __init__(self, pin_sha256, *args, **kwargs):
self.pin_sha256 = pin_sha256
super().__init__(*args, **kwargs)
def init_poolmanager(self, *args, **kwargs):
context = ssl.create_default_context()
# Set up pinning callback
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
kwargs['ssl_context'] = context
return super().init_poolmanager(*args, **kwargs)
# Pin expected certificate's SHA-256 fingerprint
EXPECTED_PIN = 'sha256_hash_of_expected_certificate'
session = requests.Session()
session.mount('https://', PinnedHTTPSAdapter(EXPECTED_PIN))
# Only accepts pinned certificate
response = session.get('https://api.example.com/data')
Why this works: Certificate pinning adds an additional security layer beyond standard certificate validation by accepting only a specific certificate (or public key hash), protecting against attacks where a Certificate Authority is compromised or coerced into issuing fraudulent certificates for your domain. The PinnedHTTPSAdapter extends requests.HTTPAdapter to inject custom SSL context configuration into the connection pool, with the pin_sha256 parameter storing the expected SHA-256 hash of the legitimate certificate's public key. During the TLS handshake, the adapter can verify the presented certificate matches the pinned hash before completing the connection. While this example shows the structure, a complete implementation would need to extract and compare the certificate hash in a callback. Certificate pinning is recommended for high-security applications (banking, healthcare, government) communicating with a small number of known servers, but it requires operational overhead to update pins when certificates rotate. The session.mount() call applies this custom adapter to all HTTPS requests through that session, ensuring consistent pinning enforcement.
SECURE: Validation with Custom CA for Internal Services
import requests
import ssl
# SECURE - Internal CA for corporate network
INTERNAL_CA_PATH = '/etc/ssl/certs/internal-ca.crt'
# For requests library
response = requests.get(
'https://internal-api.company.local/data',
verify=INTERNAL_CA_PATH
)
# For urllib with ssl context
context = ssl.create_default_context(cafile=INTERNAL_CA_PATH)
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
Why this works: Many corporate and internal networks use private Certificate Authorities to sign certificates for internal services, and specifying the internal CA certificate path via verify=INTERNAL_CA_PATH (for requests) or cafile=INTERNAL_CA_PATH (for ssl.create_default_context()) enables secure HTTPS connections to these services without disabling validation. The internal CA certificate (typically in PEM format) becomes the trust anchor for validating the certificate chain presented by internal servers, performing the same validation checks (chain of trust, expiration, hostname) as public CAs but rooted in the organization's private CA. This approach is dramatically more secure than verify=False because it maintains authentication, encryption, and integrity properties of HTTPS while accommodating the internal PKI. The check_hostname=True and verify_mode=ssl.CERT_REQUIRED settings ensure hostname verification still occurs, preventing an attacker from presenting a different valid internal certificate. Organizations should distribute the internal CA certificate securely to client systems and applications, ideally installing it in the system trust store rather than bundling it with application code.
Key Security Functions
Validation Checker
import requests
import urllib3
import ssl
def check_ssl_config(session):
"""Check if SSL validation is properly configured"""
if isinstance(session, requests.Session):
# Check requests session
for adapter in session.adapters.values():
if hasattr(adapter, 'init_poolmanager'):
# Check if verification disabled
if getattr(adapter, 'verify', True) is False:
raise ValueError("SSL verification is disabled!")
return True
def audit_ssl_context(context):
"""Audit SSL context configuration"""
if not isinstance(context, ssl.SSLContext):
raise TypeError("Invalid SSL context")
issues = []
if context.verify_mode == ssl.CERT_NONE:
issues.append("Certificate verification disabled (CERT_NONE)")
if not context.check_hostname:
issues.append("Hostname verification disabled")
if context.verify_mode != ssl.CERT_REQUIRED:
issues.append(f"Weak verification mode: {context.verify_mode}")
if issues:
raise ValueError(f"SSL configuration issues: {', '.join(issues)}")
return True
Safe Request Wrapper
import requests
import certifi
from typing import Optional
def safe_https_request(url: str, method: str = 'GET', **kwargs) -> requests.Response:
"""
Make HTTPS request with enforced certificate validation
Args:
url: Target URL (must be HTTPS)
method: HTTP method
**kwargs: Additional arguments for requests
Returns:
Response object
Raises:
ValueError: If attempting to disable validation or using HTTP
"""
# Enforce HTTPS
if not url.startswith('https://'):
raise ValueError("Only HTTPS URLs allowed")
# Prevent disabling validation
if 'verify' in kwargs and kwargs['verify'] is False:
raise ValueError("Cannot disable certificate validation")
# Use certifi CA bundle if not specified
if 'verify' not in kwargs:
kwargs['verify'] = certifi.where()
# Make request
response = requests.request(method, url, **kwargs)
# Verify connection used TLS
if not response.url.startswith('https://'):
raise ValueError("Connection did not use HTTPS")
return response
# Usage
response = safe_https_request('https://api.example.com/data')
Certificate Information Extractor
import ssl
import socket
from datetime import datetime
def get_certificate_info(hostname: str, port: int = 443) -> dict:
"""
Extract certificate information from server
Args:
hostname: Server hostname
port: Server port (default 443)
Returns:
Dictionary with certificate details
"""
context = ssl.create_default_context()
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
# Extract key information
info = {
'subject': dict(x[0] for x in cert['subject']),
'issuer': dict(x[0] for x in cert['issuer']),
'version': cert['version'],
'serial_number': cert['serialNumber'],
'not_before': cert['notBefore'],
'not_after': cert['notAfter'],
'subject_alt_names': cert.get('subjectAltName', [])
}
# Check expiration
not_after = datetime.strptime(cert['notAfter'], '%b %d %H:%M:%S %Y %Z')
days_until_expiry = (not_after - datetime.now()).days
info['days_until_expiry'] = days_until_expiry
info['is_expired'] = days_until_expiry < 0
return info
# Usage
cert_info = get_certificate_info('api.example.com')
print(f"Certificate expires in {cert_info['days_until_expiry']} days")
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
Analysis Steps
- Locate the certificate validation bypass:
# Line 34 in api/client.py
def fetch_user_data(user_id):
api_url = f'https://api.internal.company.com/users/{user_id}'
response = requests.get(api_url, verify=False) # VULNERABLE
return response.json()
- Identify why validation was disabled:
- Development workaround for self-signed certificate?
- Internal CA not in system trust store?
- "Quick fix" that made it to production?
- Assess the risk:
- API returns sensitive user data
- Connection over HTTPS but validation disabled
- Vulnerable to MITM on corporate network
- Impact: High (data exposure)
- Determine proper fix:
- Internal API likely uses internal CA
- Need to add internal CA to trust store
- Or specify CA bundle in verify parameter
Remediation Steps
Step 1: Identify the proper CA certificate
# Get the certificate from the server
openssl s_client -connect api.internal.company.com:443 -showcerts
# Extract CA certificate
# Save to /etc/ssl/certs/internal-ca.crt
Step 2: Fix the code
# BEFORE (Line 34 - vulnerable)
def fetch_user_data(user_id):
api_url = f'https://api.internal.company.com/users/{user_id}'
response = requests.get(api_url, verify=False) # VULNERABLE
return response.json()
# AFTER (fixed)
import os
# Path to internal CA certificate
INTERNAL_CA = os.getenv('INTERNAL_CA_CERT', '/etc/ssl/certs/internal-ca.crt')
def fetch_user_data(user_id):
api_url = f'https://api.internal.company.com/users/{user_id}'
response = requests.get(api_url, verify=INTERNAL_CA) # SECURE
return response.json()
Step 3: Add CA to system trust store (alternative)
# On Linux
sudo cp internal-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# Now can use verify=True (system default)
Step 4: Re-scan
Verification
After remediation:
- No
verify=Falsein production code - No
ssl.CERT_NONEusage - No
check_hostname=Falsesettings - Using proper CA bundle for internal services
- Scanner re-scan shows finding resolved
- Integration tests pass with validation enabled
- Certificate expiration monitoring in place
Security Checklist
- Never use
verify=Falsewith requests/httpx - Never use
ssl.CERT_NONEfor cert_reqs - Never set
check_hostname=False - Never suppress urllib3 SSL warnings
- Use default SSL context (
ssl.create_default_context()) - Use
verify=Trueor specify CA bundle path - For internal CAs, add to system trust store or specify path
- Remove all development SSL bypass code before production
- Use certificate pinning for high-security scenarios
- Monitor certificate expiration dates
- Run Bandit to detect validation bypasses
- Test with badssl.com for various certificate issues
- Never allow environment variables to control SSL verification