mirror of
https://github.com/tailscale/tailscale.git
synced 2025-07-31 00:03:47 +00:00
net/tlsdial: record a health warning when MitM is detected
When system roots verify a certificate for any *.tailscale.com and *.tailscale.io domain, but Let's Encrypt doesn't, report a health warning. This can be intentional, like a corporate DPI proxy, or it can be a sign of an attack. Updates https://github.com/tailscale/corp/issues/24852 Updates #3198 Signed-off-by: Andrew Lytvynov <awly@tailscale.com>
This commit is contained in:
parent
bb80f14ff4
commit
602ea59818
@ -20,6 +20,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -55,6 +56,17 @@ var mitmBlockWarnable = health.Register(&health.Warnable{
|
||||
ImpactsConnectivity: true,
|
||||
})
|
||||
|
||||
var mitmDetectWarnable = health.Register(&health.Warnable{
|
||||
Code: "mitm-detected",
|
||||
Title: "Network may be intercepting Tailscale traffic",
|
||||
Text: func(args health.Args) string {
|
||||
return fmt.Sprintf("This network may be intercepting Tailscale TLS traffic. The TLS certificate for one of Tailscale's domains is signed by %q, but all real Tailscale domains use Let's Encrypt. If this is not an intentional setup connect to another network, or contact your network administrator for assistance.", args["issuer"])
|
||||
},
|
||||
Severity: health.SeverityMedium,
|
||||
})
|
||||
|
||||
var systemCertPool = x509.SystemCertPool
|
||||
|
||||
// Config returns a tls.Config for connecting to a server.
|
||||
// If base is non-nil, it's cloned as the base config before
|
||||
// being configured and returned.
|
||||
@ -98,12 +110,12 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
// Perform some health checks on this certificate before we do
|
||||
// any verification.
|
||||
var cert *x509.Certificate
|
||||
var selfSignedIssuer string
|
||||
var issuer string
|
||||
var selfSignedIssuer bool
|
||||
if certs := cs.PeerCertificates; len(certs) > 0 {
|
||||
cert = certs[0]
|
||||
if certIsSelfSigned(cert) {
|
||||
selfSignedIssuer = cert.Issuer.String()
|
||||
}
|
||||
issuer = cert.Issuer.CommonName
|
||||
selfSignedIssuer = certIsSelfSigned(cert)
|
||||
}
|
||||
if ht != nil {
|
||||
defer func() {
|
||||
@ -120,18 +132,18 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
} else {
|
||||
ht.SetHealthy(mitmBlockWarnable)
|
||||
}
|
||||
if retErr != nil && selfSignedIssuer != "" {
|
||||
if retErr != nil && selfSignedIssuer {
|
||||
// Self-signed certs are never valid.
|
||||
//
|
||||
// TODO(bradfitz): plumb down the selfSignedIssuer as a
|
||||
// structured health warning argument.
|
||||
ht.SetTLSConnectionError(cs.ServerName, fmt.Errorf("likely intercepted connection; certificate is self-signed by %v", selfSignedIssuer))
|
||||
ht.SetTLSConnectionError(cs.ServerName, fmt.Errorf("likely intercepted connection; certificate is self-signed by %v", issuer))
|
||||
} else {
|
||||
// Ensure we clear any error state for this ServerName.
|
||||
ht.SetTLSConnectionError(cs.ServerName, nil)
|
||||
if selfSignedIssuer != "" {
|
||||
if selfSignedIssuer {
|
||||
// Log the self-signed issuer, but don't treat it as an error.
|
||||
log.Printf("tlsdial: warning: server cert for %q passed x509 validation but is self-signed by %q", host, selfSignedIssuer)
|
||||
log.Printf("tlsdial: warning: server cert for %q passed x509 validation but is self-signed by %q", host, issuer)
|
||||
}
|
||||
}
|
||||
}()
|
||||
@ -143,6 +155,11 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
DNSName: cs.ServerName,
|
||||
Intermediates: x509.NewCertPool(),
|
||||
}
|
||||
sysRoots, err := systemCertPool()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed loading system CA roots: %w", err)
|
||||
}
|
||||
opts.Roots = sysRoots
|
||||
for _, cert := range cs.PeerCertificates[1:] {
|
||||
opts.Intermediates.AddCert(cert)
|
||||
}
|
||||
@ -167,6 +184,19 @@ func Config(host string, ht *health.Tracker, base *tls.Config) *tls.Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
// If validation with system roots succeeded but Let's Encrypt roots
|
||||
// didn't, then we have some custom CA in system roots that's used to
|
||||
// impersonate a Tailscale domain.
|
||||
if strings.HasSuffix(cs.ServerName, "tailscale.com") || strings.HasSuffix(cs.ServerName, "tailscale.io") {
|
||||
// TODO(awly): there should be an MDM or prefs toggle to disable
|
||||
// this detection, for companies that legitimately do stuff like
|
||||
// DPI.
|
||||
if errSys == nil && bakedErr != nil {
|
||||
ht.SetUnhealthy(mitmDetectWarnable, health.Args{"issuer": issuer})
|
||||
} else {
|
||||
ht.SetHealthy(mitmDetectWarnable)
|
||||
}
|
||||
}
|
||||
|
||||
if errSys == nil {
|
||||
return nil
|
||||
|
@ -4,10 +4,13 @@
|
||||
package tlsdial
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@ -40,30 +43,7 @@ func TestFallbackRootWorks(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("test assumes Linux")
|
||||
}
|
||||
d := t.TempDir()
|
||||
crtFile := filepath.Join(d, "tlsdial.test.crt")
|
||||
keyFile := filepath.Join(d, "tlsdial.test.key")
|
||||
caFile := filepath.Join(d, "rootCA.pem")
|
||||
cmd := exec.Command("go",
|
||||
"run", "filippo.io/mkcert",
|
||||
"--cert-file="+crtFile,
|
||||
"--key-file="+keyFile,
|
||||
"tlsdial.test")
|
||||
cmd.Env = append(os.Environ(), "CAROOT="+d)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("mkcert: %v, %s", err, out)
|
||||
}
|
||||
if debug {
|
||||
t.Logf("Ran: %s", out)
|
||||
dents, err := os.ReadDir(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, de := range dents {
|
||||
t.Logf(" - %v", de)
|
||||
}
|
||||
}
|
||||
crtFile, keyFile, caFile := mkcert(t, "tlsdial.test")
|
||||
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
@ -128,6 +108,81 @@ func TestFallbackRootWorks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMITMDetection(t *testing.T) {
|
||||
defer resetOnce()
|
||||
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("test assumes Linux")
|
||||
}
|
||||
crtFile, keyFile, caFile := mkcert(t, "test.tailscale.com")
|
||||
|
||||
oldSystemCertPool := systemCertPool
|
||||
defer func() { systemCertPool = oldSystemCertPool }()
|
||||
systemCertPool = func() (*x509.CertPool, error) {
|
||||
roots := x509.NewCertPool()
|
||||
caPEM, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roots.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("failed to parse CA file %q", caFile)
|
||||
}
|
||||
return roots, nil
|
||||
}
|
||||
|
||||
crt, err := tls.LoadX509KeyPair(crtFile, keyFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewUnstartedServer(http.HandlerFunc(sayHi))
|
||||
srv.TLS = &tls.Config{
|
||||
Certificates: []tls.Certificate{crt},
|
||||
}
|
||||
srv.StartTLS()
|
||||
defer srv.Close()
|
||||
|
||||
srv.Client()
|
||||
ht := new(health.Tracker)
|
||||
c := &http.Client{Transport: &http.Transport{
|
||||
Dial: func(network, addr string) (net.Conn, error) {
|
||||
return net.Dial("tcp", srv.Listener.Addr().String())
|
||||
},
|
||||
TLSClientConfig: Config("test.tailscale.com", ht, nil),
|
||||
}}
|
||||
|
||||
res, err := c.Get("https://test.tailscale.com/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatal(res.Status)
|
||||
}
|
||||
|
||||
detected := ht.CurrentState().Warnings[mitmDetectWarnable.Code].BrokenSince != nil
|
||||
if !detected {
|
||||
t.Errorf("mitmDetectWarnable did not become unhealthy after the request")
|
||||
}
|
||||
}
|
||||
|
||||
func mkcert(t *testing.T, domain string) (crtFile, keyFile, caFile string) {
|
||||
d := t.TempDir()
|
||||
crtFile = filepath.Join(d, domain+".crt")
|
||||
keyFile = filepath.Join(d, domain+".key")
|
||||
caFile = filepath.Join(d, "rootCA.pem")
|
||||
cmd := exec.Command("go",
|
||||
"run", "filippo.io/mkcert",
|
||||
"--cert-file="+crtFile,
|
||||
"--key-file="+keyFile,
|
||||
domain)
|
||||
cmd.Env = append(os.Environ(), "CAROOT="+d)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("mkcert: %v, %s", err, out)
|
||||
}
|
||||
return crtFile, keyFile, caFile
|
||||
}
|
||||
|
||||
func sayHi(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "hi")
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user