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: Do not use verify=False in application code; instead, use verify=True (the default) or specify a 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.
Legacy ssl.wrap_socket() Usage
import socket
import ssl
# VULNERABLE - legacy raw SSL socket without validation
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped_socket = ssl.wrap_socket(sock) # Removed in Python 3.12; no hostname 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: The module-level ssl.wrap_socket() API was deprecated and then removed in Python 3.12. In older Python versions it did not send SNI or validate the server hostname, so it accepted connections that modern TLS clients should reject.
No cert validation performed
import socket
import ssl
# VULNERABLE - legacy 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: In legacy Python versions, ssl.wrap_socket() without the cert_reqs parameter defaulted to ssl.CERT_NONE and did not validate the server hostname. This means the connection can accept self-signed, expired, revoked, or wrong-host certificates, defeating the authentication part of HTTPS.
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 Requests' configured CA bundle
# Hostname verified to match certificate
Why this works: The requests library's default verify=True verifies the certificate chain, checks certificate validity dates, and validates that the certificate identity matches the requested hostname. Modern certificates should use Subject Alternative Name (SAN) entries for hostname identity; Common Name-only certificates are legacy and should be replaced. Requests normally uses its configured CA bundle, commonly the certifi bundle, unless a different bundle is configured through verify, REQUESTS_CA_BUNDLE, or environment-specific packaging. Making verify=True explicit in code (even though it is 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 are not included in Requests' default certifi bundle. 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 chain, date, and hostname validation. This approach is superior to verify=False because it maintains the security properties of HTTPS while accommodating non-public CAs.
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 requires the server to present a valid certificate that chains to a trusted CA, with ca_certs=certifi.where() specifying which CA certificates are trusted. Unlike CERT_NONE, CERT_REQUIRED causes the connection to fail if the certificate cannot be validated. Keep hostname verification enabled; do not pair this with assert_hostname=False.
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 with certificate validation enabled by default through verify=True, providing certificate chain validation, expiration checking, and hostname verification. Explicitly passing a CA bundle or ssl.SSLContext is appropriate when you need an internal trust store; passing verify=False disables those checks and should not be used.
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: aiohttp uses strict checks for HTTPS by default. A custom ssl.SSLContext is only needed when you must configure a specific trust store, such as a corporate CA or certifi bundle. Passing this context to the request or connector preserves certificate validation and hostname checking; using ssl=False disables SSL checks.
SECURE: Certificate Pinning for High Security
Certificate pinning is an additional control for a small number of high-value, stable endpoints. Keep normal CA and hostname validation enabled, then add pin enforcement using a maintained library or a carefully reviewed transport implementation that compares the validated peer certificate's public key/SPKI hash against current and backup pins.
Why this works: Pinning can reduce exposure to CA mis-issuance or CA compromise, but it has real operational risk. Prefer public key/SPKI pins over leaf-certificate fingerprints, maintain backup pins, automate rotation, and test expiry and rollover before enabling 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 through a managed system trust store or configured CA bundle rather than ad hoc copies in 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
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")
# 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")
Analysis Steps
-
Locate the certificate validation bypass:
-
Identify why validation was disabled:
- Development workaround for self-signed certificate?
- Internal CA not in the CA bundle used by this client?
- "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 the CA bundle used by the client
- Or specify the CA bundle in the
verifyparameter
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: Configure the CA bundle used by Requests (alternative)
# On Linux, add the corporate CA to the system bundle
sudo cp internal-ca.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# Then point Requests at that bundle if your Requests packaging does not use it automatically
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
Step 4: Re-scan
Verification
After remediation:
- No
verify=Falsein application 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 the CA bundle used by the client or specify the path explicitly
- Remove all development SSL bypass code before production
- Use certificate pinning only for high-security scenarios with backup pins and a rotation plan
- 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
Kubernetes API Client (kubernetes library)
Applications that communicate with the Kubernetes API server via the official kubernetes Python client can also bypass certificate validation.
from kubernetes import client, config as k8s_config
# VULNERABLE - Disabling TLS verification
configuration = client.Configuration()
configuration.host = "https://kubernetes.api.example.com"
configuration.verify_ssl = False # VULNERABLE - disables all certificate validation
configuration.api_key['authorization'] = 'Bearer ' + token
v1 = client.CoreV1Api(client.ApiClient(configuration))
Why this is vulnerable: verify_ssl = False disables all certificate validation for API server communication, exposing service account tokens and all cluster traffic to MITM attacks. The connection is encrypted but the server's identity is not verified.
from kubernetes import client, config as k8s_config
# SECURE - Use in-cluster config when running as a pod
# Automatically loads:
# CA bundle: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
# Token: /var/run/secrets/kubernetes.io/serviceaccount/token
k8s_config.load_incluster_config()
v1 = client.CoreV1Api()
pods = v1.list_namespaced_pod(namespace='default')
# SECURE - Load kubeconfig for out-of-cluster usage (CI/CD, local dev)
k8s_config.load_kube_config(config_file='/path/to/kubeconfig')
# Reject kubeconfig files that have insecure-skip-tls-verify: true
configuration = client.Configuration.get_default_copy()
if not configuration.verify_ssl:
raise ValueError("Kubeconfig has TLS verification disabled - fix insecure-skip-tls-verify")
v1 = client.CoreV1Api()
Why this works: load_incluster_config() reads the service account CA bundle and token automatically mounted by Kubernetes, providing certificate-validated communication without manual TLS configuration. For out-of-cluster use, load_kube_config() honours the cluster CA defined in the kubeconfig file. The explicit verify_ssl check guards against kubeconfig files that have insecure-skip-tls-verify: true set.