Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/run-cli-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ jobs:
- name: Install dependencies
run: go get .
- name: Race-check the Agent Vault packages
run: go test -race -count=1 ./packages/agentvault/ ./packages/api/
run: |
go test -race -count=1 ./packages/agentvault/ ./packages/api/
go test -race -count=1 -run 'AgentVault|ValidateProxyAddr|TrimProxyScheme|NonBlankDrops|OnlyThePinnedCertificate|KeychainTrust' ./packages/cmd/
- name: Race-check the PAM gateway packages
run: go test -race -count=1 ./packages/gateway-v2/... ./packages/pam/handlers/clickhouse/
- name: Race-check telemetry
Expand Down
37 changes: 37 additions & 0 deletions packages/agentvault/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ func LoadOwnCa(in OwnCaInput) (*OwnCa, error) {
return own, nil
}

// ListenerCert is the operator's own certificate for the SOCKS5 listener. It is read once at start, so a
// renewed certificate takes effect on the next restart.
type ListenerCert struct {
certificate tls.Certificate
leaf *x509.Certificate
}

func (c *ListenerCert) Fingerprint() string { return FingerprintOf(c.leaf.Raw) }

func (c *ListenerCert) NotAfter() time.Time { return c.leaf.NotAfter }

// Any key type and the full chain, unlike a CA, which has to be an ECDSA P-256 key the proxy can sign with.
func LoadListenerCert(in OwnCaInput) (*ListenerCert, error) {
certPEM, ok := loadCaValue(in.Cert)
if !ok {
return nil, fmt.Errorf("%s is not a PEM, base64 PEM or readable file: %s", in.CertSource, shortenValue(in.Cert))
}
keyPEM, ok := loadCaValue(in.Key)
if !ok {
return nil, fmt.Errorf("%s is not a PEM, base64 PEM or readable file (value hidden)", in.KeySource)
}
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("the certificate in %s and the key in %s can't be used together: %w", in.CertSource, in.KeySource, err)
}
leaf, err := x509.ParseCertificate(pair.Certificate[0])
if err != nil {
return nil, fmt.Errorf("the certificate in %s can't be parsed: %w", in.CertSource, err)
}
if now := time.Now(); now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
return nil, fmt.Errorf("the certificate in %s isn't valid yet or has expired (valid from %s to %s)",
in.CertSource, leaf.NotBefore.UTC().Format(time.RFC3339), leaf.NotAfter.UTC().Format(time.RFC3339))
}
pair.Leaf = leaf
return &ListenerCert{certificate: pair, leaf: leaf}, nil
}

