aboutsummaryrefslogtreecommitdiffhomepage
path: root/hst/config.go
blob: 6b7e26f4fd10999fadc75406a486ee90b805ae84 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
package hst

import (
	"errors"
	"strconv"
	"strings"

	"hakurei.app/check"
	"hakurei.app/ext"
)

// 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/rosa/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"`

	// Scheduling policy to set for the container.
	//
	// The zero value retains the current scheduling policy.
	SchedPolicy ext.SchedPolicy `json:"sched_policy,omitempty"`
	// Scheduling priority to set for the container.
	//
	// The zero value implies the minimum priority of the current SchedPolicy.
	// Has no effect if SchedPolicy is zero.
	SchedPriority ext.Int `json:"sched_priority,omitempty"`

	// 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")

	// ErrSchedPolicyBounds is returned by [Config.Validate] for an out of bounds
	// [Config.SchedPolicy] value.
	ErrSchedPolicyBounds = errors.New("scheduling policy 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 config.SchedPolicy < 0 || config.SchedPolicy > ext.SCHED_LAST {
		return &AppError{Step: "validate configuration", Err: ErrSchedPolicyBounds,
			Msg: "scheduling policy " +
				strconv.Itoa(int(config.SchedPolicy)) +
				" 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 "<invalid>"
	}
	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)
}