Just returning an error after a successful call of commit will leave garbage behind with no way for the caller to clean them. This change ensures revert is always called after successful commit with at least per-process state enabled. Signed-off-by: Ophestra <cat@gensokyo.uk>
528 lines
15 KiB
Go
528 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/gob"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.gensokyo.uk/security/fortify/acl"
|
|
"git.gensokyo.uk/security/fortify/dbus"
|
|
"git.gensokyo.uk/security/fortify/fst"
|
|
"git.gensokyo.uk/security/fortify/helper/bwrap"
|
|
"git.gensokyo.uk/security/fortify/internal"
|
|
"git.gensokyo.uk/security/fortify/internal/fmsg"
|
|
"git.gensokyo.uk/security/fortify/internal/sys"
|
|
"git.gensokyo.uk/security/fortify/system"
|
|
"git.gensokyo.uk/security/fortify/wl"
|
|
)
|
|
|
|
const (
|
|
home = "HOME"
|
|
shell = "SHELL"
|
|
|
|
xdgConfigHome = "XDG_CONFIG_HOME"
|
|
xdgRuntimeDir = "XDG_RUNTIME_DIR"
|
|
xdgSessionClass = "XDG_SESSION_CLASS"
|
|
xdgSessionType = "XDG_SESSION_TYPE"
|
|
|
|
term = "TERM"
|
|
display = "DISPLAY"
|
|
|
|
pulseServer = "PULSE_SERVER"
|
|
pulseCookie = "PULSE_COOKIE"
|
|
|
|
dbusSessionBusAddress = "DBUS_SESSION_BUS_ADDRESS"
|
|
dbusSystemBusAddress = "DBUS_SYSTEM_BUS_ADDRESS"
|
|
)
|
|
|
|
var (
|
|
ErrConfig = errors.New("no configuration to seal")
|
|
ErrUser = errors.New("invalid aid")
|
|
ErrHome = errors.New("invalid home directory")
|
|
ErrName = errors.New("invalid username")
|
|
|
|
ErrXDisplay = errors.New(display + " unset")
|
|
|
|
ErrPulseCookie = errors.New("pulse cookie not present")
|
|
ErrPulseSocket = errors.New("pulse socket not present")
|
|
ErrPulseMode = errors.New("unexpected pulse socket mode")
|
|
)
|
|
|
|
var posixUsername = regexp.MustCompilePOSIX("^[a-z_]([A-Za-z0-9_-]{0,31}|[A-Za-z0-9_-]{0,30}\\$)$")
|
|
|
|
// appSeal stores copies of various parts of [fst.Config]
|
|
type appSeal struct {
|
|
// passed through from [fst.Config]
|
|
command []string
|
|
|
|
// initial [fst.Config] gob stream for state data;
|
|
// this is prepared ahead of time as config is mutated during seal creation
|
|
ct io.WriterTo
|
|
// dump dbus proxy message buffer
|
|
dbusMsg func()
|
|
|
|
user appUser
|
|
sys *system.I
|
|
container *bwrap.Config
|
|
bwrapSync *os.File
|
|
|
|
// protected by upstream mutex
|
|
}
|
|
|
|
// appUser stores post-fsu credentials and metadata
|
|
type appUser struct {
|
|
// application id
|
|
aid *stringPair[int]
|
|
// target uid resolved by fid:aid
|
|
uid *stringPair[int]
|
|
|
|
// supplementary group ids
|
|
supp []string
|
|
|
|
// home directory host path
|
|
data string
|
|
// app user home directory
|
|
home string
|
|
// passwd database username
|
|
username string
|
|
}
|
|
|
|
func (seal *appSeal) finalise(sys sys.State, config *fst.Config, id string) error {
|
|
{
|
|
// encode initial configuration for state tracking
|
|
ct := new(bytes.Buffer)
|
|
if err := gob.NewEncoder(ct).Encode(config); err != nil {
|
|
return fmsg.WrapErrorSuffix(err,
|
|
"cannot encode initial config:")
|
|
}
|
|
seal.ct = ct
|
|
}
|
|
|
|
// pass through command slice; this value is never touched in the main process
|
|
seal.command = config.Command
|
|
|
|
// allowed aid range 0 to 9999, this is checked again in fsu
|
|
if config.Confinement.AppID < 0 || config.Confinement.AppID > 9999 {
|
|
return fmsg.WrapError(ErrUser,
|
|
fmt.Sprintf("aid %d out of range", config.Confinement.AppID))
|
|
}
|
|
|
|
/*
|
|
Resolve post-fsu user state
|
|
*/
|
|
|
|
seal.user = appUser{
|
|
aid: newInt(config.Confinement.AppID),
|
|
data: config.Confinement.Outer,
|
|
home: config.Confinement.Inner,
|
|
username: config.Confinement.Username,
|
|
}
|
|
if seal.user.username == "" {
|
|
seal.user.username = "chronos"
|
|
} else if !posixUsername.MatchString(seal.user.username) ||
|
|
len(seal.user.username) >= internal.Sysconf_SC_LOGIN_NAME_MAX() {
|
|
return fmsg.WrapError(ErrName,
|
|
fmt.Sprintf("invalid user name %q", seal.user.username))
|
|
}
|
|
if seal.user.data == "" || !path.IsAbs(seal.user.data) {
|
|
return fmsg.WrapError(ErrHome,
|
|
fmt.Sprintf("invalid home directory %q", seal.user.data))
|
|
}
|
|
if seal.user.home == "" {
|
|
seal.user.home = seal.user.data
|
|
}
|
|
if u, err := sys.Uid(seal.user.aid.unwrap()); err != nil {
|
|
return err
|
|
} else {
|
|
seal.user.uid = newInt(u)
|
|
}
|
|
seal.user.supp = make([]string, len(config.Confinement.Groups))
|
|
for i, name := range config.Confinement.Groups {
|
|
if g, err := sys.LookupGroup(name); err != nil {
|
|
return fmsg.WrapError(err,
|
|
fmt.Sprintf("unknown group %q", name))
|
|
} else {
|
|
seal.user.supp[i] = g.Gid
|
|
}
|
|
}
|
|
|
|
/*
|
|
Resolve initial container state
|
|
*/
|
|
|
|
// permissive defaults
|
|
if config.Confinement.Sandbox == nil {
|
|
fmsg.Verbose("sandbox configuration not supplied, PROCEED WITH CAUTION")
|
|
|
|
conf := &fst.SandboxConfig{
|
|
UserNS: true,
|
|
Net: true,
|
|
Syscall: new(bwrap.SyscallPolicy),
|
|
NoNewSession: true,
|
|
AutoEtc: true,
|
|
}
|
|
// bind entries in /
|
|
if d, err := sys.ReadDir("/"); err != nil {
|
|
return err
|
|
} else {
|
|
b := make([]*fst.FilesystemConfig, 0, len(d))
|
|
for _, ent := range d {
|
|
p := "/" + ent.Name()
|
|
switch p {
|
|
case "/proc":
|
|
case "/dev":
|
|
case "/tmp":
|
|
case "/mnt":
|
|
case "/etc":
|
|
|
|
default:
|
|
b = append(b, &fst.FilesystemConfig{Src: p, Write: true, Must: true})
|
|
}
|
|
}
|
|
conf.Filesystem = append(conf.Filesystem, b...)
|
|
}
|
|
|
|
// hide nscd from sandbox if present
|
|
nscd := "/var/run/nscd"
|
|
if _, err := sys.Stat(nscd); !errors.Is(err, fs.ErrNotExist) {
|
|
conf.Override = append(conf.Override, nscd)
|
|
}
|
|
// bind GPU stuff
|
|
if config.Confinement.Enablements.Has(system.EX11) || config.Confinement.Enablements.Has(system.EWayland) {
|
|
conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/dri", Device: true})
|
|
}
|
|
// opportunistically bind kvm
|
|
conf.Filesystem = append(conf.Filesystem, &fst.FilesystemConfig{Src: "/dev/kvm", Device: true})
|
|
|
|
config.Confinement.Sandbox = conf
|
|
}
|
|
|
|
var mapuid *stringPair[int]
|
|
{
|
|
var uid int
|
|
var err error
|
|
seal.container, err = config.Confinement.Sandbox.Bwrap(sys, &uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mapuid = newInt(uid)
|
|
if seal.container.SetEnv == nil {
|
|
seal.container.SetEnv = make(map[string]string)
|
|
}
|
|
}
|
|
|
|
/*
|
|
Initialise externals
|
|
*/
|
|
|
|
sc := sys.Paths()
|
|
seal.sys = system.New(seal.user.uid.unwrap())
|
|
seal.sys.IsVerbose = fmsg.Load
|
|
seal.sys.Verbose = fmsg.Verbose
|
|
seal.sys.Verbosef = fmsg.Verbosef
|
|
seal.sys.WrapErr = fmsg.WrapError
|
|
|
|
/*
|
|
Work directories
|
|
*/
|
|
|
|
// base fortify share path
|
|
seal.sys.Ensure(sc.SharePath, 0711)
|
|
|
|
// outer paths used by the main process
|
|
seal.sys.Ensure(sc.RunDirPath, 0700)
|
|
seal.sys.UpdatePermType(system.User, sc.RunDirPath, acl.Execute)
|
|
seal.sys.Ensure(sc.RuntimePath, 0700) // ensure this dir in case XDG_RUNTIME_DIR is unset
|
|
seal.sys.UpdatePermType(system.User, sc.RuntimePath, acl.Execute)
|
|
|
|
// outer process-specific share directory
|
|
sharePath := path.Join(sc.SharePath, id)
|
|
seal.sys.Ephemeral(system.Process, sharePath, 0711)
|
|
// similar to share but within XDG_RUNTIME_DIR
|
|
sharePathLocal := path.Join(sc.RunDirPath, id)
|
|
seal.sys.Ephemeral(system.Process, sharePathLocal, 0700)
|
|
seal.sys.UpdatePerm(sharePathLocal, acl.Execute)
|
|
|
|
// inner XDG_RUNTIME_DIR default formatting of `/run/user/%d` as post-fsu user
|
|
innerRuntimeDir := path.Join("/run/user", mapuid.String())
|
|
seal.container.Tmpfs("/run/user", 1*1024*1024)
|
|
seal.container.Tmpfs(innerRuntimeDir, 8*1024*1024)
|
|
seal.container.SetEnv[xdgRuntimeDir] = innerRuntimeDir
|
|
seal.container.SetEnv[xdgSessionClass] = "user"
|
|
seal.container.SetEnv[xdgSessionType] = "tty"
|
|
|
|
// outer path for inner /tmp
|
|
{
|
|
tmpdir := path.Join(sc.SharePath, "tmpdir")
|
|
seal.sys.Ensure(tmpdir, 0700)
|
|
seal.sys.UpdatePermType(system.User, tmpdir, acl.Execute)
|
|
tmpdirProc := path.Join(tmpdir, seal.user.aid.String())
|
|
seal.sys.Ensure(tmpdirProc, 01700)
|
|
seal.sys.UpdatePermType(system.User, tmpdirProc, acl.Read, acl.Write, acl.Execute)
|
|
seal.container.Bind(tmpdirProc, "/tmp", false, true)
|
|
}
|
|
|
|
/*
|
|
Passwd database
|
|
*/
|
|
|
|
// look up shell
|
|
sh := "/bin/sh"
|
|
if s, ok := sys.LookupEnv(shell); ok {
|
|
seal.container.SetEnv[shell] = s
|
|
sh = s
|
|
}
|
|
|
|
// bind home directory
|
|
homeDir := "/var/empty"
|
|
if seal.user.home != "" {
|
|
homeDir = seal.user.home
|
|
}
|
|
username := "chronos"
|
|
if seal.user.username != "" {
|
|
username = seal.user.username
|
|
}
|
|
seal.container.Bind(seal.user.data, homeDir, false, true)
|
|
seal.container.Chdir = homeDir
|
|
seal.container.SetEnv["HOME"] = homeDir
|
|
seal.container.SetEnv["USER"] = username
|
|
|
|
// generate /etc/passwd and /etc/group
|
|
seal.container.CopyBind("/etc/passwd",
|
|
[]byte(username+":x:"+mapuid.String()+":"+mapuid.String()+":Fortify:"+homeDir+":"+sh+"\n"))
|
|
seal.container.CopyBind("/etc/group",
|
|
[]byte("fortify:x:"+mapuid.String()+":\n"))
|
|
|
|
/*
|
|
Display servers
|
|
*/
|
|
|
|
// pass $TERM to launcher
|
|
if t, ok := sys.LookupEnv(term); ok {
|
|
seal.container.SetEnv[term] = t
|
|
}
|
|
|
|
// set up wayland
|
|
if config.Confinement.Enablements.Has(system.EWayland) {
|
|
// outer wayland socket (usually `/run/user/%d/wayland-%d`)
|
|
var socketPath string
|
|
if name, ok := sys.LookupEnv(wl.WaylandDisplay); !ok {
|
|
fmsg.Verbose(wl.WaylandDisplay + " is not set, assuming " + wl.FallbackName)
|
|
socketPath = path.Join(sc.RuntimePath, wl.FallbackName)
|
|
} else if !path.IsAbs(name) {
|
|
socketPath = path.Join(sc.RuntimePath, name)
|
|
} else {
|
|
socketPath = name
|
|
}
|
|
|
|
innerPath := path.Join(innerRuntimeDir, wl.FallbackName)
|
|
seal.container.SetEnv[wl.WaylandDisplay] = wl.FallbackName
|
|
|
|
if !config.Confinement.Sandbox.DirectWayland { // set up security-context-v1
|
|
socketDir := path.Join(sc.SharePath, "wayland")
|
|
outerPath := path.Join(socketDir, id)
|
|
seal.sys.Ensure(socketDir, 0711)
|
|
appID := config.ID
|
|
if appID == "" {
|
|
// use instance ID in case app id is not set
|
|
appID = "uk.gensokyo.fortify." + id
|
|
}
|
|
seal.sys.Wayland(&seal.bwrapSync, outerPath, socketPath, appID, id)
|
|
seal.container.Bind(outerPath, innerPath)
|
|
} else { // bind mount wayland socket (insecure)
|
|
fmsg.Verbose("direct wayland access, PROCEED WITH CAUTION")
|
|
seal.container.Bind(socketPath, innerPath)
|
|
seal.sys.UpdatePermType(system.EWayland, socketPath, acl.Read, acl.Write, acl.Execute)
|
|
}
|
|
}
|
|
|
|
// set up X11
|
|
if config.Confinement.Enablements.Has(system.EX11) {
|
|
// discover X11 and grant user permission via the `ChangeHosts` command
|
|
if d, ok := sys.LookupEnv(display); !ok {
|
|
return fmsg.WrapError(ErrXDisplay,
|
|
"DISPLAY is not set")
|
|
} else {
|
|
seal.sys.ChangeHosts("#" + seal.user.uid.String())
|
|
seal.container.SetEnv[display] = d
|
|
seal.container.Bind("/tmp/.X11-unix", "/tmp/.X11-unix")
|
|
}
|
|
}
|
|
|
|
/*
|
|
PulseAudio server and authentication
|
|
*/
|
|
|
|
if config.Confinement.Enablements.Has(system.EPulse) {
|
|
// PulseAudio runtime directory (usually `/run/user/%d/pulse`)
|
|
pulseRuntimeDir := path.Join(sc.RuntimePath, "pulse")
|
|
// PulseAudio socket (usually `/run/user/%d/pulse/native`)
|
|
pulseSocket := path.Join(pulseRuntimeDir, "native")
|
|
|
|
if _, err := sys.Stat(pulseRuntimeDir); err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return fmsg.WrapErrorSuffix(err,
|
|
fmt.Sprintf("cannot access PulseAudio directory %q:", pulseRuntimeDir))
|
|
}
|
|
return fmsg.WrapError(ErrPulseSocket,
|
|
fmt.Sprintf("PulseAudio directory %q not found", pulseRuntimeDir))
|
|
}
|
|
|
|
if s, err := sys.Stat(pulseSocket); err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return fmsg.WrapErrorSuffix(err,
|
|
fmt.Sprintf("cannot access PulseAudio socket %q:", pulseSocket))
|
|
}
|
|
return fmsg.WrapError(ErrPulseSocket,
|
|
fmt.Sprintf("PulseAudio directory %q found but socket does not exist", pulseRuntimeDir))
|
|
} else {
|
|
if m := s.Mode(); m&0o006 != 0o006 {
|
|
return fmsg.WrapError(ErrPulseMode,
|
|
fmt.Sprintf("unexpected permissions on %q:", pulseSocket), m)
|
|
}
|
|
}
|
|
|
|
// hard link pulse socket into target-executable share
|
|
innerPulseRuntimeDir := path.Join(sharePathLocal, "pulse")
|
|
innerPulseSocket := path.Join(innerRuntimeDir, "pulse", "native")
|
|
seal.sys.Link(pulseSocket, innerPulseRuntimeDir)
|
|
seal.container.Bind(innerPulseRuntimeDir, innerPulseSocket)
|
|
seal.container.SetEnv[pulseServer] = "unix:" + innerPulseSocket
|
|
|
|
// publish current user's pulse cookie for target user
|
|
if src, err := discoverPulseCookie(sys); err != nil {
|
|
// not fatal
|
|
fmsg.Verbose(strings.TrimSpace(err.(*fmsg.BaseError).Message()))
|
|
} else {
|
|
innerDst := fst.Tmp + "/pulse-cookie"
|
|
seal.container.SetEnv[pulseCookie] = innerDst
|
|
payload := new([]byte)
|
|
seal.container.CopyBindRef(innerDst, &payload)
|
|
seal.sys.CopyFile(payload, src, 256, 256)
|
|
}
|
|
}
|
|
|
|
/*
|
|
D-Bus proxy
|
|
*/
|
|
|
|
if config.Confinement.Enablements.Has(system.EDBus) {
|
|
// ensure dbus session bus defaults
|
|
if config.Confinement.SessionBus == nil {
|
|
config.Confinement.SessionBus = dbus.NewConfig(config.ID, true, true)
|
|
}
|
|
|
|
// downstream socket paths
|
|
sessionPath, systemPath := path.Join(sharePath, "bus"), path.Join(sharePath, "system_bus_socket")
|
|
|
|
// configure dbus proxy
|
|
if f, err := seal.sys.ProxyDBus(
|
|
config.Confinement.SessionBus, config.Confinement.SystemBus,
|
|
sessionPath, systemPath,
|
|
); err != nil {
|
|
return err
|
|
} else {
|
|
seal.dbusMsg = f
|
|
}
|
|
|
|
// share proxy sockets
|
|
sessionInner := path.Join(innerRuntimeDir, "bus")
|
|
seal.container.SetEnv[dbusSessionBusAddress] = "unix:path=" + sessionInner
|
|
seal.container.Bind(sessionPath, sessionInner)
|
|
seal.sys.UpdatePerm(sessionPath, acl.Read, acl.Write)
|
|
if config.Confinement.SystemBus != nil {
|
|
systemInner := "/run/dbus/system_bus_socket"
|
|
seal.container.SetEnv[dbusSystemBusAddress] = "unix:path=" + systemInner
|
|
seal.container.Bind(systemPath, systemInner)
|
|
seal.sys.UpdatePerm(systemPath, acl.Read, acl.Write)
|
|
}
|
|
}
|
|
|
|
/*
|
|
Miscellaneous
|
|
*/
|
|
|
|
// queue overriding tmpfs at the end of seal.container.Filesystem
|
|
for _, dest := range config.Confinement.Sandbox.Override {
|
|
seal.container.Tmpfs(dest, 8*1024)
|
|
}
|
|
|
|
// append ExtraPerms last
|
|
for _, p := range config.Confinement.ExtraPerms {
|
|
if p == nil {
|
|
continue
|
|
}
|
|
|
|
if p.Ensure {
|
|
seal.sys.Ensure(p.Path, 0700)
|
|
}
|
|
|
|
perms := make(acl.Perms, 0, 3)
|
|
if p.Read {
|
|
perms = append(perms, acl.Read)
|
|
}
|
|
if p.Write {
|
|
perms = append(perms, acl.Write)
|
|
}
|
|
if p.Execute {
|
|
perms = append(perms, acl.Execute)
|
|
}
|
|
seal.sys.UpdatePermType(system.User, p.Path, perms...)
|
|
}
|
|
|
|
// mount fortify in sandbox for init
|
|
seal.container.Bind(sys.MustExecutable(), path.Join(fst.Tmp, "sbin/fortify"))
|
|
seal.container.Symlink("fortify", path.Join(fst.Tmp, "sbin/init"))
|
|
|
|
fmsg.Verbosef("created application seal for uid %s (%s) groups: %v, command: %s",
|
|
seal.user.uid, seal.user.username, config.Confinement.Groups, config.Command)
|
|
|
|
return nil
|
|
}
|
|
|
|
// discoverPulseCookie attempts various standard methods to discover the current user's PulseAudio authentication cookie
|
|
func discoverPulseCookie(sys sys.State) (string, error) {
|
|
if p, ok := sys.LookupEnv(pulseCookie); ok {
|
|
return p, nil
|
|
}
|
|
|
|
// dotfile $HOME/.pulse-cookie
|
|
if p, ok := sys.LookupEnv(home); ok {
|
|
p = path.Join(p, ".pulse-cookie")
|
|
if s, err := sys.Stat(p); err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return p, fmsg.WrapErrorSuffix(err,
|
|
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
|
|
}
|
|
// not found, try next method
|
|
} else if !s.IsDir() {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
// $XDG_CONFIG_HOME/pulse/cookie
|
|
if p, ok := sys.LookupEnv(xdgConfigHome); ok {
|
|
p = path.Join(p, "pulse", "cookie")
|
|
if s, err := sys.Stat(p); err != nil {
|
|
if !errors.Is(err, fs.ErrNotExist) {
|
|
return p, fmsg.WrapErrorSuffix(err,
|
|
fmt.Sprintf("cannot access PulseAudio cookie %q:", p))
|
|
}
|
|
// not found, try next method
|
|
} else if !s.IsDir() {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
return "", fmsg.WrapError(ErrPulseCookie,
|
|
fmt.Sprintf("cannot locate PulseAudio cookie (tried $%s, $%s/pulse/cookie, $%s/.pulse-cookie)",
|
|
pulseCookie, xdgConfigHome, home))
|
|
}
|