200 lines
6.4 KiB
Go
200 lines
6.4 KiB
Go
package main
|
|
|
|
// dcn_store.go — every byte a machine-store op touches is reached through ONE
|
|
// handle on the folder the person granted.
|
|
//
|
|
// WHY. The bastion is often root (a networked install, the macOS and Windows
|
|
// system service), and the granted folder is often someone else's. Checking a
|
|
// path and then opening it by name leaves two ways out of the store for
|
|
// whoever can write in it or above it:
|
|
//
|
|
// - the granted folder itself turned into a link to another folder after the
|
|
// grant. The old check resolved the root again on every op and judged the
|
|
// path against wherever it led NOW, so no race was needed;
|
|
// - a file or folder inside the store swapped for a link between the check
|
|
// and the open (or the rename, for a move plan).
|
|
//
|
|
// So: the folder's real path is PINNED at grant sync (rec.RootReal, this box's
|
|
// own field, outside the signed bytes), an op whose root now leads elsewhere
|
|
// is refused, and the op opens the pinned folder as an os.Root, which never
|
|
// follows a link out of it, whatever happens to the names afterwards. That
|
|
// handle is then checked to be the folder at the pinned path reached with no
|
|
// link at all (dcnNoLinkStat: an open that refuses a link in every component
|
|
// on Linux and macOS), so a swap of a folder above the store between the two
|
|
// opens is refused too.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
)
|
|
|
|
// errDcnRepointed is the refusal for a granted folder that leads elsewhere now.
|
|
var errDcnRepointed = errors.New("the granted folder now leads somewhere other than the folder that was granted; " +
|
|
"a new grant for it is the repair")
|
|
|
|
// dcnRealRoot is where the grant's root leads now: its links resolved.
|
|
func dcnRealRoot(root string) (string, error) {
|
|
real, err := filepath.EvalSymlinks(filepath.Clean(root))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !filepath.IsAbs(real) {
|
|
return "", errors.New("the granted folder is not an absolute path")
|
|
}
|
|
return filepath.Clean(real), nil
|
|
}
|
|
|
|
// dcnPinFor is the real root a sync records: the one already pinned for the
|
|
// same root while the store is held (a re-sync, a renewed grant), else where
|
|
// the root leads now ("" when it cannot be resolved yet; pinned at first use).
|
|
func dcnPinFor(prev *dcnGrantRec, prevRoot, root string) string {
|
|
if prev != nil && prev.RootReal != "" && filepath.Clean(prevRoot) == filepath.Clean(root) {
|
|
return prev.RootReal
|
|
}
|
|
real, err := dcnRealRoot(root)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return real
|
|
}
|
|
|
|
// dcnStorePin is the pinned real root of a held store, pinning it now (and
|
|
// saving it) for a grant synced before pins existed.
|
|
func dcnStorePin(dir string, storeID int64, root string) (string, error) {
|
|
grantsMu.Lock()
|
|
defer grantsMu.Unlock()
|
|
f, err := dcnLoadFile(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
key := strconv.FormatInt(storeID, 10)
|
|
rec, ok := f.Stores[key]
|
|
if !ok {
|
|
return "", errors.New("no grant for this store on this box")
|
|
}
|
|
if rec.RootReal != "" {
|
|
return rec.RootReal, nil
|
|
}
|
|
real, err := dcnRealRoot(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rec.RootReal = real
|
|
f.Stores[key] = rec
|
|
if err := dcnSaveFile(dir, f); err != nil {
|
|
return "", err
|
|
}
|
|
return real, nil
|
|
}
|
|
|
|
// dcnOpenStore opens the store a verified grant names, as the folder that was
|
|
// granted, or refuses.
|
|
func dcnOpenStore(dir string, storeID int64, terms *dcnTerms) (*os.Root, error) {
|
|
pin, err := dcnStorePin(dir, storeID, terms.Root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
|
|
}
|
|
real, err := dcnRealRoot(terms.Root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
|
|
}
|
|
if real != pin {
|
|
return nil, errDcnRepointed
|
|
}
|
|
return dcnOpenRootAt(pin)
|
|
}
|
|
|
|
// dcnOpenRootAt opens real (a resolved, absolute path) as an os.Root and
|
|
// checks that the handle is the folder reached at real with no link in any
|
|
// component, so nothing above the folder was swapped while it was opened.
|
|
func dcnOpenRootAt(real string) (*os.Root, error) {
|
|
r, err := os.OpenRoot(real)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
|
|
}
|
|
rfi, err := r.Stat(".")
|
|
if err != nil {
|
|
_ = r.Close()
|
|
return nil, fmt.Errorf("the granted folder cannot be opened: %w", err)
|
|
}
|
|
nfi, err := dcnNoLinkStat(real)
|
|
if err != nil || !nfi.IsDir() || !os.SameFile(rfi, nfi) {
|
|
_ = r.Close()
|
|
return nil, errDcnRepointed
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
// dcnRel is a path the sandbox accepted (cleanRel/dcnResolvePath's joined
|
|
// form) as a name inside the root handle.
|
|
func dcnRel(root, joined string) (string, error) {
|
|
rel, err := filepath.Rel(filepath.Clean(root), joined)
|
|
if err != nil || rel == ".." || filepath.IsAbs(rel) || len(rel) >= 3 && rel[:3] == ".."+string(os.PathSeparator) {
|
|
return "", errors.New("path escapes the store root")
|
|
}
|
|
return rel, nil
|
|
}
|
|
|
|
// dcnEscaped: the root handle refused a name because it leads out of the store.
|
|
func dcnEscaped(err error) bool {
|
|
var pe *os.PathError
|
|
return errors.As(err, &pe) && pe.Err != nil && pe.Err.Error() == "path escapes from parent"
|
|
}
|
|
|
|
// dcnRootAtomicWrite is atomicWrite inside the store's handle: folders 0700,
|
|
// the file 0600, written beside its name and renamed into place.
|
|
func dcnRootAtomicWrite(r *os.Root, rel string, data []byte) error {
|
|
if d := filepath.Dir(rel); d != "." {
|
|
if err := r.MkdirAll(d, 0o700); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// The side name is unguessable and opened O_EXCL, as atomicWrite's
|
|
// CreateTemp is: whoever can write in the store could otherwise plant a
|
|
// link at a guessable name, and an open that follows it would truncate
|
|
// and fill whatever file in the store the link names. O_EXCL refuses any
|
|
// existing name, a link included, without following it.
|
|
var f *os.File
|
|
var tmp string
|
|
for try := 0; ; try++ {
|
|
var nb [12]byte
|
|
if _, err := rand.Read(nb[:]); err != nil {
|
|
return err
|
|
}
|
|
tmp = filepath.Join(filepath.Dir(rel), ".dcn-"+hex.EncodeToString(nb[:])+".tmp")
|
|
var err error
|
|
f, err = r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if !errors.Is(err, fs.ErrExist) || try >= 8 {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
_ = r.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
_ = r.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = r.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := r.Rename(tmp, rel); err != nil {
|
|
_ = r.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|