Never pass verify=False. Pass verify= the path of the partner's private CA bundle on every request: requests then checks the chain against that CA alone, the dates and the host name, and a per-request value cannot be replaced by REQUESTS_CA_BUNDLE. Never switch host checks off with assert_hostname=False.
- Find every place that loosens TLS: verify=False on requests calls and sessions, urllib3.disable_warnings, assert_hostname=False and cert_reqs='CERT_NONE' in urllib3 or adapters, ssl._create_unverified_context, and check_hostname = False with CERT_NONE on an SSLContext.
- For a public service, delete the override: requests verifies against certifi's bundle by default.
- For a private service, pass verify='/path/to/partner-ca.pem' (a PEM bundle; a directory must be processed with c_rehash) on each request that calls it. That makes the partner's CA the only trust anchor for those calls and leaves every other check on.
- Prefer the per-request argument to Session.verify. requests lets REQUESTS_CA_BUNDLE or CURL_CA_BUNDLE in the environment replace Session.verify (psf/requests issue 3829, open), but not a verify= passed on the call. If you do use a Session, also set session.trust_env = False.
- Leave host name checking alone. If the certificate does not name the host you connect to, add the name to its subjectAltName or connect by the name it carries.
- With httpx, pass verify=ssl.create_default_context(cafile=...) to the client; with urllib.request, pass context=ssl.create_default_context(cafile=...). Both keep check_hostname and CERT_REQUIRED on.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def get_rates(base_url: str, token: str, ca_file: str) -> dict:
# Staging failed with CERTIFICATE_VERIFY_FAILED, so verification was switched off and
# the InsecureRequestWarning silenced. This is the bug: every certificate is accepted.
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(
f"{base_url}/v1/rates",
headers={"Authorization": f"Bearer {token}"},
verify=False,
timeout=5,
)
response.raise_for_status()
return response.json()With IMPL=vulnerable, 5 of 6 tests fail (5 exploit tests).
The fix: trust only the partner's CA
def get_rates(base_url: str, token: str, ca_file: str) -> dict:
# verify=<path to a PEM bundle> replaces certifi's public roots with the partner's CA
# for this request; requests still checks the chain, the dates and the host name.
# Passed per request, it also cannot be replaced by REQUESTS_CA_BUNDLE or
# CURL_CA_BUNDLE from the environment.
response = requests.get(
f"{base_url}/v1/rates",
headers={"Authorization": f"Bearer {token}"},
verify=ca_file,
timeout=5,
)
response.raise_for_status()
return response.json()With IMPL=fixed, 6 of 6 tests pass.
A fix that looks safe but is not
class PartnerAdapter(HTTPAdapter):
"""We reach the partner through an internal alias, so the name in its certificate never
matched. This turns off only the host name check; the chain is still verified."""
def init_poolmanager(self, *args, **kwargs):
kwargs["assert_hostname"] = False
super().init_poolmanager(*args, **kwargs)
def get_rates(base_url: str, token: str, ca_file: str) -> dict:
with requests.Session() as session:
session.mount("https://", PartnerAdapter())
response = session.get(
f"{base_url}/v1/rates",
headers={"Authorization": f"Bearer {token}"},
verify=ca_file,
timeout=5,
)
response.raise_for_status()
return response.json()With IMPL=wrong, 1 of 6 tests fail (1 exploit test).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Each server below answers at api.partner.test but must not be trusted. The client has to
# refuse the TLS handshake before it sends the request carrying the bearer token.
def test_exploit_self_signed_certificate_for_the_partner_host_is_refused(rates_via):
assert_refused(*rates_via("selfSigned"))
def test_exploit_certificate_from_the_trusted_ca_for_another_host_is_refused(rates_via):
assert_refused(*rates_via("otherHost"))
def test_exploit_certificate_from_an_unknown_ca_named_like_the_trusted_ca_is_refused(rates_via):
assert_refused(*rates_via("rogueCa"))
def test_exploit_expired_certificate_from_the_trusted_ca_is_refused(rates_via):
assert_refused(*rates_via("expired"))
# Python only: requests lets REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) replace Session.verify. An
# environment that trusts another CA, here the look-alike one, must not widen the pinned CA.
def test_exploit_requests_ca_bundle_in_the_environment_does_not_replace_the_pinned_ca(rates_via, pki, monkeypatch):
monkeypatch.setenv("REQUESTS_CA_BUNDLE", pki["ca_files"]["rogue"])
assert_refused(*rates_via("rogueCa"))- verify=False accepts every certificate: self-signed, expired, issued by anyone, for any host. Silencing InsecureRequestWarning hides the only signal that it is happening.
- An HTTPAdapter that sets assert_hostname=False keeps the chain check but accepts any certificate the trusted CA has issued, for any host. Against certifi's public roots, that is any certificate any public CA has issued to anyone.
- check_hostname = False means different things in different clients. On an SSLContext passed to urllib3, it does not turn host checks off: urllib3 2.8.0 then matches the name itself unless assert_hostname=False (connection.py). With urllib.request, which uses the context as given, it does turn them off. Both were checked against this card's certificates.
- requests before 2.32.0 kept using an unverified connection for an origin after one verify=False request in the same Session, even with verify=True later (CVE-2024-35195). Pin requests 2.32.0 or later.
- REQUESTS_CA_BUNDLE replaces Session.verify, so an environment that trusts another CA silently widens a pinned session. This card's test sets it to the look-alike CA; the fixed code passes because it passes verify= on the call.
- Python 3.13 and later add VERIFY_X509_STRICT to ssl.create_default_context(), and urllib3 2.x sets it too, so a private CA that breaks RFC 5280 starts failing: a CA certificate without keyUsage or a leaf without authorityKeyIdentifier is refused (checked with Python 3.14.8 and OpenSSL 3.6.4). Reissue the certificates rather than clearing the flag.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs tls-certificate-validation/python-flask does this for you).
- The exploit tests serve a self-signed certificate for api.partner.test, a trusted-CA certificate for billing.partner.test, a certificate from an unknown CA named like the trusted one and an expired certificate, and check that GET /rates returns 502 and no request reached the server. One more test sets REQUESTS_CA_BUNDLE to the look-alike CA first.
- Against your own service, point the client at a server with a self-signed certificate for the real name and expect CERTIFICATE_VERIFY_FAILED (self-signed certificate), then at a valid certificate for another name and expect 'Hostname mismatch'.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.requests.security.disabled-cert-validation.disabled-cert-validation | 1 finding: impl/vulnerable.py line 14 (requests.get(..., verify=False)). None on fixed.py or wrong.py: the rule matches verify=False on the requests module functions, so it misses a Session (session.verify = False) and the wrong implementation's assert_hostname=False adapter.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/python.requests.security.disabled-cert-validation.disabled-cert-validation python-flask/impl; also part of p/security-audit, which found nothing else |
| Semgrep | python.lang.security.audit.network.disabled-cert-validation.disabled-cert-validation | No findings. It matches cert_reqs set to CERT_NONE or CERT_OPTIONAL (urllib3 and ssl), which none of the implementations uses; assert_hostname=False is not covered.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/python.lang.security.audit.network.disabled-cert-validation.disabled-cert-validation python-flask/impl |
| CodeQL | py/request-without-cert-validation | From the query and its Requests.qll and Urllib3.qll models: it reports a falsy verify= on a requests call, which covers vulnerable.py. Its urllib3 model reports assert_hostname=False only as a keyword on a PoolManager or HTTPSConnectionPool constructor in the analysed code; wrong.py passes it through HTTPAdapter.init_poolmanager kwargs into requests' own code, so a report is unlikely.Checked: github/codeql main at b5b8165; not executed |
| Bandit | B501request_with_no_cert_validation | 1 finding: impl/vulnerable.py line 17, HIGH severity, HIGH confidence, CWE-295. Nothing on fixed.py or wrong.py, and no other plugin fired.Checked: Bandit 1.9.4; bandit -r python-flask/impl (all plugins) |
| urllib3 runtime | n/aInsecureRequestWarning | urllib3 warns on every unverified HTTPS request, which is why real code with verify=False usually also calls urllib3.disable_warnings(), as vulnerable.py does. Nothing warns about assert_hostname=False.Checked: urllib3 2.8.0 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| requests | 2.34.2 |
| urllib3 | 2.8.0 |
| cryptography (test PKI only) | 50.0.2 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the tls-certificate-validation/python-flaskfolder.
python3 -m venv .venv
.venv/bin/python -m pip install --require-hashes -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs tls-certificate-validation/python-flask runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- flask 3.1.3
- werkzeug 3.1.9
- requests 2.34.2
- urllib3 2.8.0
- cryptography 50.0.2
- pytest 9.1.1
- Matrix
- fixed: 6 of 6 tests pass; vulnerable: 5 of 6 tests fail (5 exploit tests); wrong: 1 of 6 tests fail (1 exploit test)
- Test results
- fixed: 6/6 pass; vulnerable: 5/6 fail (5 exploit); wrong: 1/6 fail (1 exploit)
- Dependency freshness
- requirements.txt pins all 21 packages with sha256 hashes for every published file. cryptography 50.0.2 (2026-09-30) is taken at 10 days old because its wheels bundle OpenSSL 4.0.3, which fixed 14 CVEs on 2026-09-29; Werkzeug 3.1.9 (2026-09-27) is taken at 13 days old because it fixes GHSA-g6x2-hccm-hh4m. charset-normalizer 3.5.1, MarkupSafe 3.0.3, iniconfig 2.3.0 and pycparser 3.0 are used instead of newer releases under 14 days old that fix no security issue. OSV listed no advisories for the pinned versions. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.
- Notes
- The tests resolve *.partner.test to 127.0.0.1 by patching socket.getaddrinfo, which urllib3 uses to connect; the host name requests verifies is unchanged. Every other name fails to resolve inside the tests.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Requests: SSL Cert Verification (verify, REQUESTS_CA_BUNDLE)
- psf/requests issue 3829: Session.verify ignored when REQUESTS_CA_BUNDLE is set
- GHSA-9wx4-h78v-vm56 (CVE-2024-35195): Session keeps verify=False for later requests
- urllib3: HTTPSConnectionPool (assert_hostname, cert_reqs)
- Python ssl: create_default_context, check_hostname, VERIFY_X509_STRICT
- OWASP Transport Layer Security Cheat Sheet
- CWE-295: Improper Certificate Validation
- CWE-297: Improper Validation of Certificate with Host Mismatch
Attack patterns: