323 lines
11 KiB
Go
323 lines
11 KiB
Go
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)
|
|
}
|
|
}
|