Skip to content

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

  1. 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()
    
  2. 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?
  1. Assess the risk:
  • API returns sensitive user data
  • Connection over HTTPS but validation disabled
  • Vulnerable to MITM on corporate network
  • Impact: High (data exposure)
  1. 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 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: 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

bandit -r api/client.py
# Should show no B501 findings

Verification

After remediation:

  • No verify=False in application code
  • No ssl.CERT_NONE usage
  • No check_hostname=False settings
  • 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=False with requests/httpx
  • Never use ssl.CERT_NONE for cert_reqs
  • Never set check_hostname=False
  • Never suppress urllib3 SSL warnings
  • Use default SSL context (ssl.create_default_context())
  • Use verify=True or 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.

Additional Resources