bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
// Context-store worker. Dedicated Go handlers for the host's bastion-backed
|
||||
// context store: put/get/del a blob at a relative path under a sandboxed root. NO exec, NO
|
||||
// shell — these never touch p8AllowedOps. Wire framing reuses the existing mgmt helpers
|
||||
// (sendMgmt / sendMgmtExit): O = stdout, R = stderr, X = exit code.
|
||||
//
|
||||
// Not-found convention (matches the host's backend): a missing key exits 2 with no R payload, so
|
||||
// the host can distinguish "absent" from "error" (exit 1, with an R message).
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// handleCtxPut writes args[1] (base64) to the relative path args[0] under root, atomically.
|
||||
// ctxIsRecord: the paths under the context root that are THIS BOX'S RECORD,
|
||||
// never the host's blobs — session transcripts (session.go
|
||||
// sessionTranscriptPath, <root>/sessions/<id>.log). ctx_put and ctx_del are
|
||||
// standing work the host runs without a tap (writes.go ownApproval), and a
|
||||
// standing lane that could rewrite or delete the record of a shell the
|
||||
// person approved would undo that approval's record. Refused as a bad path.
|
||||
//
|
||||
// Judged on the CLEANED path relative to the root — the one the write goes to
|
||||
// — never on the argument as sent: `./sessions/x` and `a/../sessions/x` both
|
||||
// clean to the transcripts directory. Case-insensitive, because the disks
|
||||
// this runs on (macOS, Windows) are.
|
||||
func ctxIsRecord(root, abs string) bool {
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil {
|
||||
return true // cannot say where it lands: refuse
|
||||
}
|
||||
first := strings.ToLower(strings.SplitN(filepath.ToSlash(rel), "/", 2)[0])
|
||||
return first == "sessions"
|
||||
}
|
||||
|
||||
func handleCtxPut(ws *websocket.Conn, root string, args []string) {
|
||||
if len(args) < 2 {
|
||||
sendMgmt(ws, 'R', []byte("ctx_put: need <rpath> <b64>\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
abs, ok := cleanRel(root, args[0])
|
||||
if !ok || ctxIsRecord(root, abs) {
|
||||
sendMgmt(ws, 'R', []byte("path rejected\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(args[1]))
|
||||
if err != nil {
|
||||
sendMgmt(ws, 'R', []byte("bad base64\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
if err := atomicWrite(abs, data); err != nil {
|
||||
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
sendMgmtExit(ws, 0)
|
||||
}
|
||||
|
||||
// handleCtxGet reads the blob at args[0] and returns it base64 on O. Missing → exit 2 (not-found).
|
||||
func handleCtxGet(ws *websocket.Conn, root string, args []string) {
|
||||
if len(args) < 1 {
|
||||
sendMgmt(ws, 'R', []byte("ctx_get: need <rpath>\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
abs, ok := cleanRel(root, args[0])
|
||||
if !ok {
|
||||
sendMgmt(ws, 'R', []byte("path rejected\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
sendMgmtExit(ws, 2)
|
||||
return
|
||||
}
|
||||
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
sendMgmt(ws, 'O', []byte(base64.StdEncoding.EncodeToString(data)))
|
||||
sendMgmtExit(ws, 0)
|
||||
}
|
||||
|
||||
// handleCtxDel removes the blob at args[0]. Missing → exit 2 (not-found, idempotent).
|
||||
func handleCtxDel(ws *websocket.Conn, root string, args []string) {
|
||||
if len(args) < 1 {
|
||||
sendMgmt(ws, 'R', []byte("ctx_del: need <rpath>\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
abs, ok := cleanRel(root, args[0])
|
||||
if !ok || ctxIsRecord(root, abs) {
|
||||
sendMgmt(ws, 'R', []byte("path rejected\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
if err := os.Remove(abs); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
sendMgmtExit(ws, 2)
|
||||
return
|
||||
}
|
||||
sendMgmt(ws, 'R', []byte(err.Error()+"\n"))
|
||||
sendMgmtExit(ws, 1)
|
||||
return
|
||||
}
|
||||
sendMgmtExit(ws, 0)
|
||||
}
|
||||
|
||||
// cleanRel resolves rpath against root and returns the cleaned absolute path, but ONLY if it
|
||||
// stays strictly inside root. It rejects absolute paths, NUL bytes, empty/dot paths, and any
|
||||
// traversal that escapes root (".." / "../x" / "a/../../x"). This is the path sandbox wall —
|
||||
// a compromised host can ask for any rpath; this is what stops it reaching outside the store.
|
||||
func cleanRel(root, rpath string) (string, bool) {
|
||||
rpath = strings.TrimSpace(rpath)
|
||||
if rpath == "" {
|
||||
return "", false
|
||||
}
|
||||
if strings.ContainsRune(rpath, 0) {
|
||||
return "", false
|
||||
}
|
||||
if filepath.IsAbs(rpath) {
|
||||
return "", false
|
||||
}
|
||||
cleanRoot := filepath.Clean(root)
|
||||
joined := filepath.Clean(filepath.Join(cleanRoot, filepath.FromSlash(rpath)))
|
||||
rel, err := filepath.Rel(cleanRoot, joined)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
// Belt-and-braces: the joined path must be cleanRoot itself (rejected above via rel==".")
|
||||
// or a descendant of cleanRoot/. This catches edge cases Rel might not (e.g. odd roots).
|
||||
if joined != cleanRoot && !strings.HasPrefix(joined, cleanRoot+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return joined, true
|
||||
}
|
||||
|
||||
// atomicWrite writes data to path via a temp file + rename, so a reader never sees a partial
|
||||
// blob. Dirs are 0700, the file 0600 — the context store is the bastion's private state.
|
||||
func atomicWrite(path string, data []byte) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(dir, ".ctx-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // no-op once renamed away
|
||||
if err := f.Chmod(0o600); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
Reference in New Issue
Block a user