3Station 3 of 6: TLS
The certificate nobody checked
TLS without verification encrypts the connection to whoever answers. An impostor with any certificate can intercept every request and response.
$ uv run python -m wire run 3$ uv run python -m wire check 3Goal#
Fix an HTTPS client that accepts any certificate from any server, allowing an attacker to intercept API calls without the client noticing.
The idea#
What TLS provides
TLS (Transport Layer Security) wraps TCP connections to provide two guarantees: encryption and authentication. Encryption stops eavesdroppers from reading the data. Authentication proves you are talking to the server you intended, not an impostor.
Every HTTPS request uses TLS. The S in HTTPS means the connection is secured with TLS. Without TLS, an attacker on the network can read every byte: passwords, session cookies, API keys, personal data. With TLS but without verification, an attacker can still read everything by pretending to be the server.
Why encryption without authentication is not enough
When a TLS handshake starts, the server sends its certificate. The certificate contains:
- The server's public key.
- The domain names it is valid for (in the Common Name or Subject Alternative Names field).
- The signature of a Certificate Authority (CA) that vouches for the binding between the domain and the public key.
- Validity dates (not before, not after).
The client must check:
- Trust. Is the certificate signed by a CA in the system trust store?
- Name. Does the certificate name match the domain we connected to?
- Validity. Is the certificate within its date range and not revoked?
If any check fails, the connection must be refused. If all pass, the client uses the public key to complete the handshake and encrypt the session.
Without verification, TLS encrypts the connection to whoever answers. An attacker with any certificate (self-signed, expired, for a different domain) can terminate the TLS connection, read and modify every request and response, and forward them to the real server. Your client sees a successful handshake and sends secrets over the connection.
How a TLS handshake works
1. Client -> Server: ClientHello (supported ciphers, TLS version)
2. Server -> Client: ServerHello (chosen cipher)
Certificate (the server's certificate chain)
ServerKeyExchange (ephemeral DH parameters for forward secrecy)
ServerHelloDone
3. Client validates the certificate:
- Check signature chain leads to a trusted CA
- Check the domain name matches
- Check dates and revocation status
4. Client -> Server: ClientKeyExchange (client's DH public key)
ChangeCipherSpec
Finished (encrypted with the session key)
5. Server -> Client: ChangeCipherSpec
Finished (encrypted with the session key)
6. Application data flows, encrypted with the negotiated session key.
Step 3 is where verification happens. If the client skips it, steps 4 through 6 still work, but the client has no idea who it is talking to.
What people get wrong
Disabling verification for debugging. A developer hits a certificate error in a test environment, sets verify=False or check_hostname=False to make it go away, and the setting ends up in production. The fix is to never disable verification. For dev environments, use a real certificate from Let's Encrypt (free and automated) or add a private CA certificate to the system trust store.
Ignoring hostname mismatches. A certificate for example.com is valid but the client connects to api.example.com. Some clients see the signature is valid and accept it. The fix is hostname checking: the certificate must name the exact domain you connected to, either in the Common Name or Subject Alternative Names extension.
Trusting any CA. Some clients load every certificate they find into the trust store. An attacker with a certificate from any CA, even one that should not be trusted, can intercept connections. The fix is to use the system trust store or a curated list of CAs, and never add untrusted roots.
How production systems handle TLS
System trust stores. Operating systems and browsers ship with a list of trusted root CAs (around 100 to 150). When a client validates a certificate, it checks whether the signature chain leads to one of these roots. On Linux, the trust store is usually /etc/ssl/certs/ca-certificates.crt or /etc/pki/tls/certs/ca-bundle.crt. On macOS, it is the Keychain. On Windows, it is the Certificate Store. Keep the trust store up to date; CAs are added and removed over time.
Let's Encrypt. A free, automated Certificate Authority that issues certificates trusted by all major browsers and operating systems. It uses the ACME protocol (RFC 8555) to verify domain ownership and issue certificates. Certificates are valid for 90 days and must be renewed automatically. For production, use Let's Encrypt or another ACME CA unless you have a specific need for a commercial CA.
Certificate pinning. Some high-security applications pin the certificate or public key: the client refuses to connect unless the server presents a specific certificate or a certificate signed by a specific CA. Pinning stops attacks where the attacker compromises a CA. It also stops certificate rotation, so it requires careful automation. Use it only if you understand the operational cost.
mTLS (mutual TLS). The server also validates the client's certificate. Both sides prove their identity. This is common in service-to-service communication where both ends are controlled systems (microservices, API gateways, internal tools). It is rare for browser-to-server connections.
TLS 1.3. The latest version (RFC 8446, 2018) simplifies the handshake, requires forward secrecy, and removes weak cipher suites. Most clients and servers now support TLS 1.3. Older versions (TLS 1.0, TLS 1.1) are deprecated and should be disabled. TLS 1.2 is still widely supported but less efficient than 1.3.
The case#
Run the case to see the weakness:
uv run python -m wire run 3
case 03: The certificate nobody checked
the real API (signed by the lab CA) connected, 200 tokens received: 1
an impostor (self-signed for 127.0.0.1) connected, 200 tokens received: 1
a real certificate for other.test connected, 200 tokens received: 1
TLS without verification encrypts the connection to whoever answers.
The client connected to three servers: the real API with a valid certificate, an impostor with a self-signed certificate, and a server with a valid certificate for the wrong hostname. All three handshakes succeeded, and the client sent its API key to all three.
Reasoning towards the fix#
Open lab/cases/case03_tls.py. The make_context function creates the TLS settings for every outgoing HTTPS call. The fetch function connects to a server and sends a bearer token.
As written, make_context creates a default context with ssl.create_default_context(), but then explicitly disables both checks: check_hostname = False and verify_mode = ssl.CERT_NONE. This turns off all validation.
The tour function calls three servers: the real API with a valid certificate signed by the lab CA, an impostor with a self-signed certificate, and a server with a valid certificate for a different hostname. All three handshakes succeed because nothing is checked.
To fix it:
- Remove the lines that set
check_hostname = Falseandverify_mode = ssl.CERT_NONE. - Use
ssl.create_default_context(cafile=CA)to trust the lab's certificate authority.CAis imported fromwire.tlsand points tocerts/ca.pem.
The default context already has check_hostname = True and verify_mode = ssl.CERT_REQUIRED. By loading the CA file, you tell the context which root certificate to trust. The hostname check happens automatically when HTTPSConnection connects.
The checks start servers with a self-signed certificate and a certificate for the wrong name. With verification enabled, the handshake must fail with an SSL error.
Check your fix#
Once you have made the changes, check them:
uv run python -m wire check 3
As shipped, the check output is:
PASS the real API still works (connected, 200)
FAIL a self-signed impostor is refused (connected, 200, tokens: 1)
FAIL a valid certificate for another name is refused (connected, 200, tokens: 1)
not yet. Try: uv run python -m wire hint 3
When fixed, all lines show PASS. The client accepts the real certificate and rejects the self-signed certificate and the wrong-hostname certificate.
Try it on your own machine#
Test your own services. Try connecting with curl --insecure or requests.get(..., verify=False), and see if you notice the difference. Then try with verification on, and confirm that invalid certificates are rejected.
curl https://self-signed.badssl.com/
curl --insecure https://self-signed.badssl.com/
Check your codebase for verify=False, check_hostname = False, or CERT_NONE. Every one is a vulnerability.
In production#
Disabling certificate verification is never the right fix. If you are hitting certificate errors, the reasons are usually:
- Self-signed certificate for testing. Use a real certificate from Let's Encrypt. It is free, automated, and trusted by every client.
- Internal CA not in the system's trust store. Add the CA to the trust store, or load it explicitly in the client's
SSLContext. Do not disable verification. - Hostname mismatch. Fix the certificate to include the correct hostname in the Subject Alternative Names. Do not disable hostname checking.
- Expired certificate. Renew it. Certificate lifetimes are short now (90 days for Let's Encrypt), and automation is expected.
To keep verification working:
- Use
ssl.create_default_context()and do not changecheck_hostnameorverify_mode. - For internal services with private CAs, pass
cafiletocreate_default_contextto trust that CA, or add it to the system store. Do not disable verification. - Monitor certificate expiry. Automate renewal with ACME (Let's Encrypt, ZeroSSL) or your CA's API.
- If you must skip verification in a development environment, make it impossible to deploy that configuration to production. Fail the build if
verify=Falseappears in production code.
There is no good reason to disable verification in production. If you think you have one, you are solving the wrong problem.
Key points#
- TLS without verification encrypts the connection to whoever answers, not who you intended.
- Certificate verification checks trust, hostname, and validity. All three must pass.
ssl.create_default_context()does the right thing. Do not changecheck_hostnameorverify_mode.- For private CAs, pass
cafiletocreate_default_context. Do not disable verification. - In production, use real certificates from a trusted CA. Do not disable verification.