package hst import ( "errors" "strconv" "strings" "hakurei.app/container/check" ) // Config configures an application container. type Config struct { // Reverse-DNS style configured arbitrary identifier string. // // This value is passed as is to Wayland security-context-v1 and used as // part of defaults in D-Bus session proxy. The zero value causes a default // value to be derived from the container instance. ID string `json:"id,omitempty"` // System services to make available in the container. Enablements *Enablements `json:"enablements,omitempty"` // Session D-Bus proxy configuration. // // Has no effect if [EDBus] but is not set in Enablements. The zero value // assumes built-in defaults derived from ID. SessionBus *BusConfig `json:"session_bus,omitempty"` // System D-Bus proxy configuration. // // Has no effect if [EDBus] but is not set in Enablements. The zero value // disables system bus proxy. SystemBus *BusConfig `json:"system_bus,omitempty"` // Direct access to Wayland socket, no attempt is made to attach // security-context-v1 and the bare socket is made available to the // container. // // This option is unsupported and will most likely enable full control over // the Wayland session from within the container. Do not set this to true // unless you are sure you know what you are doing. DirectWayland bool `json:"direct_wayland,omitempty"` // Direct access to the PipeWire socket established via SecurityContext::Create, // no attempt is made to start the pipewire-pulse server. // // The SecurityContext machinery is fatally flawed, it unconditionally sets // read and execute bits on all objects for clients with the lowest achievable // privilege level (by setting PW_KEY_ACCESS to "restricted" or by satisfying // all conditions of [the /.flatpak-info hack]). This enables them to call // any method targeting any object, and since Registry::Destroy checks for // the read and execute bit, allows the destruction of any object other than // PW_ID_CORE as well. // // This behaviour is implemented separately in media-session and wireplumber, // with the wireplumber implementation in Lua via an embedded Lua vm. In all // known setups, wireplumber is in use, and in that case, no option for // configuring this behaviour exists, without replacing the Lua script. // Also, since PipeWire relies on these permissions to work, reducing them // was never possible in the first place. // // Currently, the only other sandboxed use case is flatpak, which is not // aware of PipeWire and blindly exposes the bare PulseAudio socket to the // container (behaves like DirectPulse). This socket is backed by the // pipewire-pulse compatibility daemon, which obtains client pid via the // SO_PEERCRED option. The PipeWire daemon, pipewire-pulse daemon and the // session manager daemon then separately performs [the /.flatpak-info hack]. // Under such use case, since the client has no direct access to PipeWire, // insecure parts of the protocol are obscured by the absence of an // equivalent API in PulseAudio, or pipewire-pulse simply not implementing // them. // // Hakurei does not rely on [the /.flatpak-info hack]. Instead, a socket is // sets up via SecurityContext. A pipewire-pulse server connected through it // achieves the same permissions as flatpak does via [the /.flatpak-info hack] // and is maintained for the life of the container. // // This option is unsupported and enables a denial-of-service attack as the // sandboxed client is able to destroy any client object and thus // disconnecting them from PipeWire, or destroy the SecurityContext object, // preventing any further container creation. // // Do not set this to true, it is insecure under any configuration. // // [the /.flatpak-info hack]: https://git.gensokyo.uk/security/hakurei/issues/21 DirectPipeWire bool `json:"direct_pipewire,omitempty"` // Direct access to PulseAudio socket, no attempt is made to establish // pipewire-pulse server via a PipeWire socket with a SecurityContext // attached, and the bare socket is made available to the container. // // This option is unsupported and enables arbitrary code execution as the // PulseAudio server. // // Do not set this to true, it is insecure under any configuration. DirectPulse bool `json:"direct_pulse,omitempty"` // Extra acl updates to perform before setuid. ExtraPerms []ExtraPermConfig `json:"extra_perms,omitempty"` // Numerical application id, passed to hsu, used to derive init user // namespace credentials. Identity int `json:"identity"` // Init user namespace supplementary groups inherited by all container processes. Groups []string `json:"groups"` // High level configuration applied to the underlying [container]. Container *ContainerConfig `json:"container"` } var ( // ErrConfigNull is returned by [Config.Validate] for an invalid configuration // that contains a null value for any field that must not be null. ErrConfigNull = errors.New("unexpected null in config") // ErrIdentityBounds is returned by [Config.Validate] for an out of bounds // [Config.Identity] value. ErrIdentityBounds = errors.New("identity out of bounds") // ErrEnviron is returned by [Config.Validate] if an environment variable // name contains '=' or NUL. ErrEnviron = errors.New("invalid environment variable name") // ErrInsecure is returned by [Config.Validate] if the configuration is // considered insecure. ErrInsecure = errors.New("configuration is insecure") ) // Validate checks [Config] and returns [AppError] if an invalid value is encountered. func (config *Config) Validate() error { if config == nil { return &AppError{Step: "validate configuration", Err: ErrConfigNull, Msg: "invalid configuration"} } // this is checked again in hsu if config.Identity < IdentityStart || config.Identity > IdentityEnd { return &AppError{Step: "validate configuration", Err: ErrIdentityBounds, Msg: "identity " + strconv.Itoa(config.Identity) + " out of range"} } if err := config.SessionBus.CheckInterfaces("session"); err != nil { return err } if err := config.SystemBus.CheckInterfaces("system"); err != nil { return err } if config.Container == nil { return &AppError{Step: "validate configuration", Err: ErrConfigNull, Msg: "configuration missing container state"} } if config.Container.Home == nil { return &AppError{Step: "validate configuration", Err: ErrConfigNull, Msg: "container configuration missing path to home directory"} } if config.Container.Shell == nil { return &AppError{Step: "validate configuration", Err: ErrConfigNull, Msg: "container configuration missing path to shell"} } if config.Container.Path == nil { return &AppError{Step: "validate configuration", Err: ErrConfigNull, Msg: "container configuration missing path to initial program"} } for key := range config.Container.Env { if strings.IndexByte(key, '=') != -1 || strings.IndexByte(key, 0) != -1 { return &AppError{Step: "validate configuration", Err: ErrEnviron, Msg: "invalid environment variable " + strconv.Quote(key)} } } if et := config.Enablements.Unwrap(); !config.DirectPulse && et&EPulse != 0 { return &AppError{Step: "validate configuration", Err: ErrInsecure, Msg: "enablement PulseAudio is insecure and no longer supported"} } return nil } // ExtraPermConfig describes an acl update to perform before setuid. type ExtraPermConfig struct { // Whether to create Path as a directory if it does not exist. Ensure bool `json:"ensure,omitempty"` // Pathname to act on. Path *check.Absolute `json:"path"` // Whether to set ACL_READ for the target user. Read bool `json:"r,omitempty"` // Whether to set ACL_WRITE for the target user. Write bool `json:"w,omitempty"` // Whether to set ACL_EXECUTE for the target user. Execute bool `json:"x,omitempty"` } // String returns a checked string representation of [ExtraPermConfig]. func (e *ExtraPermConfig) String() string { if e == nil || e.Path == nil { return "" } buf := make([]byte, 0, 5+len(e.Path.String())) buf = append(buf, '-', '-', '-') if e.Ensure { buf = append(buf, '+') } buf = append(buf, ':') buf = append(buf, []byte(e.Path.String())...) if e.Read { buf[0] = 'r' } if e.Write { buf[1] = 'w' } if e.Execute { buf[2] = 'x' } return string(buf) }