bastion 0.56.0
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package main
|
||||
|
||||
// A machine store is the folder the person granted, for as long as the grant
|
||||
// lives. The bastion often runs as root and the folder belongs to someone who
|
||||
// is not, so whoever can write in it, or above it, must not be able to point
|
||||
// a read or a move somewhere else: by turning the granted folder into a link
|
||||
// after the grant (no race needed), or by swapping a file for a link between
|
||||
// the check and the open.
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// dcnRunOp runs one DCN handler on the far side of a real WebSocket and
|
||||
// returns what it sent: the O payload, the R text and the exit code.
|
||||
func dcnRunOp(t *testing.T, h func(ws *websocket.Conn)) (out, errText string, exit int) {
|
||||
t.Helper()
|
||||
up := websocket.Upgrader{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
h(ws)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ws.Close()
|
||||
_ = ws.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
exit = -1
|
||||
for {
|
||||
_, data, err := ws.ReadMessage()
|
||||
if err != nil || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
switch data[0] {
|
||||
case 'O':
|
||||
out += string(data[1:])
|
||||
case 'R':
|
||||
errText += string(data[1:])
|
||||
case 'X':
|
||||
exit, _ = strconv.Atoi(string(data[1:]))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type dcnStoreFixture struct {
|
||||
dir string // the pairing's dcn dir
|
||||
base string
|
||||
store string // the granted folder
|
||||
other string // a folder the grant never named
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
}
|
||||
|
||||
func newDcnStoreFixture(t *testing.T) *dcnStoreFixture {
|
||||
t.Helper()
|
||||
fx := &dcnStoreFixture{dir: t.TempDir(), base: t.TempDir()}
|
||||
fx.store = filepath.Join(fx.base, "store")
|
||||
fx.other = filepath.Join(fx.base, "other")
|
||||
for _, d := range []string{fx.store, fx.other, filepath.Join(fx.store, "docs")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fx.store, "a.txt"), []byte("MINE"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(fx.other, "a.txt"), []byte("OTHER"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fx.pub, fx.priv, _ = ed25519.GenerateKey(nil)
|
||||
return fx
|
||||
}
|
||||
|
||||
// sync installs a read grant for the store through the real grant_sync op.
|
||||
func (fx *dcnStoreFixture) sync(t *testing.T, mask ...string) {
|
||||
t.Helper()
|
||||
if len(mask) == 0 {
|
||||
mask = []string{"read"}
|
||||
}
|
||||
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
|
||||
"root": fx.store, "mask": mask,
|
||||
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
|
||||
payload, _ := json.Marshal(map[string]any{"store_id": 1, "terms_canonical": rec.TermsCanonical,
|
||||
"signature": rec.Signature, "verify_pubkey": base64.StdEncoding.EncodeToString(fx.pub)})
|
||||
_, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
|
||||
handleDcnGrantSync(ws, fx.dir, []string{base64.StdEncoding.EncodeToString(payload)})
|
||||
})
|
||||
if exit != 0 {
|
||||
t.Fatalf("grant_sync: exit %d: %s", exit, why)
|
||||
}
|
||||
}
|
||||
|
||||
func (fx *dcnStoreFixture) op(t *testing.T, h func(*websocket.Conn, string, []string), args map[string]any) (map[string]any, string, int) {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(args)
|
||||
out, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
|
||||
h(ws, fx.dir, []string{"1", string(raw)})
|
||||
})
|
||||
var obj map[string]any
|
||||
_ = json.Unmarshal([]byte(out), &obj)
|
||||
return obj, why, exit
|
||||
}
|
||||
|
||||
func (fx *dcnStoreFixture) read(t *testing.T, path string) (string, string, int) {
|
||||
t.Helper()
|
||||
obj, why, exit := fx.op(t, handleDcnReadRange, map[string]any{"path": path})
|
||||
b, _ := base64.StdEncoding.DecodeString(func() string { s, _ := obj["content_b64"].(string); return s }())
|
||||
return string(b), why, exit
|
||||
}
|
||||
|
||||
// repoint turns the granted folder into a link to the other one.
|
||||
func (fx *dcnStoreFixture) repoint(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := os.Rename(fx.store, fx.store+".was"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(fx.other, fx.store); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDcnStoreRepointedAfterTheGrantIsRefused(t *testing.T) {
|
||||
fx := newDcnStoreFixture(t)
|
||||
fx.sync(t)
|
||||
if got, why, exit := fx.read(t, "a.txt"); exit != 0 || got != "MINE" {
|
||||
t.Fatalf("the granted file must read: exit %d %q %s", exit, got, why)
|
||||
}
|
||||
fx.repoint(t)
|
||||
if got, _, exit := fx.read(t, "a.txt"); exit == 0 || got == "OTHER" {
|
||||
t.Fatalf("after the granted folder became a link elsewhere, read_range returned %q (exit %d); it must refuse", got, exit)
|
||||
}
|
||||
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": "a.txt"}); exit == 0 {
|
||||
t.Fatalf("stat through the repointed folder must refuse, got %v", obj)
|
||||
}
|
||||
if obj, _, exit := fx.op(t, handleDcnList, map[string]any{}); exit == 0 {
|
||||
t.Fatalf("list through the repointed folder must refuse, got %v", obj)
|
||||
}
|
||||
}
|
||||
|
||||
// A folder inside the store that becomes a link to somewhere else after the
|
||||
// grant is the same escape one level down.
|
||||
func TestDcnSubfolderRepointedIsRefused(t *testing.T) {
|
||||
fx := newDcnStoreFixture(t)
|
||||
fx.sync(t)
|
||||
if err := os.RemoveAll(filepath.Join(fx.store, "docs")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(fx.other, filepath.Join(fx.store, "docs")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if got, _, exit := fx.read(t, "docs/a.txt"); exit == 0 || got == "OTHER" {
|
||||
t.Fatalf("a link inside the store to a folder outside it read %q (exit %d)", got, exit)
|
||||
}
|
||||
}
|
||||
|
||||
// The race: a file swapped for a link to a file outside the store between the
|
||||
// check and the open. Run for long enough that the old check-then-open loses.
|
||||
func TestDcnReadRangeSwapRaceNeverLeavesTheStore(t *testing.T) {
|
||||
fx := newDcnStoreFixture(t)
|
||||
fx.sync(t)
|
||||
target := filepath.Join(fx.store, "a.txt")
|
||||
realTmp := filepath.Join(fx.store, ".real")
|
||||
linkTmp := filepath.Join(fx.store, ".link")
|
||||
var stop atomic.Bool
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for !stop.Load() {
|
||||
_ = os.Symlink(filepath.Join(fx.other, "a.txt"), linkTmp)
|
||||
_ = os.Rename(linkTmp, target)
|
||||
_ = os.WriteFile(realTmp, []byte("MINE"), 0o644)
|
||||
_ = os.Rename(realTmp, target)
|
||||
}
|
||||
}()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
n := 0
|
||||
for time.Now().Before(deadline) {
|
||||
n++
|
||||
if got, _, _ := fx.read(t, "a.txt"); got == "OTHER" {
|
||||
stop.Store(true)
|
||||
<-done
|
||||
t.Fatalf("read %d returned the file outside the store through a swapped link", n)
|
||||
}
|
||||
}
|
||||
stop.Store(true)
|
||||
<-done
|
||||
}
|
||||
|
||||
// A move plan applies through the store too: a target folder that leads
|
||||
// outside the store for an instant between the re-check and the rename must
|
||||
// not receive the file.
|
||||
func TestDcnPlanMoveRaceNeverLeavesTheStore(t *testing.T) {
|
||||
fx := newDcnStoreFixture(t)
|
||||
if err := os.Remove(filepath.Join(fx.other, "a.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docs := filepath.Join(fx.store, "docs")
|
||||
var stop atomic.Bool
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for !stop.Load() {
|
||||
_ = os.Rename(docs, docs+".real")
|
||||
_ = os.Symlink(fx.other, docs+".link")
|
||||
_ = os.Rename(docs+".link", docs)
|
||||
_ = os.Rename(docs, docs+".link")
|
||||
_ = os.Remove(docs + ".link")
|
||||
_ = os.Rename(docs+".real", docs)
|
||||
}
|
||||
}()
|
||||
defer func() { stop.Store(true); <-done }()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for i := 0; time.Now().Before(deadline); i++ {
|
||||
_ = os.WriteFile(filepath.Join(fx.store, "a.txt"), []byte("MINE"), 0o644)
|
||||
plan := &dcnPlanTerms{PlanID: int64(i), StoreID: 1, Root: fx.store,
|
||||
Moves: []dcnMove{{From: "a.txt", To: "docs/a.txt"}}}
|
||||
_ = dcnApplyPlan(fx.store, nil, plan)
|
||||
if _, err := os.Lstat(filepath.Join(fx.other, "a.txt")); err == nil {
|
||||
t.Fatalf("move %d landed outside the store, through a folder that was a link for an instant", i)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(fx.store, "docs.real", "a.txt"))
|
||||
_ = os.Remove(filepath.Join(docs, "a.txt"))
|
||||
}
|
||||
}
|
||||
|
||||
// The plan report is written beside its name and renamed into place. Whoever
|
||||
// can write in the store could plant a link at that side name; the write must
|
||||
// never follow it, or the root bastion truncates whatever file the link names
|
||||
// (an excluded .ssh/authorized_keys included) and fills it with the report.
|
||||
func TestDcnReportWriteNeverFollowsAPlantedSideName(t *testing.T) {
|
||||
store := t.TempDir()
|
||||
victim := filepath.Join(store, ".ssh", "authorized_keys")
|
||||
if err := os.MkdirAll(filepath.Dir(victim), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(victim, []byte("ssh-ed25519 AAAAexample user@example\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plans := filepath.Join(store, ".agent", "plans")
|
||||
if err := os.MkdirAll(plans, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := filepath.Join(".agent", "plans", "7.json")
|
||||
// Every side name a writer could guess: this process's pid, and a spread
|
||||
// of others, all pointing at the victim.
|
||||
for _, pid := range []int{os.Getpid(), 1, 2, 100, 1000} {
|
||||
side := filepath.Join(store, rel+".tmp-"+strconv.Itoa(pid))
|
||||
if err := os.Symlink(filepath.Join("..", "..", ".ssh", "authorized_keys"), side); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r, err := os.OpenRoot(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
if err := dcnRootAtomicWrite(r, rel, []byte(`{"state":"ok"}`)); err != nil {
|
||||
t.Fatalf("report write: %v", err)
|
||||
}
|
||||
if got, _ := os.ReadFile(victim); string(got) != "ssh-ed25519 AAAAexample user@example\n" {
|
||||
t.Fatalf("the report write followed a planted link: the victim now holds %q", got)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(store, rel)); err != nil || string(got) != `{"state":"ok"}` {
|
||||
t.Fatalf("report not in place: %q %v", got, err)
|
||||
}
|
||||
if fi, err := os.Lstat(filepath.Join(store, rel)); err != nil || fi.Mode()&os.ModeSymlink != 0 || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("report is not a 0600 regular file: %v %v", fi, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A grant installed before pins existed has no root_real. It still works,
|
||||
// pins where its folder leads at first use, keeps that pin, and a repoint
|
||||
// after that is refused like any other.
|
||||
func TestDcnGrantFromBeforePinsPinsAtFirstUse(t *testing.T) {
|
||||
fx := newDcnStoreFixture(t)
|
||||
fx.sync(t)
|
||||
f, err := dcnLoadFile(fx.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := f.Stores["1"]
|
||||
rec.RootReal = ""
|
||||
f.Stores["1"] = rec
|
||||
if err := dcnSaveFile(fx.dir, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, why, exit := fx.read(t, "a.txt"); exit != 0 || got != "MINE" {
|
||||
t.Fatalf("a grant from before pins no longer reads: exit %d %q %s", exit, got, why)
|
||||
}
|
||||
f, err = dcnLoadFile(fx.dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := filepath.EvalSymlinks(fx.store)
|
||||
if got := f.Stores["1"].RootReal; got != want {
|
||||
t.Fatalf("first use pinned %q, want %q", got, want)
|
||||
}
|
||||
fx.repoint(t)
|
||||
if got, _, exit := fx.read(t, "a.txt"); exit == 0 || got == "OTHER" {
|
||||
t.Fatalf("after the first-use pin a repointed folder was read: exit %d %q", exit, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user