mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-10-12 01:58:46 +02:00
483 lines
16 KiB
Go
483 lines
16 KiB
Go
//go:build darwin || linux
|
|
|
|
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
ctrld "github.com/Control-D-Inc/ctrld"
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
func logSendFixture(t *testing.T) (trustedLogTree, delegatedLogCollector) {
|
|
t.Helper()
|
|
// Short names also fit Darwin's 104-byte unix socket path limit.
|
|
dir, err := os.MkdirTemp("", "ls-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(dir) })
|
|
tree := trustedLogTree{root: dir, uid: uint32(os.Geteuid())}
|
|
c := delegatedLogCollector{tree: tree, config: filepath.Join(dir, "config.toml"), sources: []delegatedLogSource{{path: filepath.Join(dir, "debug.log"), backups: 2, budget: 8}}}
|
|
writeLogSendFixture(t, c.config, "[service]\nallow_unprivileged_log_send = true\n")
|
|
writeLogSendFixture(t, c.sources[0].path, "0123456789abcdef")
|
|
return tree, c
|
|
}
|
|
func writeLogSendFixture(t *testing.T, path, data string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(data), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func readDelegated(t *testing.T, c delegatedLogCollector) string {
|
|
t.Helper()
|
|
r, err := c.collect(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.Close()
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func TestDelegatedLogCollectionStripsColor(t *testing.T) {
|
|
_, c := logSendFixture(t)
|
|
colored := "\x1b[34mINFO\x1b[0m ok\n\x1b[31mERROR\x1b[0m bad\n"
|
|
writeLogSendFixture(t, c.sources[0].path, colored)
|
|
// The budget counts raw bytes, so it must cover the escapes too.
|
|
c.sources[0].budget = int64(len(colored))
|
|
got := readDelegated(t, c)
|
|
if strings.Contains(got, "\x1b") {
|
|
t.Fatalf("delegated upload kept color codes: %q", got)
|
|
}
|
|
if want := "INFO ok\nERROR bad\n"; got != want {
|
|
t.Fatalf("body=%q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogCollectionBoundedAndPinned(t *testing.T) {
|
|
_, c := logSendFixture(t)
|
|
writeLogSendFixture(t, c.sources[0].path+".1", strings.Repeat("older", 100))
|
|
if got := readDelegated(t, c); got != "89abcdef" {
|
|
t.Fatalf("tail=%q", got)
|
|
}
|
|
c.sources = append(c.sources, delegatedLogSource{path: filepath.Join(c.tree.root, "journal.log"), budget: 4, backups: 2})
|
|
writeLogSendFixture(t, c.sources[1].path, "journal-data")
|
|
if got := readDelegated(t, c); got != "89abcdef"+logWriterLogEndMarker+"data" {
|
|
t.Fatalf("bundle=%q", got)
|
|
}
|
|
r, err := c.collect(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer r.Close()
|
|
// An opened upload keeps the checked inode even if a privileged rotation
|
|
// replaces its name; it cannot switch to the replacement's bytes.
|
|
if err := os.Rename(c.sources[0].path, c.sources[0].path+".old"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeLogSendFixture(t, c.sources[0].path, "replacement-secret")
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(data), "secret") || !bytes.HasPrefix(data, []byte("89abcdef")) {
|
|
t.Fatalf("unpinned data: %q", data)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if r, err := c.collect(ctx); err == nil {
|
|
r.Close()
|
|
t.Fatal("canceled collection succeeded")
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogCollectionTrustFailures(t *testing.T) {
|
|
cases := map[string]func(*testing.T, *delegatedLogCollector){
|
|
"config writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.config, 0666) },
|
|
"log writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.sources[0].path, 0666) },
|
|
"directory writable": func(t *testing.T, c *delegatedLogCollector) { os.Chmod(c.tree.root, 0777) },
|
|
"wrong owner": func(t *testing.T, c *delegatedLogCollector) { c.tree.uid++ },
|
|
"config symlink": func(t *testing.T, c *delegatedLogCollector) {
|
|
os.Rename(c.config, c.config+".real")
|
|
os.Symlink(c.config+".real", c.config)
|
|
},
|
|
"log symlink": func(t *testing.T, c *delegatedLogCollector) {
|
|
os.Remove(c.sources[0].path)
|
|
os.Symlink(c.config, c.sources[0].path)
|
|
},
|
|
"backup symlink": func(t *testing.T, c *delegatedLogCollector) { os.Symlink(c.config, c.sources[0].path+".2") },
|
|
"hardlink": func(t *testing.T, c *delegatedLogCollector) {
|
|
if err := os.Link(c.sources[0].path, c.sources[0].path+".link"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
"fifo": func(t *testing.T, c *delegatedLogCollector) {
|
|
os.Remove(c.sources[0].path)
|
|
if err := unix.Mkfifo(c.sources[0].path, 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
},
|
|
"directory as log": func(t *testing.T, c *delegatedLogCollector) {
|
|
os.Remove(c.sources[0].path)
|
|
os.Mkdir(c.sources[0].path, 0700)
|
|
},
|
|
"disabled": func(t *testing.T, c *delegatedLogCollector) {
|
|
writeLogSendFixture(t, c.config, "[service]\nallow_unprivileged_log_send = false\n")
|
|
},
|
|
"absent optin": func(t *testing.T, c *delegatedLogCollector) { writeLogSendFixture(t, c.config, "[service]\n") },
|
|
"invalid config": func(t *testing.T, c *delegatedLogCollector) { writeLogSendFixture(t, c.config, "[service") },
|
|
"huge config": func(t *testing.T, c *delegatedLogCollector) {
|
|
writeLogSendFixture(t, c.config, strings.Repeat("a", (1<<20)+1))
|
|
},
|
|
"missing config": func(t *testing.T, c *delegatedLogCollector) { os.Remove(c.config) },
|
|
"relative log": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].path = "debug.log" },
|
|
"escape anchor": func(t *testing.T, c *delegatedLogCollector) {
|
|
c.sources[0].path = filepath.Join(c.tree.root, "..", "debug.log")
|
|
},
|
|
"too many backups": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].backups = 65 },
|
|
"unbounded budget": func(t *testing.T, c *delegatedLogCollector) { c.sources[0].budget = 0 },
|
|
"intermediate symlink": func(t *testing.T, c *delegatedLogCollector) {
|
|
os.Symlink(c.tree.root, filepath.Join(c.tree.root, "alias"))
|
|
c.sources[0].path = filepath.Join(c.tree.root, "alias", "debug.log")
|
|
},
|
|
}
|
|
for name, mutate := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, c := logSendFixture(t)
|
|
mutate(t, &c)
|
|
if err := c.validate(context.Background()); err == nil {
|
|
t.Fatal("unsafe startup validation succeeded")
|
|
}
|
|
if r, err := c.collect(context.Background()); err == nil {
|
|
r.Close()
|
|
t.Fatal("unsafe collection succeeded")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func startLogSendFixture(t *testing.T, s *logSendServer, peer func(net.Conn) (uint32, error)) (*http.Client, string) {
|
|
t.Helper()
|
|
tree, _ := logSendFixture(t)
|
|
path := filepath.Join(tree.root, "send.sock")
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.serve(l, peer)
|
|
t.Cleanup(s.stop)
|
|
tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialLogSend(ctx, tree, path) }, DisableKeepAlives: true}
|
|
t.Cleanup(tr.CloseIdleConnections)
|
|
return &http.Client{Transport: tr, Timeout: 5 * time.Second}, path
|
|
}
|
|
func logSendStatus(t *testing.T, c *http.Client, method, path, body string) int {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, "http://unix"+path, strings.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(b) != 0 {
|
|
t.Fatalf("response leaked data: %q", b)
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func TestDelegatedLogSendRealTransportAndIsolation(t *testing.T) {
|
|
_, collector := logSendFixture(t)
|
|
var sent string
|
|
var caller int64 = -2
|
|
var auditCode int
|
|
var mu sync.Mutex
|
|
s := &logSendServer{gate: &logUploadGate{}, collect: collector.collect,
|
|
upload: func(ctx context.Context, r io.ReadCloser) error {
|
|
b, err := io.ReadAll(r)
|
|
sent = string(b)
|
|
return err
|
|
},
|
|
audit: func(uid int64, code int) { mu.Lock(); defer mu.Unlock(); caller, auditCode = uid, code },
|
|
}
|
|
client, _ := startLogSendFixture(t, s, logSendPeer)
|
|
for _, path := range []string{"/clients", "/started", "/reload", "/deactivation", "/cd", "/iface", "/log/view", "/log/tail", "/log/send?full=1", "/log/send?url=https://attacker", "/log/send?path=/etc/passwd", "/log/send/", "/log/%73end", "//log/send"} {
|
|
if got := logSendStatus(t, client, "POST", path, ""); got != 400 {
|
|
t.Fatalf("%s status=%d", path, got)
|
|
}
|
|
}
|
|
for _, method := range []string{"GET", "PUT", "DELETE", "CONNECT"} {
|
|
if got := logSendStatus(t, client, method, sendLogsPath, ""); got != 400 {
|
|
t.Fatalf("%s status=%d", method, got)
|
|
}
|
|
}
|
|
if got := logSendStatus(t, client, "POST", sendLogsPath, `{"uid":"attacker","full":true}`); got != 400 {
|
|
t.Fatal(got)
|
|
}
|
|
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 200 {
|
|
t.Fatal(got)
|
|
}
|
|
if sent != "89abcdef" {
|
|
t.Fatalf("uploaded=%q", sent)
|
|
}
|
|
mu.Lock()
|
|
uid, code := caller, auditCode
|
|
mu.Unlock()
|
|
if uid != int64(os.Geteuid()) || code != 200 {
|
|
t.Fatalf("audit uid=%d status=%d", uid, code)
|
|
}
|
|
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 503 {
|
|
t.Fatal(got)
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendGateSharedWithAdmin(t *testing.T) {
|
|
p := &prog{cfg: &ctrld.Config{}, cs: &controlServer{mux: http.NewServeMux()}}
|
|
p.registerControlServerHandler()
|
|
entered, release := make(chan struct{}), make(chan struct{})
|
|
var collections atomic.Int32
|
|
s := &logSendServer{gate: &p.logUpload, collect: func(context.Context) (io.ReadCloser, error) {
|
|
collections.Add(1)
|
|
close(entered)
|
|
<-release
|
|
return io.NopCloser(strings.NewReader("data")), nil
|
|
}, upload: func(context.Context, io.ReadCloser) error { return errors.New("secret upstream error") }}
|
|
client, _ := startLogSendFixture(t, s, logSendPeer)
|
|
done := make(chan int, 1)
|
|
go func() {
|
|
resp, err := client.Post("http://unix"+sendLogsPath, "", nil)
|
|
if err != nil {
|
|
done <- 0
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
done <- resp.StatusCode
|
|
}()
|
|
<-entered
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 24; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
r := httptest.NewRecorder()
|
|
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath+"?full=1", nil))
|
|
if r.Code != 503 {
|
|
t.Errorf("admin concurrent=%d", r.Code)
|
|
}
|
|
}()
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
r := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", sendLogsPath, nil).WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(42)))
|
|
s.ServeHTTP(r, req)
|
|
if r.Code != 503 {
|
|
t.Errorf("delegated concurrent=%d", r.Code)
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
close(release)
|
|
if got := <-done; got != 502 {
|
|
t.Fatal(got)
|
|
}
|
|
if collections.Load() != 1 {
|
|
t.Fatal(collections.Load())
|
|
}
|
|
r := httptest.NewRecorder()
|
|
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath, nil))
|
|
if r.Code != 503 {
|
|
t.Fatalf("admin failure cooldown=%d", r.Code)
|
|
}
|
|
// Expire the cooldown explicitly, without sleeping a minute. The actual admin
|
|
// handler fails before an upload on an empty config, then reserves cooldown too.
|
|
p.logUpload.mu.Lock()
|
|
p.logUpload.next = time.Time{}
|
|
p.logUpload.mu.Unlock()
|
|
oldUID := cdUID
|
|
cdUID = ""
|
|
defer func() { cdUID = oldUID }()
|
|
r = httptest.NewRecorder()
|
|
p.cs.mux.ServeHTTP(r, httptest.NewRequest("POST", sendLogsPath, nil))
|
|
if r.Code != 301 {
|
|
t.Fatalf("empty admin=%d", r.Code)
|
|
}
|
|
if got := logSendStatus(t, client, "POST", sendLogsPath, ""); got != 503 {
|
|
t.Fatalf("delegated after admin=%d", got)
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendFailureCooldownAndMissingIdentity(t *testing.T) {
|
|
var calls int
|
|
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { calls++; return nil, errors.New("secret path") }}
|
|
req := httptest.NewRequest("POST", sendLogsPath, nil)
|
|
r := httptest.NewRecorder()
|
|
s.ServeHTTP(r, req)
|
|
if r.Code != 403 || calls != 0 {
|
|
t.Fatalf("missing identity: code=%d calls=%d", r.Code, calls)
|
|
}
|
|
req = req.WithContext(context.WithValue(context.Background(), logSendPeerKey{}, uint32(501)))
|
|
r = httptest.NewRecorder()
|
|
s.ServeHTTP(r, req)
|
|
if r.Code != 412 || r.Body.Len() != 0 || calls != 1 {
|
|
t.Fatalf("collection failure: %+v calls=%d", r, calls)
|
|
}
|
|
r = httptest.NewRecorder()
|
|
s.ServeHTTP(r, req)
|
|
if r.Code != 503 || calls != 1 {
|
|
t.Fatal("failure did not reserve cooldown")
|
|
}
|
|
client, _ := startLogSendFixture(t, &logSendServer{}, func(net.Conn) (uint32, error) { return 0, errors.New("no credentials") })
|
|
if resp, err := client.Post("http://unix"+sendLogsPath, "", nil); err == nil {
|
|
resp.Body.Close()
|
|
t.Fatal("unverified peer accepted")
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendShutdownCancelsAndJoins(t *testing.T) {
|
|
entered, exited := make(chan struct{}), make(chan struct{})
|
|
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("data")), nil }, upload: func(ctx context.Context, _ io.ReadCloser) error {
|
|
close(entered)
|
|
<-ctx.Done()
|
|
close(exited)
|
|
return ctx.Err()
|
|
}}
|
|
client, path := startLogSendFixture(t, s, logSendPeer)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
resp, err := client.Post("http://unix"+sendLogsPath, "", nil)
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
}
|
|
}()
|
|
<-entered
|
|
s.stop()
|
|
select {
|
|
case <-exited:
|
|
default:
|
|
t.Fatal("stop did not join upload")
|
|
}
|
|
<-done
|
|
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("socket not removed: %v", err)
|
|
}
|
|
p := &prog{}
|
|
p.stopLogSendServer()
|
|
if !p.logSendStopped {
|
|
t.Fatal("startup not fenced")
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendClientAndImpersonation(t *testing.T) {
|
|
tree, _ := logSendFixture(t)
|
|
path := filepath.Join(tree.root, "send.sock")
|
|
var dials int
|
|
dial := func(context.Context) (net.Conn, error) { dials++; return nil, errors.New("not running") }
|
|
if err := requestDelegatedLogSend(context.Background(), true, dial); err == nil || dials != 0 {
|
|
t.Fatal("full dialed")
|
|
}
|
|
if err := requestDelegatedLogSend(context.Background(), false, dial); err == nil || !strings.Contains(err.Error(), "administrator") {
|
|
t.Fatal(err)
|
|
}
|
|
writeLogSendFixture(t, path, "not a socket")
|
|
if c, err := dialLogSend(context.Background(), tree, path); err == nil {
|
|
c.Close()
|
|
t.Fatal("regular file accepted")
|
|
}
|
|
os.Remove(path)
|
|
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("x")), nil }, upload: func(context.Context, io.ReadCloser) error { return nil }}
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.serve(l, logSendPeer)
|
|
defer s.stop()
|
|
if err := requestDelegatedLogSend(context.Background(), false, func(ctx context.Context) (net.Conn, error) { return dialLogSend(ctx, tree, path) }); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
os.Chmod(tree.root, 0777)
|
|
if c, err := dialLogSend(context.Background(), tree, path); err == nil {
|
|
c.Close()
|
|
t.Fatal("writable location accepted")
|
|
}
|
|
os.Chmod(tree.root, 0700)
|
|
// A nonroot peer cannot impersonate root, even though it can create a socket
|
|
// in its own directory. Production validation starts at /, not this test anchor.
|
|
bad := tree
|
|
bad.uid++
|
|
if c, err := dialLogSend(context.Background(), bad, path); err == nil {
|
|
c.Close()
|
|
t.Fatal("wrong owner accepted")
|
|
}
|
|
os.Symlink(path, path+".alias")
|
|
if c, err := dialLogSend(context.Background(), tree, path+".alias"); err == nil {
|
|
c.Close()
|
|
t.Fatal("symlink socket accepted")
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendRawRequestsBounded(t *testing.T) {
|
|
s := &logSendServer{gate: &logUploadGate{}, collect: func(context.Context) (io.ReadCloser, error) {
|
|
t.Error("invalid request collected logs")
|
|
return nil, errors.New("unexpected")
|
|
}}
|
|
_, path := startLogSendFixture(t, s, logSendPeer)
|
|
for _, raw := range []string{
|
|
"POST /log/send HTTP/1.1\r\nHost: unix\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
|
|
"POST /log/send HTTP/1.1\r\nHost: unix\r\nContent-Length: 999999999\r\n\r\n",
|
|
"POST /log/send HTTP/1.1\r\nHost: unix\r\nExpect: 100-continue\r\n\r\n",
|
|
"POST /log/send HTTP/1.1\r\nHost: unix\r\nX-Large: " + strings.Repeat("x", 8192) + "\r\n\r\n",
|
|
} {
|
|
conn, err := net.Dial("unix", path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
io.WriteString(conn, raw)
|
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
|
if err != nil {
|
|
conn.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 400 && resp.StatusCode != 431 {
|
|
t.Errorf("unexpected status %d", resp.StatusCode)
|
|
}
|
|
resp.Body.Close()
|
|
conn.Close()
|
|
}
|
|
}
|
|
|
|
func TestDelegatedLogSendCanonicalAliases(t *testing.T) {
|
|
for from, to := range map[string]string{"/var/log/ctrld.log": "/private/var/log/ctrld.log", "/etc/ctrld.toml": "/private/etc/ctrld.toml", "/private/var/log/a": "/private/var/log/a", "/Users/u/alias/log": "/Users/u/alias/log"} {
|
|
if got := canonicalLogSendPath(from); got != to {
|
|
t.Fatalf("%q => %q", from, got)
|
|
}
|
|
}
|
|
}
|