1package wayland
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/bnema/go-wayland-bindings/server/securitycontext"
13 "github.com/bnema/neferwl/internal/adapters/logging"
14 "github.com/bnema/wlturbo"
15 "golang.org/x/sys/unix"
16)
17
18// sandboxListener is what a sandbox engine prepares: a listening socket and
19// the pipe whose hangup ends the context.
20type sandboxListener struct {
21 path string
22 listen int // engine's copy; the compositor gets a dup
23 closeR int // goes to the compositor
24 closeW int // the engine keeps it; closing it hangs up closeR
25 released bool
26}
27
28func newSandboxListener(t *testing.T) *sandboxListener {
29 t.Helper()
30 // Unix socket paths are short; t.TempDir can be long.
31 dir, err := os.MkdirTemp("", "sbx")
32 if err != nil {
33 t.Fatal(err)
34 }
35 t.Cleanup(func() { _ = os.RemoveAll(dir) })
36 l := &sandboxListener{path: filepath.Join(dir, "s")}
37 if l.listen, err = unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0); err != nil {
38 t.Fatal(err)
39 }
40 if err = unix.Bind(l.listen, &unix.SockaddrUnix{Name: l.path}); err != nil {
41 t.Fatal(err)
42 }
43 if err = unix.Listen(l.listen, 8); err != nil {
44 t.Fatal(err)
45 }
46 var p [2]int
47 if err = unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
48 t.Fatal(err)
49 }
50 l.closeR, l.closeW = p[0], p[1]
51 t.Cleanup(func() {
52 if !l.released {
53 _ = unix.Close(l.listen)
54 }
55 _ = unix.Close(l.closeR)
56 if l.closeW >= 0 {
57 _ = unix.Close(l.closeW)
58 }
59 })
60 return l
61}
62
63// handOver drops the engine's descriptors the compositor has its own copies
64// of (the protocol: closing them is the only valid operation afterwards). The
65// write end of the pipe stays with the engine.
66func (l *sandboxListener) handOver() {
67 _ = unix.Close(l.listen)
68 _ = unix.Close(l.closeR)
69 l.released, l.closeR = true, -1
70}
71
72func (l *sandboxListener) hangUp() {
73 _ = unix.Close(l.closeW)
74 l.closeW = -1
75}
76
77func (l *sandboxListener) connect(t *testing.T) *wlturbo.Display {
78 t.Helper()
79 c, err := wlturbo.Connect(l.path)
80 if err != nil {
81 t.Fatal(err)
82 }
83 t.Cleanup(func() { _ = c.Close(); forgetWireClient(c) })
84 if err := c.Roundtrip(); err != nil {
85 t.Fatal(err)
86 }
87 return c
88}
89
90// refused reports whether nothing listens on the socket any more.
91func (l *sandboxListener) refused() bool {
92 fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
93 if err != nil {
94 return false
95 }
96 defer unix.Close(fd)
97 return errors.Is(unix.Connect(fd, &unix.SockaddrUnix{Name: l.path}), unix.ECONNREFUSED)
98}
99
100func waitTrue(t *testing.T, what string, cond func() bool) {
101 t.Helper()
102 deadline := time.Now().Add(5 * time.Second)
103 for !cond() {
104 if time.Now().After(deadline) {
105 t.Fatal("timeout: " + what)
106 }
107 time.Sleep(2 * time.Millisecond)
108 }
109}
110
111// contextClient is a client of the main socket with a security context being built.
112type contextClient struct {
113 c *wlturbo.Display
114 manager uint32
115 ctx uint32
116}
117
118func newContextClient(t *testing.T, h *admissionHarness) *contextClient {
119 t.Helper()
120 c := protocolClient(t, h.s, h.dir)
121 return &contextClient{c: c, manager: bindProtocol(t, c, "wp_security_context_manager_v1")}
122}
123
124// create sends create_listener with the engine's descriptors.
125func (cc *contextClient) create(t *testing.T, listen, closeFD int) {
126 t.Helper()
127 cc.ctx = cc.c.AllocateID()
128 if err := wireRequest(cc.c, cc.manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{listen, closeFD}, cc.ctx); err != nil {
129 t.Fatal(err)
130 }
131}
132
133func (cc *contextClient) request(t *testing.T, op uint32, args ...any) {
134 t.Helper()
135 requestProtocol(t, cc.c, cc.ctx, op, args...)
136}
137
138// commit makes a context with an engine, like a Flatpak would, and waits for
139// the compositor to have processed it.
140func (cc *contextClient) commit(t *testing.T, l *sandboxListener, engine, app string) {
141 t.Helper()
142 cc.create(t, l.listen, l.closeR)
143 cc.request(t, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, engine)
144 if app != "" {
145 cc.request(t, securitycontext.WpSecurityContextV1RequestSetAppId, app)
146 }
147 cc.request(t, securitycontext.WpSecurityContextV1RequestCommit)
148 if err := cc.c.Roundtrip(); err != nil {
149 t.Fatal(err)
150 }
151 l.handOver()
152}
153
154func (h *admissionHarness) sandboxedCount() int {
155 n := -1
156 h.s.display.Do(func() { n = len(h.s.sandboxed) })
157 return n
158}
159
160// A client that connects through a committed context is sandboxed: it cannot
161// capture, while a client of the main socket still can (the allowlist is `*` here).
162func TestSecurityContextSandboxedClientCannotCapture(t *testing.T) {
163 h := newAdmissionHarnessWith(t, maxCaptureInflight+2, Options{CaptureAllow: allowStore(t, "*\n")}, logging.For(context.Background(), "wayland"))
164 plain := h.client(t)
165 if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
166 t.Fatal("plain client refused")
167 }
168
169 l := newSandboxListener(t)
170 cc := newContextClient(t, h)
171 cc.commit(t, l, "org.flatpak", "org.example.App")
172
173 sandboxed := h.clientOn(t, l.connect(t))
174 waitTrue(t, "client tagged", func() bool { return h.sandboxedCount() == 1 })
175 for i := 0; i < 2; i++ {
176 if _, ok := h.accepted(t, sandboxed.captureWlr(t)); ok {
177 t.Fatal("sandboxed wlr capture allowed")
178 }
179 }
180 if !sandboxed.extSessionStopped(t) {
181 t.Fatal("sandboxed ext capture allowed")
182 }
183 // The committing client is not sandboxed by it, and is unaffected.
184 if _, ok := h.accepted(t, plain.captureWlr(t)); !ok {
185 t.Fatal("plain client refused after the context")
186 }
187 // The tag goes with the client.
188 _ = sandboxed.c.Close()
189 waitTrue(t, "tag dropped", func() bool { return h.sandboxedCount() == 0 })
190}
191
192// Sandboxed clients refuse even when the allowlist is off or lists them.
193func TestSecurityContextOverridesAllowlist(t *testing.T) {
194 self, _ := os.Executable()
195 for _, content := range []string{"*\n", self + "\n"} {
196 h, out := authHarness(t, allowStore(t, content), nil)
197 l := newSandboxListener(t)
198 newContextClient(t, h).commit(t, l, "org.flatpak", "")
199 c := h.clientOn(t, l.connect(t))
200 if _, ok := h.accepted(t, c.captureWlr(t)); ok {
201 t.Fatalf("sandboxed client captured with allowlist %q", content)
202 }
203 if !strings.Contains(out.String(), `"reason":"sandboxed client"`) {
204 t.Fatalf("log = %s", out.String())
205 }
206 }
207}
208
209// The listener keeps serving after the client that created it is gone, takes
210// several connections, and stops when close_fd hangs up.
211func TestSecurityContextListenerLifecycle(t *testing.T) {
212 h := newAdmissionHarness(t, maxCaptureInflight+2)
213 l := newSandboxListener(t)
214 cc := newContextClient(t, h)
215 cc.commit(t, l, "org.flatpak", "org.example.App")
216 _ = cc.c.Close() // the protocol: the listener outlives the client that made it
217 waitTrue(t, "creator gone", func() bool {
218 n := 0
219 h.s.display.Do(func() { n = len(h.s.peers) })
220 return n == 0
221 })
222 a, b := h.clientOn(t, l.connect(t)), h.clientOn(t, l.connect(t))
223 waitTrue(t, "two tagged", func() bool { return h.sandboxedCount() == 2 })
224 for _, c := range []*admissionClient{a, b} {
225 if _, ok := h.accepted(t, c.captureWlr(t)); ok {
226 t.Fatal("sandboxed client captured")
227 }
228 }
229 if h.s.securityLive.Load() != 1 {
230 t.Fatalf("listeners = %d", h.s.securityLive.Load())
231 }
232 l.hangUp()
233 waitTrue(t, "listener stopped", func() bool { return h.s.securityLive.Load() == 0 && l.refused() })
234 // Clients already connected are untouched.
235 if err := a.c.Roundtrip(); err != nil {
236 t.Fatal(err)
237 }
238}
239
240// Compositor shutdown ends the listeners and closes their sockets.
241func TestSecurityContextStopsWithTheCompositor(t *testing.T) {
242 dir := t.TempDir()
243 s, err := New(Options{RuntimeDir: dir}, Channels{}, logging.For(context.Background(), "wayland"))
244 if err != nil {
245 t.Fatal(err)
246 }
247 ctx, cancel := context.WithCancel(context.Background())
248 done := make(chan error, 1)
249 go func() { done <- s.Run(ctx) }()
250 defer cancel()
251 c := protocolClient(t, s, dir)
252 manager := bindProtocol(t, c, "wp_security_context_manager_v1")
253 l := newSandboxListener(t)
254 id := c.AllocateID()
255 if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{l.listen, l.closeR}, id); err != nil {
256 t.Fatal(err)
257 }
258 requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestSetSandboxEngine, "org.flatpak")
259 requestProtocol(t, c, id, securitycontext.WpSecurityContextV1RequestCommit)
260 if err := c.Roundtrip(); err != nil {
261 t.Fatal(err)
262 }
263 l.handOver()
264 waitTrue(t, "listening", func() bool { return s.securityLive.Load() == 1 })
265 cancel()
266 select {
267 case err := <-done:
268 if err != nil {
269 t.Fatal(err)
270 }
271 case <-time.After(5 * time.Second):
272 t.Fatal("Run did not return")
273 }
274 if s.securityLive.Load() != 0 || !l.refused() {
275 t.Fatalf("listener left behind: live=%d refused=%v", s.securityLive.Load(), l.refused())
276 }
277}
278
279// Destroying a context that was never committed closes the descriptors: the
280// socket stops listening.
281func TestSecurityContextDestroyBeforeCommit(t *testing.T) {
282 h := newAdmissionHarness(t, 2)
283 l := newSandboxListener(t)
284 cc := newContextClient(t, h)
285 cc.create(t, l.listen, l.closeR)
286 if err := cc.c.Roundtrip(); err != nil {
287 t.Fatal(err)
288 }
289 l.handOver()
290 if l.refused() {
291 t.Fatal("socket stopped listening before the context was destroyed")
292 }
293 cc.request(t, securitycontext.WpSecurityContextV1RequestDestroy)
294 if err := cc.c.Roundtrip(); err != nil {
295 t.Fatal(err)
296 }
297 waitTrue(t, "descriptors closed", l.refused)
298 if h.s.securityLive.Load() != 0 {
299 t.Fatal("an uncommitted context listens")
300 }
301}
302
303func TestSecurityContextErrors(t *testing.T) {
304 const (
305 ctxAlreadyUsed = uint32(securitycontext.WpSecurityContextV1ErrorAlreadyUsed)
306 ctxAlreadySet = uint32(securitycontext.WpSecurityContextV1ErrorAlreadySet)
307 ctxInvalidMeta = uint32(securitycontext.WpSecurityContextV1ErrorInvalidMetadata)
308 )
309 engine := uint32(securitycontext.WpSecurityContextV1RequestSetSandboxEngine)
310 app := uint32(securitycontext.WpSecurityContextV1RequestSetAppId)
311 inst := uint32(securitycontext.WpSecurityContextV1RequestSetInstanceId)
312 commit := uint32(securitycontext.WpSecurityContextV1RequestCommit)
313 type step struct {
314 op uint32
315 arg []any
316 }
317 tests := []struct {
318 name string
319 steps []step
320 code uint32
321 }{
322 {"engine twice", []step{{engine, []any{"a"}}, {engine, []any{"b"}}}, ctxAlreadySet},
323 {"app id twice", []step{{app, []any{"a"}}, {app, []any{"b"}}}, ctxAlreadySet},
324 {"instance id twice", []step{{inst, []any{"a"}}, {inst, []any{"b"}}}, ctxAlreadySet},
325 {"empty engine", []step{{engine, []any{""}}}, ctxInvalidMeta},
326 {"control characters", []step{{app, []any{"a\x01b"}}}, ctxInvalidMeta},
327 {"oversized", []step{{app, []any{strings.Repeat("x", maxSecurityMetadata+1)}}}, ctxInvalidMeta},
328 {"commit without engine", []step{{commit, nil}}, ctxInvalidMeta},
329 {"set after commit", []step{{engine, []any{"a"}}, {commit, nil}, {app, []any{"a"}}}, ctxAlreadyUsed},
330 {"commit twice", []step{{engine, []any{"a"}}, {commit, nil}, {commit, nil}}, ctxAlreadyUsed},
331 }
332 for _, tc := range tests {
333 t.Run(tc.name, func(t *testing.T) {
334 h := newAdmissionHarness(t, 2)
335 l := newSandboxListener(t)
336 cc := newContextClient(t, h)
337 cc.create(t, l.listen, l.closeR)
338 for _, s := range tc.steps {
339 cc.request(t, s.op, s.arg...)
340 }
341 expectProtocolError(t, cc.c, cc.ctx, tc.code)
342 l.handOver()
343 })
344 }
345}
346
347func TestSecurityContextInvalidListenFD(t *testing.T) {
348 h := newAdmissionHarness(t, 2)
349 // A connected socket, a regular pipe and a datagram socket listen on nothing.
350 pair, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0)
351 if err != nil {
352 t.Fatal(err)
353 }
354 defer unix.Close(pair[1])
355 dgram, err := unix.Socket(unix.AF_UNIX, unix.SOCK_DGRAM|unix.SOCK_CLOEXEC, 0)
356 if err != nil {
357 t.Fatal(err)
358 }
359 defer unix.Close(dgram)
360 pipe := make([]int, 2)
361 if err := unix.Pipe2(pipe, unix.O_CLOEXEC); err != nil {
362 t.Fatal(err)
363 }
364 defer unix.Close(pipe[1])
365 for name, fd := range map[string]int{"connected": pair[0], "datagram": dgram, "pipe": pipe[0]} {
366 t.Run(name, func(t *testing.T) {
367 cc := newContextClient(t, h)
368 r, w := mustPipe(t)
369 defer unix.Close(w)
370 cc.create(t, fd, r)
371 expectProtocolError(t, cc.c, cc.manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorInvalidListenFd))
372 _ = unix.Close(r)
373 })
374 }
375}
376
377func mustPipe(t *testing.T) (r, w int) {
378 t.Helper()
379 var p [2]int
380 if err := unix.Pipe2(p[:], unix.O_CLOEXEC); err != nil {
381 t.Fatal(err)
382 }
383 return p[0], p[1]
384}
385
386// A sandboxed client may bind the manager (binding every global is common)
387// but create_listener is the nested error.
388func TestSecurityContextNestedForbidden(t *testing.T) {
389 h := newAdmissionHarness(t, 2)
390 l := newSandboxListener(t)
391 newContextClient(t, h).commit(t, l, "org.flatpak", "")
392 c := l.connect(t)
393 manager := bindProtocol(t, c, "wp_security_context_manager_v1")
394 waitTrue(t, "tagged", func() bool { return h.sandboxedCount() == 1 })
395 inner := newSandboxListener(t)
396 id := c.AllocateID()
397 if err := wireRequest(c, manager, uint16(securitycontext.WpSecurityContextManagerV1RequestCreateListener), []int{inner.listen, inner.closeR}, id); err != nil {
398 t.Fatal(err)
399 }
400 expectProtocolError(t, c, manager, uint32(securitycontext.WpSecurityContextManagerV1ErrorNested))
401 // The compositor closed its copies: nothing listens on the inner socket
402 // once ours goes too.
403 inner.handOver()
404 waitTrue(t, "inner closed", inner.refused)
405}