Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix TLS certificate validation in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Leave verification on and change only the trust anchor: put the partner's CA in x509.NewCertPool(), set it as tls.Config.RootCAs with MinVersion tls.VersionTLS12, and keep InsecureSkipVerify false and ServerName empty so crypto/tls checks the chain, the dates and the host name. Do not rebuild those checks in VerifyConnection or VerifyPeerCertificate.

Stack
  • Language Go
  • Runtime Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
  • Framework Go standard library 1.27.2 (net/http client, crypto/tls, crypto/x509)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Search for InsecureSkipVerify: true, VerifyPeerCertificate and VerifyConnection callbacks, RootCAs built from x509.SystemCertPool() plus extra certificates, and every http.Transport, tls.Dial and grpc credential that takes a tls.Config.
  2. For a public service, delete InsecureSkipVerify and leave RootCAs nil, which uses the host's root CAs.
  3. For a private service, read its CA bundle (PEM), add it to x509.NewCertPool() and fail if AppendCertsFromPEM returns false, which means no certificate was found. Set the pool as RootCAs on that one client's Transport.
  4. Leave ServerName empty for net/http, which sets it from the URL's host. If you connect by IP address or an alias, set ServerName to the name the certificate carries, or reissue the certificate with that name.
  5. Set MinVersion: tls.VersionTLS12 (or TLS 1.3) so the floor is in the code, not in the defaults.
  6. If you need an extra check, such as pinning a key, keep normal verification on and add it in VerifyConnection, which runs after it and on resumed connections.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.goGo
func (Vulnerable) TLSConfig(caPEM []byte) (*tls.Config, error) {
	// Staging failed with "x509: certificate signed by unknown authority", so verification
	// was turned off. This is the bug: every certificate is accepted, for any host name.
	return &tls.Config{InsecureSkipVerify: true}, nil
}

With IMPL=vulnerable, 4 of 5 tests fail (4 exploit tests).

The fix: trust only the partner's CA

Fixedimpl/fixed.goGo
func (Fixed) TLSConfig(caPEM []byte) (*tls.Config, error) {
	// A new pool, not x509.SystemCertPool(): no public CA, and nothing that SSL_CERT_FILE or
	// SSL_CERT_DIR adds to the system pool, can vouch for the partner.
	roots := x509.NewCertPool()
	if !roots.AppendCertsFromPEM(caPEM) {
		return nil, errors.New("the partner CA file holds no PEM certificate")
	}
	return &tls.Config{
		RootCAs:    roots,
		MinVersion: tls.VersionTLS12,
		// InsecureSkipVerify stays false. ServerName stays empty, so net/http sets it to the
		// URL's host and crypto/tls checks it against the certificate's DNS names.
	}, nil
}

With IMPL=fixed, 5 of 5 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.goGo
func (Wrong) TLSConfig(caPEM []byte) (*tls.Config, error) {
	roots := x509.NewCertPool()
	if !roots.AppendCertsFromPEM(caPEM) {
		return nil, errors.New("the partner CA file holds no PEM certificate")
	}
	return &tls.Config{
		MinVersion: tls.VersionTLS12,
		// We reach the partner through an internal alias, so every call failed with
		// "x509: certificate is valid for ..., not ...". Verifying the chain ourselves,
		// without DNSName, made the error go away.
		InsecureSkipVerify: true,
		VerifyConnection: func(cs tls.ConnectionState) error {
			opts := x509.VerifyOptions{Roots: roots, Intermediates: x509.NewCertPool()}
			for _, cert := range cs.PeerCertificates[1:] {
				opts.Intermediates.AddCert(cert)
			}
			_, err := cs.PeerCertificates[0].Verify(opts)
			return err
		},
	}, nil
}

With IMPL=wrong, 1 of 5 tests fail (1 exploit test).

The regression 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.

Testapp_test.goGo
// 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.

func TestExploitASelfSignedCertificateForThePartnerHostNameIsRefused(t *testing.T) {
	assertRefused(t, "selfSigned")
}

func TestExploitACertificateFromTheTrustedCAForAnotherHostNameIsRefused(t *testing.T) {
	assertRefused(t, "otherHost")
}

func TestExploitACertificateFromAnUnknownCANamedLikeTheTrustedCAIsRefused(t *testing.T) {
	assertRefused(t, "rogueCa")
}

func TestExploitAnExpiredCertificateFromTheTrustedCAIsRefused(t *testing.T) {
	assertRefused(t, "expired")
}

Pitfalls

  • InsecureSkipVerify turns off the chain check and the host name check together. The crypto/tls example that pairs it with VerifyConnection rebuilds both with x509.VerifyOptions; leave out DNSName, as wrong.go does, and any certificate the CA issued for any host passes.
  • VerifyPeerCertificate is not called on resumed connections, so a check that replaces normal verification there can be skipped. VerifyConnection runs for every connection, including resumptions (gosec G123 looks for the first pattern).
  • x509.SystemCertPool() plus AppendCertsFromPEM trusts every public CA, as well as the partner's, to vouch for the partner, and SSL_CERT_FILE and SSL_CERT_DIR change what SystemCertPool returns (on macOS and Windows too since Go 1.27). Use a new pool for a private service.
  • Go ignores the certificate's Common Name and checks only its DNS and IP subject alternative names, so a certificate without a matching SAN fails. Reissue it rather than turn the check off.
  • Go has no process-wide switch like NODE_TLS_REJECT_UNAUTHORIZED: verification is off only where code sets InsecureSkipVerify. Search vendored and internal packages that build their own http.Client too.
  • CodeQL's go/disabled-certificate-check skips functions and variables whose names contain verif, validat or trust, so do not rely on it alone.

Verify the fix

  1. 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/go-nethttp does this for you, in Docker if you do not have Go).
  2. The exploit tests present a self-signed certificate for the right name, a certificate from the trusted CA for another name, one from an unknown CA named like the trusted CA, and an expired one; each must end in 502 with no request, and so no bearer token, reaching the server.
  3. Run gosec ./... and semgrep --config r/problem-based-packs.insecure-transport.go-stdlib.bypass-tls-verification.bypass-tls-verification .; expect findings only in impl/vulnerable.go and impl/wrong.go. Neither sees a missing host name check that does not set InsecureSkipVerify.
  4. Against your own service, point the client at a server with a self-signed certificate for the real name and at one with a valid certificate for a different name (openssl s_server will do). Both handshakes must fail.

Scanner rules

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.

Scanner rules for Fix TLS certificate validation in Go and net/http
ToolRuleWhat it found here
Semgrepproblem-based-packs.insecure-transport.go-stdlib.bypass-tls-verification.bypass-tls-verificationFlagged the tls.Config literal with InsecureSkipVerify: true in impl/vulnerable.go and in impl/wrong.go; nothing in impl/fixed.go. It flags wrong.go because that also sets InsecureSkipVerify, not because it sees the missing host name check, which is the real flaw there.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> .
Semgrepgo.lang.security.audit.crypto.missing-ssl-minversion.missing-ssl-minversionOne finding: the tls.Config in impl/vulnerable.go has no MinVersion. The fixed and wrong configurations set tls.VersionTLS12. Go's client minimum is already TLS 1.2 by default, so this is hygiene, not the card's bug; p/golang reported only this.Checked: Semgrep 1.180.0 (CE)
CodeQLgo/disabled-certificate-checkTwo results: InsecureSkipVerify set to true in impl/vulnerable.go and in impl/wrong.go; nothing in impl/fixed.go. The query skips test code, writes guarded by a feature flag, and functions or variables whose names match selfcert, selfsign, validat, verif or trust, which it takes as deliberate: a helper called verifyPartner that sets InsecureSkipVerify would not be reported.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165)
CodeQLgo/insecure-tlsNo results: it reports MinVersion or MaxVersion values below TLS 1.2 and insecure cipher suites, and the cards set TLS 1.2 or nothing. It does not look at certificate checks.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12
gosecG402Look for bad TLS connection settingsFlagged InsecureSkipVerify set to true in impl/vulnerable.go and impl/wrong.go (HIGH severity, HIGH confidence); nothing in impl/fixed.go. It did not report the missing MinVersion in vulnerable.go.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./...
gosecG123TLS resumption may bypass VerifyPeerCertificate when VerifyConnection is unsetNo findings: the wrong implementation uses VerifyConnection, which crypto/tls runs on resumed connections too. A VerifyPeerCertificate callback in its place is what this rule looks for.Checked: gosec 2.29.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http client, crypto/tls, crypto/x509)
crypto/tls, crypto/x509, net/http (standard library)Go 1.27.2
gotest.tools/gotestsum (JUnit report, a go.mod tool)1.13.0
golang.org/x/text (raised for gotestsum)0.42.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the tls-certificate-validation/go-nethttpfolder.

Shell
go mod download
go build ./... tool
Shell
# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...

Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go mod download && go build ./... tool'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=fixed \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=vulnerable \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -v fix-go-gomod:/go/pkg/mod \
  -v fix-go-gocache:/root/.cache/go-build \
  -e IMPL=wrong \
  golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
  sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs tls-certificate-validation/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
Toolchain
  • go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
  • gotestsum 1.13.0
  • semgrep 1.180.0
  • gosec 2.29.0
  • codeql CLI 2.27.1, codeql/go-queries 1.6.12
  • govulncheck 1.8.0
Matrix
fixed: 5 of 5 tests pass; vulnerable: 4 of 5 tests fail (4 exploit tests); wrong: 1 of 5 tests fail (1 exploit test)
Test results
fixed 5/5 pass; vulnerable 4/5 fail (4 exploit tests); wrong 1/5 fail (1 exploit test: the trusted CA's certificate for another host), with --network=none after install; the same on Go 1.26.9
Dependency freshness
No third-party module at run time: the client uses crypto/tls, crypto/x509 and net/http. gotestsum 1.13.0 (2025-09-11) runs the tests; it asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds, so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release, with fixes to crypto/tls among others. govulncheck 1.8.0 found no vulnerabilities in the module or the standard library.
Notes
Same scenario, seed (testdata/seed.json, byte for byte the other stacks' file) and test names. The tests mint the PKI with crypto/x509 at start (RSA-2048, as the seed says) and send *.partner.test to a loopback httptest TLS server through the Transport's DialContext, so TLS still sees the partner's name. The wrong implementation is the crypto/tls VerifyConnection example without DNSName (CWE-297), like the Node and Python cards. There is no environment test: Go has no setting that turns verification off, SSL_CERT_FILE and SSL_CERT_DIR change only the system pool, which the fixed client does not use, and crypto/x509 loads that pool once per process, so such a test would depend on test order.

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

  1. Go standard library: crypto/tls Config (InsecureSkipVerify, RootCAs, ServerName, VerifyConnection)
  2. Go standard library: crypto/tls example, VerifyConnection
  3. Go standard library: crypto/x509 SystemCertPool (SSL_CERT_FILE, SSL_CERT_DIR)
  4. Go standard library: crypto/x509 Certificate.VerifyHostname (the Common Name is ignored)
  5. Go 1.27 release notes (crypto/x509: SystemCertPool and SSL_CERT_FILE on macOS and Windows)
  6. OWASP Transport Layer Security Cheat Sheet
  7. RFC 9525: Service Identity in TLS
  8. CWE-297: Improper Validation of Certificate with Host Mismatch
  9. gosec 2.29.0 rules (G402, G123)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.