mirror of https://github.com/tailscale/tailscale/
net/tlsdial: fix TLS cert validation of HTTPS proxies
If you had HTTPS_PROXY=https://some-valid-cert.example.com running a CONNECT proxy, we should've been able to do a TLS CONNECT request to e.g. controlplane.tailscale.com:443 through that, and I'm pretty sure it used to work, but refactorings and lack of integration tests made it regress. It probably regressed when we added the baked-in LetsEncrypt root cert validation fallback code, which was testing against the wrong hostname (the ultimate one, not the one which we were being asked to validate) Fixes #16222 Change-Id: If014e395f830e2f87f056f588edacad5c15e91bc Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>pull/16321/head
parent
4979ce7a94
commit
e92eb6b17b
@ -0,0 +1,81 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// The proxy-test-server command is a simple HTTP proxy server for testing
|
||||
// Tailscale's client proxy functionality.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"tailscale.com/net/connectproxy"
|
||||
"tailscale.com/tempfork/acme"
|
||||
)
|
||||
|
||||
var (
|
||||
listen = flag.String("listen", ":8080", "Address to listen on for HTTPS proxy requests")
|
||||
hostname = flag.String("hostname", "localhost", "Hostname for the proxy server")
|
||||
tailscaleOnly = flag.Bool("tailscale-only", true, "Restrict proxy to Tailscale targets only")
|
||||
extraAllowedHosts = flag.String("allow-hosts", "", "Comma-separated list of allowed target hosts to additionally allow if --tailscale-only is true")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
am := &autocert.Manager{
|
||||
HostPolicy: autocert.HostWhitelist(*hostname),
|
||||
Prompt: autocert.AcceptTOS,
|
||||
Cache: autocert.DirCache(os.ExpandEnv("$HOME/.cache/autocert/proxy-test-server")),
|
||||
}
|
||||
var allowTarget func(hostPort string) error
|
||||
if *tailscaleOnly {
|
||||
allowTarget = func(hostPort string) error {
|
||||
host, port, err := net.SplitHostPort(hostPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid target %q: %v", hostPort, err)
|
||||
}
|
||||
if port != "443" {
|
||||
return fmt.Errorf("target %q must use port 443", hostPort)
|
||||
}
|
||||
for allowed := range strings.SplitSeq(*extraAllowedHosts, ",") {
|
||||
if host == allowed {
|
||||
return nil // explicitly allowed target
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(host, ".tailscale.com") {
|
||||
return fmt.Errorf("target %q is not a Tailscale host", hostPort)
|
||||
}
|
||||
return nil // valid Tailscale target
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := http.ListenAndServe(":http", am.HTTPHandler(nil)); err != nil {
|
||||
log.Fatalf("autocert HTTP server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
hs := &http.Server{
|
||||
Addr: *listen,
|
||||
Handler: &connectproxy.Handler{
|
||||
Check: allowTarget,
|
||||
Logf: log.Printf,
|
||||
},
|
||||
TLSConfig: &tls.Config{
|
||||
GetCertificate: am.GetCertificate,
|
||||
NextProtos: []string{
|
||||
"http/1.1", // enable HTTP/2
|
||||
acme.ALPNProto, // enable tls-alpn ACME challenges
|
||||
},
|
||||
},
|
||||
}
|
||||
log.Printf("Starting proxy-test-server on %s (hostname: %q)\n", *listen, *hostname)
|
||||
log.Fatal(hs.ListenAndServeTLS("", "")) // cert and key are provided by autocert
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package connectproxy contains some CONNECT proxy code.
|
||||
package connectproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"tailscale.com/net/netx"
|
||||
"tailscale.com/types/logger"
|
||||
)
|
||||
|
||||
// Handler is an HTTP CONNECT proxy handler.
|
||||
type Handler struct {
|
||||
// Dial, if non-nil, is an alternate dialer to use
|
||||
// instead of the default dialer.
|
||||
Dial netx.DialFunc
|
||||
|
||||
// Logf, if non-nil, is an alterate logger to
|
||||
// use instead of log.Printf.
|
||||
Logf logger.Logf
|
||||
|
||||
// Check, if non-nil, validates the CONNECT target.
|
||||
Check func(hostPort string) error
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if r.Method != "CONNECT" {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
dial := h.Dial
|
||||
if dial == nil {
|
||||
var d net.Dialer
|
||||
dial = d.DialContext
|
||||
}
|
||||
logf := h.Logf
|
||||
if logf == nil {
|
||||
logf = log.Printf
|
||||
}
|
||||
|
||||
hostPort := r.RequestURI
|
||||
if h.Check != nil {
|
||||
if err := h.Check(hostPort); err != nil {
|
||||
logf("CONNECT target %q not allowed: %v", hostPort, err)
|
||||
http.Error(w, "Invalid CONNECT target", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
back, err := dial(ctx, "tcp", hostPort)
|
||||
if err != nil {
|
||||
logf("error CONNECT dialing %v: %v", hostPort, err)
|
||||
http.Error(w, "Connect failure", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer back.Close()
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "CONNECT hijack unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
c, br, err := hj.Hijack()
|
||||
if err != nil {
|
||||
logf("CONNECT hijack: %v", err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
io.WriteString(c, "HTTP/1.1 200 OK\r\n\r\n")
|
||||
|
||||
errc := make(chan error, 2)
|
||||
go func() {
|
||||
_, err := io.Copy(c, back)
|
||||
errc <- err
|
||||
}()
|
||||
go func() {
|
||||
_, err := io.Copy(back, br)
|
||||
errc <- err
|
||||
}()
|
||||
<-errc
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIHcxOQNVyqvBSSlu7c93QW6OsyccjL+R1evW4acd32MWoAoGCCqGSM49
|
||||
AwEHoUQDQgAEIOY5/CQ8CMuKYPLf+r6OEneqfzQ5RfgPnLdkL22qhm8xb69ZCXxz
|
||||
UecawU0KEDfHLYbUYXSuhAFxxuPh9I3x5Q==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEING1XBDWFXQjqBmLjhp20hXOf2rk/I0N6W7muv9RVvk3oAoGCCqGSM49
|
||||
AwEHoUQDQgAE8lxnEEeLqYikwmXbXSsIQSw20R0oLA831s960KQZEgt0P9SbWcJc
|
||||
QTk98rdfYT/QDdHn157Oh4FPcDtxmdQ4vw==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@ -0,0 +1,5 @@
|
||||
-----BEGIN EC PRIVATE KEY-----
|
||||
MHcCAQEEIMl3xjqt1dnXBpYJSEqevirAcnSJ79I2tucdRazlrDG9oAoGCCqGSM49
|
||||
AwEHoUQDQgAEQ/+Jme+16hgO7TtPSIFHVV0Yt969ltVlARVcNUZmWc0upQaq7uiJ
|
||||
Aur5KtzwxU3YI4bhNK0593OK2TLvEEWIdw==
|
||||
-----END EC PRIVATE KEY-----
|
||||
@ -0,0 +1,167 @@
|
||||
// Copyright (c) Tailscale Inc & AUTHORS
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
// Package tlstest contains code to help test Tailscale's client proxy support.
|
||||
package tlstest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
_ "embed"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Some baked-in ECDSA keys to speed up tests, not having to burn CPU to
|
||||
// generate them each time. We only make the certs (which have expiry times)
|
||||
// at runtime.
|
||||
//
|
||||
// They were made with:
|
||||
//
|
||||
// openssl ecparam -name prime256v1 -genkey -noout -out root-ca.key
|
||||
var (
|
||||
//go:embed testdata/root-ca.key
|
||||
rootCAKeyPEM []byte
|
||||
|
||||
// TestProxyServerKey is the PEM private key for [TestProxyServerCert].
|
||||
//
|
||||
//go:embed testdata/proxy.tstest.key
|
||||
TestProxyServerKey []byte
|
||||
|
||||
// TestControlPlaneKey is the PEM private key for [TestControlPlaneCert].
|
||||
//
|
||||
//go:embed testdata/controlplane.tstest.key
|
||||
TestControlPlaneKey []byte
|
||||
)
|
||||
|
||||
// TestRootCA returns a self-signed ECDSA root CA certificate (as PEM) for
|
||||
// testing purposes.
|
||||
func TestRootCA() []byte {
|
||||
return bytes.Clone(testRootCAOncer())
|
||||
}
|
||||
|
||||
var testRootCAOncer = sync.OnceValue(func() []byte {
|
||||
key := rootCAKey()
|
||||
now := time.Now().Add(-time.Hour)
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Tailscale Unit Test ECDSA Root",
|
||||
Organization: []string{"Tailscale Test Org"},
|
||||
},
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(5, 0, 0),
|
||||
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
SubjectKeyId: mustSKID(&key.PublicKey),
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return pemCert(der)
|
||||
})
|
||||
|
||||
func pemCert(der []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
|
||||
panic(fmt.Sprintf("failed to encode PEM: %v", err))
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
var rootCAKey = sync.OnceValue(func() *ecdsa.PrivateKey {
|
||||
return mustParsePEM(rootCAKeyPEM, x509.ParseECPrivateKey)
|
||||
})
|
||||
|
||||
func mustParsePEM[T any](pemBytes []byte, parse func([]byte) (T, error)) T {
|
||||
block, rest := pem.Decode(pemBytes)
|
||||
if block == nil || len(rest) > 0 {
|
||||
panic("invalid PEM")
|
||||
}
|
||||
v, err := parse(block.Bytes)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("invalid PEM: %v", err))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// KeyPair is a simple struct to hold a certificate and its private key.
|
||||
type KeyPair struct {
|
||||
Domain string
|
||||
KeyPEM []byte // PEM-encoded private key
|
||||
}
|
||||
|
||||
// ServerTLSConfig returns a TLS configuration suitable for a server
|
||||
// using the KeyPair's certificate and private key.
|
||||
func (p KeyPair) ServerTLSConfig() *tls.Config {
|
||||
cert, err := tls.X509KeyPair(p.CertPEM(), p.KeyPEM)
|
||||
if err != nil {
|
||||
panic("invalid TLS key pair: " + err.Error())
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyServerKeyPair is a KeyPair for a test control plane server
|
||||
// with domain name "proxy.tstest".
|
||||
var ProxyServerKeyPair = KeyPair{
|
||||
Domain: "proxy.tstest",
|
||||
KeyPEM: TestProxyServerKey,
|
||||
}
|
||||
|
||||
// ControlPlaneKeyPair is a KeyPair for a test control plane server
|
||||
// with domain name "controlplane.tstest".
|
||||
var ControlPlaneKeyPair = KeyPair{
|
||||
Domain: "controlplane.tstest",
|
||||
KeyPEM: TestControlPlaneKey,
|
||||
}
|
||||
|
||||
func (p KeyPair) CertPEM() []byte {
|
||||
caCert := mustParsePEM(TestRootCA(), x509.ParseCertificate)
|
||||
caPriv := mustParsePEM(rootCAKeyPEM, x509.ParseECPrivateKey)
|
||||
leafKey := mustParsePEM(p.KeyPEM, x509.ParseECPrivateKey)
|
||||
|
||||
serial, err := rand.Int(rand.Reader, big.NewInt(0).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
now := time.Now().Add(-time.Hour)
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: p.Domain},
|
||||
NotBefore: now,
|
||||
NotAfter: now.AddDate(2, 0, 0),
|
||||
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{p.Domain},
|
||||
}
|
||||
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, &leafKey.PublicKey, caPriv)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return pemCert(der)
|
||||
}
|
||||
|
||||
func mustSKID(pub *ecdsa.PublicKey) []byte {
|
||||
skid, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return skid[:20] // same as x509 library
|
||||
}
|
||||
Loading…
Reference in New Issue