CWE-295: Improper Certificate Validation - Go
Overview
Improper certificate validation vulnerabilities in Go applications occur when TLS/SSL certificate verification is disabled or implemented incorrectly, allowing man-in-the-middle (MITM) attacks. When establishing HTTPS connections, proper certificate validation ensures you're communicating with the legitimate server, not an attacker intercepting traffic. Go's crypto/tls package provides strong default certificate validation, but developers often disable it during development or when encountering certificate errors, creating severe security vulnerabilities.
The most dangerous pattern is setting InsecureSkipVerify: true in tls.Config, which disables all certificate validation. This allows attackers on the network path to intercept, read, and modify all traffic by presenting their own certificates. The connection is still encrypted, but the encryption protects against the attacker performing the MITM attack, not passive eavesdroppers. Other common mistakes include failing to verify certificate hostnames, accepting expired certificates, not checking certificate chains properly, or implementing custom verification logic with flaws.
These vulnerabilities are particularly critical in microservices architectures where services communicate over mutual TLS, in mobile apps making HTTPS requests, and when connecting to databases, message queues, or APIs over TLS. The impact includes credential theft (authentication tokens, passwords), sensitive data exposure, code injection (if attackers modify responses containing code/configuration), and complete compromise of encrypted communications.
Primary Defence: Never use InsecureSkipVerify: true in production. Use Go's default certificate validation which checks certificate chains, expiry, and hostname matching. For custom certificate authorities, add them to the trusted root store via tls.Config.RootCAs. For mutual TLS, properly configure client certificates. Always validate that certificate hostnames match the requested domain.
Common Vulnerable Patterns
InsecureSkipVerify in Production
// VULNERABLE - Disabling certificate verification
package main
import (
"crypto/tls"
"fmt"
"io"
"net/http"
)
func fetchData(url string) ([]byte, error) {
// DANGEROUS: Disable certificate verification
tr := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // NEVER DO THIS
},
}
client := &http.Client{Transport: tr}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func main() {
// VULNERABLE: All HTTPS requests are vulnerable to MITM
data, _ := fetchData("https://api.example.com/sensitive-data")
fmt.Printf("Data: %s\n", data)
}
// ATTACK:
// Attacker on network (WiFi, ISP, compromised router) intercepts connection
// Presents their own certificate, which client accepts without validation
// Attacker reads all data, including credentials, API keys, personal information
// Can also modify responses to inject malicious code/data
Why this is vulnerable: InsecureSkipVerify: true tells Go to accept any certificate, regardless of validity, expiration, or hostname. An attacker performing a MITM attack can present their own certificate (self-signed or for a different domain), intercept the encrypted connection, decrypt all traffic, read/modify it, re-encrypt with the legitimate server's connection, and forward it. The client has no way to detect this attack. This completely defeats the purpose of TLS encryption.
Conditional Verification Based on Environment
// VULNERABLE - Environment-based skip verification
import (
"crypto/tls"
"net/http"
"os"
)
func createHTTPClient() *http.Client {
// DANGEROUS: Different behavior in dev vs prod
config := &tls.Config{}
if os.Getenv("ENVIRONMENT") == "development" {
// VULNERABLE: Insecure config in dev
config.InsecureSkipVerify = true
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}
}
// RISK:
// 1. Dev environment still handles real data - vulnerable to MITM
// 2. Environment variable might not be set correctly in production
// 3. Code designed to work insecurely makes it easy to ship vulnerabilities
Why this is vulnerable: Development and staging environments often handle real customer data and credentials. MITM attacks are possible on development networks too (coffee shop WiFi, shared networks, compromised dev machines). Additionally, relying on environment variables creates risk - if ENVIRONMENT isn't set or is set incorrectly (e.g., "dev", "Development", "DEV" instead of "development"), the insecure code path executes in production. It also creates a culture where insecure configurations are normalized.
Custom Verification with Flawed Logic
// VULNERABLE - Flawed custom certificate verification
import (
"crypto/tls"
"crypto/x509"
"log"
"net/http"
)
func createClientWithCustomVerify() *http.Client {
config := &tls.Config{
InsecureSkipVerify: true, // Disable default verification
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
// DANGEROUS: Reimplementing verification incorrectly
if len(rawCerts) == 0 {
return fmt.Errorf("no certificates")
}
cert, err := x509.ParseCertificate(rawCerts[0])
if err != nil {
return err
}
// VULNERABLE: Only checks subject, not chain, expiry, or hostname
if cert.Subject.Organization[0] == "Example Corp" {
return nil
}
return fmt.Errorf("unknown organization")
},
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}
}
// VULNERABILITY:
// - Doesn't verify certificate chain (trust path to root CA)
// - Doesn't check expiration date
// - Doesn't verify hostname matches requested domain
// - Organization can be spoofed by attacker
Why this is vulnerable: Certificate validation is complex - checking signatures, chains of trust, expiration dates, hostname matching, revocation status, and more. Setting InsecureSkipVerify: true and implementing VerifyPeerCertificate bypasses Go's robust default validation. The custom implementation only checks organization name, which attackers can easily forge in their own certificates. Without chain verification, the certificate doesn't need to be signed by a trusted CA. Without expiry checks, compromised old certificates remain valid. Without hostname verification, certificates for other domains are accepted.
Accepting Self-Signed Certificates Globally
// VULNERABLE - Adding attacker's certificate to root CAs
import (
"crypto/tls"
"crypto/x509"
"io/ioutil"
"net/http"
)
func clientTrustingAnyCert() *http.Client {
// Load a "self-signed" certificate
cert, _ := ioutil.ReadFile("untrusted-cert.pem")
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(cert)
// DANGEROUS: Trusting any certificate user provides
config := &tls.Config{
RootCAs: certPool,
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}
}
// RISK:
// If attacker can write to cert file location, they can install their CA
// Then perform MITM attacks on all connections using this client
Why this is vulnerable: While using custom root CAs is sometimes necessary (internal PKI, testing), loading certificates without proper validation is dangerous. If attackers can control the certificate file (via path traversal, configuration injection, or file upload vulnerabilities), they can install their own CA certificate, making the application trust any certificate they sign. The application should have a hardcoded or securely-delivered set of trusted CAs, not dynamically load arbitrary certificate files.
Secure Patterns
Use Default Certificate Validation
// SECURE - Go's default certificate validation
package main
import (
"fmt"
"io"
"net/http"
)
func secureFetchData(url string) ([]byte, error) {
// SECURE: Create standard HTTP client with default TLS config
// No custom TLS configuration = secure defaults
client := &http.Client{}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func main() {
data, err := secureFetchData("https://api.example.com/data")
if err != nil {
// Handle error - don't disable verification to "fix" it
fmt.Printf("Request failed: %v\n", err)
return
}
fmt.Printf("Data: %s\n", data)
}
Why this works: Go's default HTTP client uses comprehensive certificate validation: verifies certificate chains up to trusted root CAs, checks expiration dates, validates hostname matches the requested URL, and ensures proper cryptographic signatures. The system's certificate store (or Go's embedded roots) contains trusted Certificate Authorities. No custom tls.Config means no opportunity for misconfiguration. If certificate validation fails, the error indicates a real problem (expired cert, wrong hostname, untrusted CA) that must be fixed at the source, not bypassed in code.
Custom Root CAs for Internal PKI
// SECURE - Properly configuring custom root CAs
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
)
// SECURE: Embed trusted CA certificate in binary
const internalCA = `-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKL0UG+mRKU7MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
... (your internal CA certificate) ...
-----END CERTIFICATE-----`
func createSecureInternalClient() (*http.Client, error) {
// SECURE: Parse embedded CA certificate
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM([]byte(internalCA)) {
return nil, fmt.Errorf("failed to parse CA certificate")
}
// SECURE: Use custom CA pool while maintaining other validations
config := &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
// InsecureSkipVerify: false (default, explicit for clarity)
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}, nil
}
func fetchInternalData(url string) ([]byte, error) {
client, err := createSecureInternalClient()
if err != nil {
return nil, err
}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Why this works: The internal CA certificate is embedded directly in the application binary as a constant, preventing tampering. x509.NewCertPool() creates a trusted certificate pool, and AppendCertsFromPEM() adds the internal CA. The tls.Config uses this custom pool while maintaining all other validation (chain verification, expiry, hostname). Server certificates signed by the internal CA will be trusted, but all other validation steps still occur. MinVersion: tls.VersionTLS12 enforces minimum TLS version. This is the correct approach for internal services using private PKI.
System CA Pool Plus Custom CAs
// SECURE - Combine system CAs with custom CAs
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
)
func createCombinedCAClient() (*http.Client, error) {
// SECURE: Start with system CA pool
certPool, err := x509.SystemCertPool()
if err != nil {
// Fallback to empty pool if system pool unavailable
certPool = x509.NewCertPool()
}
// Add internal CA(s) to system pool
internalCAs := []string{
`-----BEGIN CERTIFICATE-----
... internal CA 1 ...
-----END CERTIFICATE-----`,
`-----BEGIN CERTIFICATE-----
... internal CA 2 ...
-----END CERTIFICATE-----`,
}
for i, ca := range internalCAs {
if !certPool.AppendCertsFromPEM([]byte(ca)) {
return nil, fmt.Errorf("failed to parse CA %d", i)
}
}
config := &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
},
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}, nil
}
Why this works: x509.SystemCertPool() loads the operating system's trusted root certificates (public CAs like DigiCert, Let's Encrypt, etc.), ensuring the client can connect to public HTTPS sites. Custom internal CAs are appended to this pool, allowing connections to both public and internal services. This avoids the vulnerability of the previous "custom CA only" pattern where public sites would fail. MinVersion: tls.VersionTLS12 disables older, insecure TLS versions. CipherSuites explicitly selects strong ciphers with forward secrecy (ECDHE) and authenticated encryption (GCM).
Mutual TLS with Client Certificates
// SECURE - Mutual TLS (mTLS) with client certificates
import (
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net/http"
)
const (
// Server's CA certificate (to verify server)
serverCA = `-----BEGIN CERTIFICATE-----
... server CA certificate ...
-----END CERTIFICATE-----`
// Client certificate (to authenticate to server)
clientCert = `-----BEGIN CERTIFICATE-----
... client certificate ...
-----END CERTIFICATE-----`
// Client private key
clientKey = `-----BEGIN RSA PRIVATE KEY-----
... client private key ...
-----END RSA PRIVATE KEY-----`
)
func createMutualTLSClient() (*http.Client, error) {
// SECURE: Load server CA pool
serverCAPool := x509.NewCertPool()
if !serverCAPool.AppendCertsFromPEM([]byte(serverCA)) {
return nil, fmt.Errorf("failed to parse server CA")
}
// SECURE: Load client certificate and key
cert, err := tls.X509KeyPair([]byte(clientCert), []byte(clientKey))
if err != nil {
return nil, fmt.Errorf("failed to load client cert: %w", err)
}
config := &tls.Config{
RootCAs: serverCAPool,
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}, nil
}
func callMutualTLSService(url string) ([]byte, error) {
client, err := createMutualTLSClient()
if err != nil {
return nil, err
}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Why this works: Mutual TLS provides bidirectional authentication - the client verifies the server's certificate (preventing MITM), and the server verifies the client's certificate (authenticating the client). RootCAs contains the CA that signed the server's certificate, enabling server verification. Certificates contains the client's certificate and private key, presented to the server during the TLS handshake. The server validates the client certificate against its trusted CA pool. This is secure alternatives to API keys for service-to-service authentication in microservices architectures.
Custom Server Name Verification
// SECURE - Verifying certificate hostname for specific scenarios
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
)
func createClientWithSNIOverride(serverName string) *http.Client {
config := &tls.Config{
ServerName: serverName, // Override SNI for IP-based connections
MinVersion: tls.VersionTLS12,
// Default verification still occurs
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}
}
// Use case: Connecting to IP address but verifying specific hostname
func connectToIPWithHostnameVerification() error {
// Server certificate is for "api.example.com" but connecting via IP
client := createClientWithSNIOverride("api.example.com")
// Connect via IP, but verify certificate is for api.example.com
resp, err := client.Get("https://192.0.2.10/data")
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
Why this works: ServerName sets the Server Name Indication (SNI) and the expected hostname for certificate verification. This allows connecting to servers by IP address while still validating the certificate hostname. The TLS handshake sends "api.example.com" as SNI, and certificate validation checks the certificate's Subject Alternative Names (SANs) against "api.example.com", not the IP. Full certificate validation still occurs - chain, expiry, signatures. This is useful for services behind load balancers or when DNS isn't available, while maintaining security.
Testing with Local Certificates
// SECURE - Proper testing with local certificates
// +build integration
package main_test
import (
"crypto/tls"
"crypto/x509"
"net/http"
"net/http/httptest"
"testing"
)
func TestHTTPSEndpoint(t *testing.T) {
// SECURE: Use httptest with TLS for integration tests
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("test response"))
}))
defer server.Close()
// SECURE: Trust the test server's certificate explicitly
// This is safe because server.Client() provides properly configured client
client := server.Client()
resp, err := client.Get(server.URL)
if err != nil {
t.Fatalf("Request failed: %v", err)
}
defer resp.Body.Close()
// Test assertions
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected 200, got %d", resp.StatusCode)
}
}
// Alternative: Manual test client configuration
func createTestClient(serverCert *x509.Certificate) *http.Client {
certPool := x509.NewCertPool()
certPool.AddCert(serverCert)
config := &tls.Config{
RootCAs: certPool,
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: config,
},
}
}
Why this works: httptest.NewTLSServer creates a test server with a self-signed certificate and provides server.Client(), which is pre-configured to trust that specific certificate. This allows testing HTTPS endpoints without disabling verification. The test client trusts only the test server's certificate, not all certificates. Tests remain secure and don't require InsecureSkipVerify. For manual configuration, certPool.AddCert() adds the specific test certificate to a custom pool, maintaining full validation for that certificate.