bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
package main
|
||||
|
||||
// The socket exists for one claim: THE DAEMON LEARNS WHO IS ASKING FROM THE
|
||||
// KERNEL, NOT FROM THE ASKER. These tests are that claim, exercised with two
|
||||
// real uids where the platform allows it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A connection from THIS process is identified as this process's user, read off
|
||||
// the socket rather than sent along it.
|
||||
func TestTheDaemonReadsThePeerUserFromTheKernel(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("no unix-socket peer credentials on windows; contextsock_windows.go says why")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "s.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
got := make(chan string, 1)
|
||||
errs := make(chan error, 1)
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
u, err := contextPeerUser(c)
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
got <- u
|
||||
}()
|
||||
|
||||
c, err := net.Dial("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
select {
|
||||
case err := <-errs:
|
||||
t.Fatalf("reading the peer identity failed: %v", err)
|
||||
case u := <-got:
|
||||
if u == "" {
|
||||
t.Fatal("the kernel gave no identity at all")
|
||||
}
|
||||
// It must be THIS process's user, whatever that is.
|
||||
want := strconv.Itoa(os.Getuid())
|
||||
if !strings.Contains(u, want) && u != currentUserName(t) {
|
||||
t.Errorf("peer identified as %q; this process is uid %s (%s)",
|
||||
u, want, currentUserName(t))
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the server never identified the peer")
|
||||
}
|
||||
}
|
||||
|
||||
func currentUserName(t *testing.T) string {
|
||||
t.Helper()
|
||||
out, err := exec.Command("id", "-un").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// THE CLAIM THAT MATTERS: a client cannot name itself. The request type carries
|
||||
// no user field at all, so there is nothing to forge — asserted on the wire
|
||||
// format rather than on behaviour, because the absence IS the design.
|
||||
func TestTheAskRequestCarriesNoIdentityToForge(t *testing.T) {
|
||||
// READ THE TYPE, NOT A MARSHALLED VALUE. The first version marshalled a
|
||||
// struct literal, so a field added with `json:",omitempty"` would serialize
|
||||
// to nothing and pass — the same "measures the wrong thing" shape as a
|
||||
// count-not-content assertion.
|
||||
rt := reflect.TypeOf(contextAskRequest{})
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
name := strings.ToLower(rt.Field(i).Name)
|
||||
tag := strings.ToLower(rt.Field(i).Tag.Get("json"))
|
||||
// WHOLE WORDS, not substrings. "as" matched any field containing those
|
||||
// two letters, so an innocent Alias or Last would have tripped it
|
||||
// later for no reason — and a guard that cries
|
||||
// wolf is one the next person deletes.
|
||||
for _, forgeable := range []string{"user", "uid", "username", "token", "asuser", "onbehalfof"} {
|
||||
if name == forgeable || strings.HasPrefix(tag, forgeable+",") || tag == forgeable {
|
||||
t.Errorf("contextAskRequest carries field %q (json %q). Anything the "+
|
||||
"client can put in this struct is something the client can lie "+
|
||||
"about; the identity is read off the connection by the daemon, and "+
|
||||
"that only holds if no field here competes with it.",
|
||||
rt.Field(i).Name, rt.Field(i).Tag.Get("json"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if rt.NumField() == 0 {
|
||||
t.Fatal("contextAskRequest has no fields at all — this check would pass for ever")
|
||||
}
|
||||
// And the reply echoes the resolved user, so a person can see whose ask it
|
||||
// was recorded as.
|
||||
var rep contextAskReply
|
||||
if err := json.Unmarshal([]byte(`{"ok":true,"user":"user"}`), &rep); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rep.User != "user" {
|
||||
t.Errorf("the reply does not carry the resolved user: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// A SECOND, DIFFERENT USER — the case the whole socket exists for. Root only,
|
||||
// because only root can become somebody else to make the point.
|
||||
func TestADifferentUserIsIdentifiedAsThatUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || os.Getuid() != 0 {
|
||||
t.Skip("needs root on unix to connect as a second uid")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "ctxsock-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
// Traversable by anyone, listable by nobody — the shape contextSockListen
|
||||
// uses, so this exercises the real permission story and not a 0777 stand-in.
|
||||
if err := os.Chmod(dir, 0o711); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sock := filepath.Join(dir, "s.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
if err := os.Chmod(sock, 0o666); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := make(chan string, 1)
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
if u, err := contextPeerUser(c); err == nil {
|
||||
got <- u
|
||||
}
|
||||
}()
|
||||
|
||||
// uid 65534 (nobody) connects. If the kernel is the source, the daemon sees
|
||||
// 65534 — not root, which is what this process is.
|
||||
cmd := exec.Command("setpriv", "--reuid=65534", "--regid=65534", "--clear-groups",
|
||||
"python3", "-c",
|
||||
"import socket,sys;s=socket.socket(socket.AF_UNIX);s.connect(sys.argv[1]);import time;time.sleep(1)",
|
||||
sock)
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skipf("could not become a second user here: %v", err)
|
||||
}
|
||||
defer func() { _ = cmd.Process.Kill(); _, _ = cmd.Process.Wait() }()
|
||||
|
||||
select {
|
||||
case u := <-got:
|
||||
if strings.Contains(u, "0") && !strings.Contains(u, "65534") && u != "nobody" {
|
||||
t.Errorf("a connection from uid 65534 was identified as %q — if this reads as "+
|
||||
"root, the daemon is reporting ITSELF rather than the asker, and the "+
|
||||
"username ceiling means nothing", u)
|
||||
}
|
||||
if !strings.Contains(u, "65534") && u != "nobody" {
|
||||
t.Errorf("uid 65534 was identified as %q, want nobody/65534", u)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("the server never identified the second user")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the test that would have caught dead code ───────────────────────────────
|
||||
|
||||
// THE REAL LISTENER, WIRED TO THE REAL CLIENT. Both existing tests built their
|
||||
// own listener and dialled that exact path, so nothing ever checked that
|
||||
// startContextSock and contextAskViaSock agree about WHERE the socket is. They
|
||||
// did not: the listener bound <cfg>/context.sock.d/context.sock and the client
|
||||
// dialled <cfg>/context.sock, so every ask fell back silently and the whole
|
||||
// feature was dead as shipped.
|
||||
//
|
||||
// A unit test per side cannot see that. This one starts the actual daemon-side
|
||||
// listener and calls the actual client entry point, with nothing in between.
|
||||
func TestTheListenerAndTheClientAgreeOnWhereTheSocketIs(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("no unix-socket peer credentials on windows; contextsock_windows.go says why")
|
||||
}
|
||||
if os.Getuid() != 0 {
|
||||
t.Skip("binding the machine-wide socket needs root")
|
||||
}
|
||||
p, err := contextSockPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
t.Skipf("something is already listening at %s on this box", p)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
startContextSock(done)
|
||||
defer close(done)
|
||||
|
||||
// Wait for the bind rather than sleeping a fixed time.
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("the daemon never bound %s — the path the client will dial", p)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
defer os.Remove(p)
|
||||
|
||||
// The client's own entry point. `available` is the whole assertion: false
|
||||
// means it could not reach the daemon, which is what shipped.
|
||||
_, _, note, available := contextAskViaSock("/nonexistent-folder-for-this-test", "probe")
|
||||
if !available {
|
||||
t.Fatalf("the client could not reach the listener the daemon just started. "+
|
||||
"That is the shape the socket shipped in: available=false on every ask, a "+
|
||||
"silent fall back to a path that fails for non-root, and a daemon log line "+
|
||||
"naming an address nothing listens on. note=%q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// The socket has to be reachable BY ANOTHER USER, which is the only reason it
|
||||
// exists. A path inside a 0700 config dir is not.
|
||||
func TestTheSocketPathIsTraversableByAnotherUser(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("not this platform's mechanism")
|
||||
}
|
||||
p, err := contextSockPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(p, ".config") || strings.Contains(p, "AppData") {
|
||||
t.Fatalf("the socket path is %s, inside a per-user config dir. The daemon and "+
|
||||
"the person resolve DIFFERENT paths there, so they can never meet — which "+
|
||||
"is exactly the case this endpoint exists for.", p)
|
||||
}
|
||||
if !strings.HasPrefix(p, "/var/run") && !strings.HasPrefix(p, "/run") {
|
||||
t.Errorf("the socket is at %s; it belongs somewhere machine-wide and root-owned "+
|
||||
"so no other uid can squat it", p)
|
||||
}
|
||||
}
|
||||
|
||||
// A socket at the right path owned by somebody else is NOT the daemon, and the
|
||||
// client must refuse rather than hand it a folder path.
|
||||
func TestASocketNotOwnedByRootIsRefused(t *testing.T) {
|
||||
if runtime.GOOS == "windows" || os.Getuid() == 0 {
|
||||
t.Skip("needs a non-root view, or is not this platform's mechanism")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "impostor.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
if why := contextSockDialable(sock); why == "" {
|
||||
t.Error("a socket owned by this non-root test process was accepted as the " +
|
||||
"machine's bastion")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user