func countPEMBlocks(data []byte, blockType string) int {
n := 0
for {
Expand Down
78 changes: 78 additions & 0 deletions packages/agentvault/dial_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package agentvault

import (
"errors"
"net"
"net/http"
"net/netip"
"syscall"
)

var errPrivateDestination = errors.New("the destination is a private, loopback or link-local address")

// The same ranges the backend refuses for outbound requests (backend/src/lib/ip/ipRange.ts), plus the
// IPv4-compatible and site-local IPv6 forms, which can also carry an internal address.
var blockedPrefixes = func() []netip.Prefix {
cidrs := []string{
"0.0.0.0/8",
"10.0.0.0/8",
"100.64.0.0/10",
"127.0.0.0/8",
"169.254.0.0/16",
"172.16.0.0/12",
"192.0.0.0/24",
"192.0.2.0/24",
"192.88.99.0/24",
"192.168.0.0/16",
"198.18.0.0/15",
"198.51.100.0/24",
"203.0.113.0/24",
"224.0.0.0/4",
"240.0.0.0/4",
"::/96",
"::1/128",
"100::/64",
"2001:2::/48",
"2001:db8::/32",
"2002::/16",
"64:ff9b::/96",
"64:ff9b:1::/48",
"fc00::/7",
"fe80::/10",
"fec0::/10",
"ff00::/8",
}
prefixes := make([]netip.Prefix, 0, len(cidrs))
for _, cidr := range cidrs {
prefixes = append(prefixes, netip.MustParsePrefix(cidr))
}
return prefixes
}()

// Prefix.Contains is false for any address with a zone, so the zone comes off first; an IPv4 address written
// as IPv6 is unwrapped so the IPv4 ranges apply to it.
func blockedDestination(ip netip.Addr) bool {
ip = ip.WithZone("").Unmap()
for _, prefix := range blockedPrefixes {
if prefix.Contains(ip) {
return true
}
}
return false
}

// Runs on the resolved address of every socket the dialer opens, so a hostname that resolves to an internal
// address is caught however the name was written.
func guardControl(_, address string, _ syscall.RawConn) error {
addrPort, err := netip.ParseAddrPort(address)
if err != nil || blockedDestination(addrPort.Addr()) {
return errPrivateDestination
}
return nil
}

func newGuardedUpstreamTransport() *http.Transport {
transport := newUpstreamTransport()
transport.DialContext = (&net.Dialer{Control: guardControl}).DialContext
return transport
}
62 changes: 62 additions & 0 deletions packages/agentvault/dial_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package agentvault

import (
"errors"
"net/netip"
"testing"
)

func TestBlockedDestination(t *testing.T) {
for _, tc := range []struct {
addr string
blocked bool
}{
{"10.0.0.1", true},
{"172.16.5.4", true},
{"192.168.1.1", true},
{"127.0.0.1", true},
{"0.0.0.0", true},
{"100.64.0.1", true},
{"169.254.169.254", true},
{"192.0.0.1", true},
{"198.18.0.1", true},
{"224.0.0.1", true},
{"240.0.0.1", true},
{"255.255.255.255", true},
{"::", true},
{"::1", true},
{"fe80::1", true},
{"fe80::1%eth0", true},
{"fc00::1", true},
{"fd00:ec2::254", true},
{"fec0::1", true},
{"ff02::1", true},
{"::ffff:169.254.169.254", true},
{"::169.254.169.254", true},
{"64:ff9b::a9fe:a9fe", true},
{"64:ff9b::a9fe:a9fe%1", true},
{"64:ff9b:1::1", true},
{"2002:a9fe:a9fe::1", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"2606:4700:4700::1111", false},
{"::ffff:8.8.8.8", false},
} {
t.Run(tc.addr, func(t *testing.T) {
if got := blockedDestination(netip.MustParseAddr(tc.addr)); got != tc.blocked {
t.Errorf("blockedDestination(%s) = %v, want %v", tc.addr, got, tc.blocked)
}
})
}
}

func TestGuardControlFailsClosed(t *testing.T) {
for _, address := range []string{"127.0.0.1:443", "[::1]:443", "not-an-address", "127.0.0.1%lo0:443"} {
if err := guardControl("tcp", address, nil); !errors.Is(err, errPrivateDestination) {
t.Errorf("guardControl(%q) = %v, want errPrivateDestination", address, err)
}
}
if err := guardControl("tcp4", "8.8.8.8:443", nil); err != nil {
t.Errorf("a public address was refused: %v", err)
}
}
4 changes: 2 additions & 2 deletions packages/agentvault/match_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestExceptionOverridesBundleHostsPolicy(t *testing.T) {
t.Fatalf("bestMatch(%q) = %v, want %v", tc.host, matched, tc.wantMatch)
}

if blocked := ps.blocksOffBundle(matched, tc.host, "443"); blocked != tc.wantBlock {
if blocked := ps.currentConfig().blocksOffBundle(matched, tc.host, "443"); blocked != tc.wantBlock {
t.Errorf("blocked = %v, want %v", blocked, tc.wantBlock)
}
})
Expand All @@ -214,7 +214,7 @@ func TestExceptionPortScope(t *testing.T) {
{"pkg.example.com", "443", false, "naming a port still scopes the entry to it"},
{"other.example.com", "80", false, "a host that is not on the list"},
} {
if got := ps.isAllowedHost(tc.host, tc.port); got != tc.want {
if got := ps.currentConfig().isAllowedHost(tc.host, tc.port); got != tc.want {
t.Errorf("isAllowedHost(%q, %q) = %v, want %v: %s", tc.host, tc.port, got, tc.want, tc.why)
}
}
Expand Down
Loading
Loading