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 leaves the connection encrypted but the server unauthenticated, so anyone positioned on the network can intercept, read, and modify the traffic - a man-in-the-middle (MITM) attack. 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 disables certificate validation, so an attacker positioned on the network can present a self-signed or fraudulent certificate and read whatever the request carries - credentials, API keys, user data - over a connection that still looks like HTTPS to the caller.
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' tells urllib3 to skip both chain verification and hostname matching, so any certificate an interceptor presents is accepted and nothing in the client reports it.
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: ssl.create_default_context() starts out secure, but setting check_hostname=False and verify_mode=ssl.CERT_NONE turns off the chain check and the hostname check, so the connection accepts any certificate an interceptor presents.
Legacy ssl.wrap_socket() Usage
import socket
import ssl
# VULNERABLE - legacy raw SSL socket; cert_reqs defaults to CERT_NONE
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
wrapped = ssl.wrap_socket(sock) # Removed in Python 3.12
wrapped.connect(('api.example.com', 443))
# VULNERABLE - no better with cert_reqs spelled out
wrapped = ssl.wrap_socket(sock, cert_reqs=ssl.CERT_NONE)
wrapped.connect(('api.example.com', 443))
wrapped.send(b'POST /api/login HTTP/1.1\r\n...\r\n')
Why this is vulnerable: The module-level ssl.wrap_socket() defaulted to cert_reqs=ssl.CERT_NONE and never checked the hostname, so it accepted self-signed, expired, and wrong-host certificates. It was removed in Python 3.12, but code predating that still runs on older interpreters, and a mechanical port to SSLContext.wrap_socket() carries the second half of the bug forward - see the next pattern.
SSLContext.wrap_socket() Without server_hostname
import socket
import ssl
context = ssl.create_default_context()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('api.example.com', 443))
# VULNERABLE - no server_hostname, so check_hostname has to be turned off
context.check_hostname = False
wrapped = context.wrap_socket(sock)
Why this is vulnerable: SSLContext.wrap_socket() only checks the hostname when you pass server_hostname. Without it a context with check_hostname=True raises ValueError: check_hostname requires server_hostname, so the usual "fix" is to clear the flag - which leaves the chain check in place and accepts a certificate issued for any other name. Nothing here matches a verify=False scanner rule, verify_mode is still CERT_REQUIRED (2), and the connection is fully interceptable by anyone holding a valid certificate for a domain they control. Measured on Python 3.13 against a trusted certificate issued for localhost and served on 127.0.0.1: check_hostname=True with server_hostname='127.0.0.1' raises SSLCertVerificationError: IP address mismatch, and this snippet completes the handshake. Pass server_hostname= instead of clearing the flag.
Monkeypatching ssl._create_default_https_context
import ssl
# VULNERABLE - process-wide: every stdlib HTTPS client stops validating
ssl._create_default_https_context = ssl._create_unverified_context
# VULNERABLE - same effect at a single call site
context = ssl._create_unverified_context()
Why this is vulnerable: ssl._create_default_https_context is the factory http.client.HTTPSConnection calls when no context is passed, so it is also what urllib.request and xmlrpc.client end up using. Reassigning it disables chain and hostname validation for every such connection in the process, including ones inside third-party packages, from a single line that names no URL and no client. Measured on Python 3.13 against a self-signed server: with the patch applied at import time, urllib.request.urlopen() and http.client.HTTPSConnection both return 200. Note the ordering - urllib.request caches its opener, so patching after the first request leaves that path validating and produces a codebase where the same bypass appears to work in one module and not another.
It does not reach requests, httpx or aiohttp, which build their own contexts, so a codebase can carry this and a verify=False and need both fixed. Note also that PYTHONHTTPSVERIFY=0, which is often quoted as the environment-variable form of this, is a Python 2.7 feature from PEP 493 and has no effect on Python 3 - ssl.py does not read it. Confirmed on 3.13: the same request still fails with CERTIFICATE_VERIFY_FAILED.
Trust Stores Redirected Through the Environment
# VULNERABLE - both make the process trust whatever CA is in that file
SSL_CERT_FILE=/tmp/rogue-ca.pem python app.py # stdlib / OpenSSL default paths
REQUESTS_CA_BUNDLE=/tmp/rogue-ca.pem python app.py # requests, and pip
Why this is vulnerable: These are the environment-level bypasses that Python actually has, and they are worse than a disable switch because validation stays on: the connection is verified, against a trust anchor somebody else chose. Measured on Python 3.13 against a self-signed server, both return 200 where the unset baseline raises SSLError, and the two are separate - SSL_CERT_FILE moves ssl.create_default_context() and everything built on it, REQUESTS_CA_BUNDLE moves requests only, so setting one and testing the other proves nothing.
The legitimate use is exactly the same command, which is why this is a review question rather than a grep. Ask where the file comes from and who can write it: a corporate CA delivered by configuration management is fine, a path under /tmp, a build artefact directory, or anywhere the application itself can write is not. CURL_CA_BUNDLE behaves the same way for requests and for anything shelling out to curl.
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 aiohttp.ClientSession() as session:
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.
urllib3 with assert_hostname=False
import urllib3
import certifi
# VULNERABLE - chain is verified, hostname is not
http = urllib3.PoolManager(
cert_reqs='CERT_REQUIRED',
ca_certs=certifi.where(),
assert_hostname=False
)
response = http.request('GET', 'https://api.example.com/data')
Why this is vulnerable: This is the half-disabled case, and it is the one that survives review. cert_reqs='CERT_REQUIRED' and a real CA bundle are both present, so the code reads as correct and the rules that look for CERT_NONE or verify=False stay quiet - but assert_hostname=False means any certificate that chains to a public CA is accepted, including one an attacker legitimately obtained for a domain they own. Chain validation without hostname validation authenticates nobody. The same trap is reachable through ssl.SSLContext.check_hostname = False and, on a raw socket, by omitting server_hostname.
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: The switch is the vulnerability, not the person who flips it. Whoever is on call during a certificate incident will set VERIFY_SSL=false to restore service, and nothing afterwards puts it back: it lives in a deployment manifest rather than in code, so no review sees it, no diff records it, and the application logs nothing to say validation stopped. Fix the certificate instead, and give internal CAs a bundle path - a variable naming a CA file fails loudly when it is wrong, where a boolean fails silently.
Secure Patterns
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 default, verify=True, verifies the certificate chain, checks the validity dates, and confirms 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.
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: A CA bundle path in the verify parameter validates against internal or private Certificate Authorities that Requests' default certifi bundle does not carry. When the internal service is signed by a corporate CA, pointing verify at that CA certificate file (in PEM format) keeps chain, date, and hostname validation in place, which is what verify=False throws away to solve the same problem.
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: cert_reqs='CERT_REQUIRED' requires the server to present a certificate that chains to a trusted CA, and ca_certs=certifi.where() says which CAs those are. 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.
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: ssl.create_default_context() returns an SSLContext already set to verify_mode=ssl.CERT_REQUIRED (a valid certificate is required) and check_hostname=True (the hostname must match the certificate), with the trusted CA certificates loaded from the system's certificate store. It was added in Python 3.4 so that secure settings are what a caller gets without asking, rather than something to remember to switch on. Setting check_hostname=True and verify_mode=ssl.CERT_REQUIRED again, even though they are the defaults, documents the intent and keeps them enabled if a future Python version changes its defaults. Passed to urllib.request.urlopen(), the context is used for the TLS handshake, which then rejects invalid, expired, self-signed, and hostname-mismatched certificates. This lower-level approach gives more control than requests and requires more careful handling of the SSL context.
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: httpx validates certificates by default through verify=True, covering the chain, the expiry dates, and the hostname. 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.
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.
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.
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: Corporate and internal networks commonly use a private Certificate Authority to sign certificates for internal services. Naming that CA through verify=INTERNAL_CA_PATH (for requests) or cafile=INTERNAL_CA_PATH (for ssl.create_default_context()) reaches those services over HTTPS without disabling validation: the internal CA certificate (typically in PEM format) becomes the trust anchor for the chain the internal server presents, and the same checks run as for a public CA - chain of trust, expiration, hostname. Where verify=False gives up authentication entirely, this keeps it, rooted in the organization's own PKI. check_hostname=True and verify_mode=ssl.CERT_REQUIRED keep hostname verification on, so an attacker holding a different valid internal certificate is still rejected. Distribute the internal CA certificate to client systems through a managed system trust store or a 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):
"""Reject a Session whose default disables verification.
`verify` lives on the Session, not on its adapters - an HTTPAdapter has no
such attribute, so probing the adapters always reports "configured".
Requests treats *any* falsy value as "do not verify", so test for
truthiness rather than comparing against False.
"""
if not isinstance(session, requests.Session):
raise TypeError("Expected a requests.Session")
if not session.verify:
raise ValueError(f"SSL verification is disabled (verify={session.verify!r})")
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_REQUIRED:
issues.append(f"Certificate verification not required: {context.verify_mode!r}")
# Checked separately: a context can require a valid chain and still accept a
# certificate issued for any other hostname.
if not context.check_hostname:
issues.append("Hostname verification disabled")
if issues:
raise ValueError(f"SSL configuration issues: {', '.join(issues)}")
return True
Safe Request Wrapper
import requests
from urllib.parse import urljoin
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")
# Any falsy verify disables validation, not just False: requests checks
# `if verify:`, so 0 and '' skip the certificate check exactly like False.
if not kwargs.setdefault('verify', True):
raise ValueError("Cannot disable certificate validation")
# Follow redirects by hand so a downgrade to http:// is refused BEFORE the
# request is replayed. requests' own redirect handling would resend the
# method, body and cookies over cleartext, and a check on response.url
# afterwards reports a leak that has already happened.
kwargs['allow_redirects'] = False
for _ in range(5):
response = requests.request(method, url, **kwargs)
if not response.is_redirect:
return response
# Location is frequently relative ("/login"), so resolve it against the
# URL that produced it before judging the scheme. Testing the raw header
# rejects every relative redirect, which is a working application broken
# by a security fix.
url = urljoin(response.url, response.headers['location'])
if not url.startswith('https://'):
raise ValueError(f"Refusing redirect to non-HTTPS URL: {url}")
raise ValueError("Too many redirects")
# Usage
response = safe_https_request('https://api.example.com/data')
Certificate Information Extractor
import ssl
import socket
from datetime import datetime, timezone
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. notAfter is always GMT and strptime's %Z
# discards it, so attach UTC rather than comparing against local time.
not_after = datetime.strptime(
cert['notAfter'], '%b %d %H:%M:%S %Y %Z'
).replace(tzinfo=timezone.utc)
days_until_expiry = (not_after - datetime.now(timezone.utc)).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: Obtain the CA certificate from the PKI, not from the endpoint
Get the root CA from whoever operates it - the PKI team, configuration management, or the platform's secret store - and verify its fingerprint against a value they publish out of band:
# Confirm you were given a CA, and that it is the one you were told to expect
openssl x509 -in internal-ca.crt -noout -subject -issuer -fingerprint -sha256
openssl x509 -in internal-ca.crt -noout -text | grep -A1 "Basic Constraints"
# expect: CA:TRUE, and a fingerprint matching the published value
openssl s_client -connect host:443 -showcerts is the right tool for inspecting
what a server presents, and it is worth running to see which chain the endpoint
actually serves. It is the wrong source for a trust anchor twice over: servers
normally send the leaf and intermediates and omit the root, so the certificate you
want is usually not in the output; and taking a trust anchor from the endpoint you
are trying to authenticate is circular - an attacker already in position hands you
the CA that makes their own certificate verify.
# Inspection only - shows the chain the server sends, root usually absent
openssl s_client -connect api.internal.company.com:443 -showcerts </dev/null
# Install the CA you obtained and verified above
sudo cp internal-ca.crt /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
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.