xephyr-run 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: xephyr-run
3
+ Version: 0.1.0
4
+ Summary: Run one X client in its own named Xephyr window at a chosen position
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ziglang
8
+
9
+ # xephyr-run
10
+ Run X11 programs in xephyr jails so that they cannot talk to other X11 and perform actions or spy on your keypresses.
11
+
12
+ Thin wrapper around Xephyr which deals with some defaults and tracks xephyr X11 sessions.
13
+ Has some seccomp syscall filtering magic to prevent the jailed process from talking to other X sessions.
14
+
15
+ AI-generated and unreviewed. Mostly tested by the AI itself.
16
+
17
+ Run one X client in its own named [Xephyr](https://www.freedesktop.org/wiki/Software/Xephyr/)
18
+ window. The client talks to the nested X server, not your session, so it cannot
19
+ snoop your keystrokes, screenshot other windows or read your clipboard, as long as it is also
20
+ stopped from connecting to your main X server (see [Confinement](#confinement-the-default); this
21
+ is on by default). Your window manager places the Xephyr window like any other, and the client is
22
+ stretched to fill it, following resizes. Xephyr exits when the client does.
23
+
24
+ ## Installation
25
+ `pipx install xephyr-run`
26
+
27
+ Requires `Xephyr` and `xdotool`. The package also installs `ziglang`, which compiles the connect
28
+ filter described below on first use.
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ xephyr-run run -- xeyes
34
+ xephyr-run run -n term -- xterm -fa 'DejaVu Sans Mono' -fs 10
35
+ xephyr-run run --no-confine -- some-trusted-x-program # switch the connect filter off
36
+ xephyr-run list # name, display, Xephyr pid, command
37
+ xephyr-run attach term -- xwininfo -root -tree
38
+ xephyr-run kill term
39
+ ```
40
+
41
+ The name defaults to the command's name, and two instances cannot share one. The
42
+ Xephyr window is titled `xephyr-run-NAME`, which a window manager rule can match (for i3,
43
+ `for_window [title="^xephyr-run-"] floating enable`).
44
+ `-s WIDTHxHEIGHT` sets the initial size (default 640x400).
45
+
46
+ Instances are recorded in `$XDG_STATE_HOME/xephyr-run/NAME.json` (default
47
+ `~/.local/state/xephyr-run`) and cleaned up when they exit.
48
+
49
+
50
+ ## Caveats
51
+ This tool only jails X11 access. You might like to combine it with some other sort of jail (which I
52
+ may do persently). However, I felt this feature on its own was a useful demonstraction.
53
+
54
+
55
+ ## Alternatives and prior work
56
+ ed: used Xerphyr direction with a jail
57
+
58
+ ed: there was some socker of docker thing that did this
59
+
60
+ ## Why a plain Xephyr is not enough
61
+ By itself, Xephyr does not prevent arbitrary code from connecting to your main X11 server. With a
62
+ normal configuration, a program running as you can find the main server and connect to it: by its
63
+ file socket `/tmp/.X11-unix/X0` or by the abstract socket `@/tmp/.X11-unix/X0`. Desktop sessions
64
+ usually let any local process of your user in without a cookie (`xhost` lists `SI:localuser:you`),
65
+ so hiding `~/.Xauthority` does not help. A client that connects there can log your keystrokes,
66
+ take screenshots, and type into other windows.
67
+
68
+ There are approaches for securing X11 servers (a separate Unix user, restricted X access control,
69
+ untrusted cookies, a container or VM), but on Ubuntu everything is user based, and this tool is
70
+ meant to work without changing your X11 configuration. So instead of changing what the X server
71
+ allows, `xephyr-run` limits what the client can connect to.
72
+
73
+ The host can see into the Xephyr window (pixels and input), but as a single opaque window: it
74
+ cannot see the client's own windows, and `xwininfo -root -tree` on the host does not list them.
75
+
76
+ ## Confinement (the default)
77
+ Unless you pass `--no-confine`, the client runs under a **connect filter** that stops it connecting
78
+ to any X server other than its own nested one:
79
+
80
+ * every abstract Unix socket is refused (`ECONNREFUSED`, so X libraries fall back to the file socket);
81
+ * every Unix socket under `/tmp/.X11-unix` is refused (`EACCES`) except the instance's own display;
82
+ * everything else, including all network connections, is allowed.
83
+
84
+ Things the client may legitimately use over abstract sockets, such as a D-Bus session bus
85
+ configured with `unix:abstract=`, stop working. Try the program you intend to run, and use
86
+ `--no-confine` for programs you trust and which need them.
87
+
88
+ It does not need root, a container, or a network namespace.
89
+
90
+ ### How the connect filter works
91
+
92
+ `xephyr_run/connect_filter.c` is a small supervisor. It runs your command with a
93
+ [seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) filter that pauses each `connect()`
94
+ and hands it to the supervisor (seccomp user notification). The supervisor:
95
+
96
+ 1. copies the socket address out of the program's memory **once**;
97
+ 2. judges that copy against the ordered rules (first match wins; no match means allow);
98
+ 3. if allowed, performs the `connect()` itself on the program's own socket (through `pidfd_getfd`),
99
+ using the same copy.
100
+
101
+ Because the connect is made from the copy, the program cannot change the address between the
102
+ check and the use, which is the flaw in letting the kernel carry on with the original call.
103
+ A Unix path is canonicalised first (relative paths use the program's working directory, symlinks
104
+ are resolved) and the connect goes to that canonical path.
105
+
106
+ The filter also refuses `io_uring_setup` (io_uring can connect without a `connect` syscall) and
107
+ kills a process that switches to a different syscall ABI.
108
+
109
+ The first time a confined client is started, the supervisor is compiled with the `ziglang` package
110
+ (a self-contained C compiler, no system toolchain needed) into a static binary cached in
111
+ `$XDG_CACHE_HOME/xephyr-run` (default `~/.cache/xephyr-run`); the first build takes roughly
112
+ 20 seconds. Delete that directory to force a rebuild. If it cannot be compiled, a slower Python
113
+ implementation of the same logic (`xephyr_run/connect_filter.py`) is used, with a warning.
114
+
115
+ The seccomp code is independent of the rest of the tool. `connect_filter.py` and
116
+ `connect_filter.c` know nothing about X11 and can be reused with other rules:
117
+
118
+ ```python
119
+ from xephyr_run import connect_filter
120
+ process = connect_filter.popen_confined_rules(['some-command'], [('deny', '^@')])
121
+ # or with an arbitrary Python decision function: connect_filter.popen_confined(argv, lambda address: ...)
122
+ ```
123
+
124
+ ### Limits
125
+
126
+ * Only `connect()` is checked. A Unix **datagram** socket can be addressed with
127
+ `sendto`/`sendmsg` without connecting, and those are not covered. X11 uses stream sockets.
128
+ * A descriptor the client inherits, or is passed over another socket, is not covered.
129
+ * The supervisor's work is done per `connect()`, so a program that connects very often pays for
130
+ it; X clients connect rarely. The overhead has not been measured.
131
+ * Path rules judge the canonical path in the supervisor's filesystem view. A directory the client can
132
+ write to could be swapped for a symlink after the check; `/tmp/.X11-unix` is sticky and root-owned.
133
+ * Linux on x86_64 or aarch64 only, kernel 5.7 or newer (seccomp listeners and `pidfd_getfd`).
134
+ * The supervisor exits when the client does; other processes the client left running keep the filter
135
+ but their connects then fail.
136
+ * Landlock could do part of this without a supervisor (it can scope abstract Unix sockets since ABI 6),
137
+ but as far as I know it cannot yet restrict connecting to a pathname socket on kernel ABI 8.
138
+ * I am lazy and do not build versions of this for differnt hardware - but I wanted something
139
+ performant which others can use. I therefore outsource the building of filtering code to run time
140
+ using `ziglang`
141
+
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ pipx install --editable .
147
+ python3 -m unittest discover -s tests
148
+ ```
149
+
150
+ Run the tests with the pipx environment's Python so `ziglang` is available; otherwise the compiled
151
+ supervisor's tests are skipped if no system C compiler exists. The tests check both supervisors
152
+ against real sockets, including a test where a thread rewrites the address while `connect()` is
153
+ pending (and a control test showing that the same program does reach the forbidden socket
154
+ when unfiltered).
@@ -0,0 +1,146 @@
1
+ # xephyr-run
2
+ Run X11 programs in xephyr jails so that they cannot talk to other X11 and perform actions or spy on your keypresses.
3
+
4
+ Thin wrapper around Xephyr which deals with some defaults and tracks xephyr X11 sessions.
5
+ Has some seccomp syscall filtering magic to prevent the jailed process from talking to other X sessions.
6
+
7
+ AI-generated and unreviewed. Mostly tested by the AI itself.
8
+
9
+ Run one X client in its own named [Xephyr](https://www.freedesktop.org/wiki/Software/Xephyr/)
10
+ window. The client talks to the nested X server, not your session, so it cannot
11
+ snoop your keystrokes, screenshot other windows or read your clipboard, as long as it is also
12
+ stopped from connecting to your main X server (see [Confinement](#confinement-the-default); this
13
+ is on by default). Your window manager places the Xephyr window like any other, and the client is
14
+ stretched to fill it, following resizes. Xephyr exits when the client does.
15
+
16
+ ## Installation
17
+ `pipx install xephyr-run`
18
+
19
+ Requires `Xephyr` and `xdotool`. The package also installs `ziglang`, which compiles the connect
20
+ filter described below on first use.
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ xephyr-run run -- xeyes
26
+ xephyr-run run -n term -- xterm -fa 'DejaVu Sans Mono' -fs 10
27
+ xephyr-run run --no-confine -- some-trusted-x-program # switch the connect filter off
28
+ xephyr-run list # name, display, Xephyr pid, command
29
+ xephyr-run attach term -- xwininfo -root -tree
30
+ xephyr-run kill term
31
+ ```
32
+
33
+ The name defaults to the command's name, and two instances cannot share one. The
34
+ Xephyr window is titled `xephyr-run-NAME`, which a window manager rule can match (for i3,
35
+ `for_window [title="^xephyr-run-"] floating enable`).
36
+ `-s WIDTHxHEIGHT` sets the initial size (default 640x400).
37
+
38
+ Instances are recorded in `$XDG_STATE_HOME/xephyr-run/NAME.json` (default
39
+ `~/.local/state/xephyr-run`) and cleaned up when they exit.
40
+
41
+
42
+ ## Caveats
43
+ This tool only jails X11 access. You might like to combine it with some other sort of jail (which I
44
+ may do persently). However, I felt this feature on its own was a useful demonstraction.
45
+
46
+
47
+ ## Alternatives and prior work
48
+ ed: used Xerphyr direction with a jail
49
+
50
+ ed: there was some socker of docker thing that did this
51
+
52
+ ## Why a plain Xephyr is not enough
53
+ By itself, Xephyr does not prevent arbitrary code from connecting to your main X11 server. With a
54
+ normal configuration, a program running as you can find the main server and connect to it: by its
55
+ file socket `/tmp/.X11-unix/X0` or by the abstract socket `@/tmp/.X11-unix/X0`. Desktop sessions
56
+ usually let any local process of your user in without a cookie (`xhost` lists `SI:localuser:you`),
57
+ so hiding `~/.Xauthority` does not help. A client that connects there can log your keystrokes,
58
+ take screenshots, and type into other windows.
59
+
60
+ There are approaches for securing X11 servers (a separate Unix user, restricted X access control,
61
+ untrusted cookies, a container or VM), but on Ubuntu everything is user based, and this tool is
62
+ meant to work without changing your X11 configuration. So instead of changing what the X server
63
+ allows, `xephyr-run` limits what the client can connect to.
64
+
65
+ The host can see into the Xephyr window (pixels and input), but as a single opaque window: it
66
+ cannot see the client's own windows, and `xwininfo -root -tree` on the host does not list them.
67
+
68
+ ## Confinement (the default)
69
+ Unless you pass `--no-confine`, the client runs under a **connect filter** that stops it connecting
70
+ to any X server other than its own nested one:
71
+
72
+ * every abstract Unix socket is refused (`ECONNREFUSED`, so X libraries fall back to the file socket);
73
+ * every Unix socket under `/tmp/.X11-unix` is refused (`EACCES`) except the instance's own display;
74
+ * everything else, including all network connections, is allowed.
75
+
76
+ Things the client may legitimately use over abstract sockets, such as a D-Bus session bus
77
+ configured with `unix:abstract=`, stop working. Try the program you intend to run, and use
78
+ `--no-confine` for programs you trust and which need them.
79
+
80
+ It does not need root, a container, or a network namespace.
81
+
82
+ ### How the connect filter works
83
+
84
+ `xephyr_run/connect_filter.c` is a small supervisor. It runs your command with a
85
+ [seccomp](https://man7.org/linux/man-pages/man2/seccomp.2.html) filter that pauses each `connect()`
86
+ and hands it to the supervisor (seccomp user notification). The supervisor:
87
+
88
+ 1. copies the socket address out of the program's memory **once**;
89
+ 2. judges that copy against the ordered rules (first match wins; no match means allow);
90
+ 3. if allowed, performs the `connect()` itself on the program's own socket (through `pidfd_getfd`),
91
+ using the same copy.
92
+
93
+ Because the connect is made from the copy, the program cannot change the address between the
94
+ check and the use, which is the flaw in letting the kernel carry on with the original call.
95
+ A Unix path is canonicalised first (relative paths use the program's working directory, symlinks
96
+ are resolved) and the connect goes to that canonical path.
97
+
98
+ The filter also refuses `io_uring_setup` (io_uring can connect without a `connect` syscall) and
99
+ kills a process that switches to a different syscall ABI.
100
+
101
+ The first time a confined client is started, the supervisor is compiled with the `ziglang` package
102
+ (a self-contained C compiler, no system toolchain needed) into a static binary cached in
103
+ `$XDG_CACHE_HOME/xephyr-run` (default `~/.cache/xephyr-run`); the first build takes roughly
104
+ 20 seconds. Delete that directory to force a rebuild. If it cannot be compiled, a slower Python
105
+ implementation of the same logic (`xephyr_run/connect_filter.py`) is used, with a warning.
106
+
107
+ The seccomp code is independent of the rest of the tool. `connect_filter.py` and
108
+ `connect_filter.c` know nothing about X11 and can be reused with other rules:
109
+
110
+ ```python
111
+ from xephyr_run import connect_filter
112
+ process = connect_filter.popen_confined_rules(['some-command'], [('deny', '^@')])
113
+ # or with an arbitrary Python decision function: connect_filter.popen_confined(argv, lambda address: ...)
114
+ ```
115
+
116
+ ### Limits
117
+
118
+ * Only `connect()` is checked. A Unix **datagram** socket can be addressed with
119
+ `sendto`/`sendmsg` without connecting, and those are not covered. X11 uses stream sockets.
120
+ * A descriptor the client inherits, or is passed over another socket, is not covered.
121
+ * The supervisor's work is done per `connect()`, so a program that connects very often pays for
122
+ it; X clients connect rarely. The overhead has not been measured.
123
+ * Path rules judge the canonical path in the supervisor's filesystem view. A directory the client can
124
+ write to could be swapped for a symlink after the check; `/tmp/.X11-unix` is sticky and root-owned.
125
+ * Linux on x86_64 or aarch64 only, kernel 5.7 or newer (seccomp listeners and `pidfd_getfd`).
126
+ * The supervisor exits when the client does; other processes the client left running keep the filter
127
+ but their connects then fail.
128
+ * Landlock could do part of this without a supervisor (it can scope abstract Unix sockets since ABI 6),
129
+ but as far as I know it cannot yet restrict connecting to a pathname socket on kernel ABI 8.
130
+ * I am lazy and do not build versions of this for differnt hardware - but I wanted something
131
+ performant which others can use. I therefore outsource the building of filtering code to run time
132
+ using `ziglang`
133
+
134
+
135
+ ## Development
136
+
137
+ ```bash
138
+ pipx install --editable .
139
+ python3 -m unittest discover -s tests
140
+ ```
141
+
142
+ Run the tests with the pipx environment's Python so `ziglang` is available; otherwise the compiled
143
+ supervisor's tests are skipped if no system C compiler exists. The tests check both supervisors
144
+ against real sockets, including a test where a thread rewrites the address while `connect()` is
145
+ pending (and a control test showing that the same program does reach the forbidden socket
146
+ when unfiltered).
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "xephyr-run"
7
+ version = "0.1.0"
8
+ description = "Run one X client in its own named Xephyr window at a chosen position"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["ziglang"]
12
+
13
+ [project.scripts]
14
+ xephyr-run = "xephyr_run.cli:main"
15
+
16
+ [tool.setuptools.packages.find]
17
+ include = ["xephyr_run*"]
18
+
19
+ [tool.setuptools.package-data]
20
+ xephyr_run = ["*.c"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,76 @@
1
+ import os
2
+ import re
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ from xephyr_run import cli, connect_filter, registry
8
+
9
+
10
+ class Size(unittest.TestCase):
11
+ def test_parse(self):
12
+ self.assertEqual(cli.parse_size('640x400'), '640x400')
13
+ for bad in ('', '640', 'axb', '640x400+1+1'):
14
+ with self.assertRaises(ValueError):
15
+ cli.parse_size(bad)
16
+
17
+
18
+ class Display(unittest.TestCase):
19
+ def test_skips_taken_numbers(self):
20
+ with tempfile.TemporaryDirectory() as directory:
21
+ sockets = Path(directory)
22
+ self.assertEqual(cli.free_display(sockets, first=5), 5)
23
+ (sockets/'X5').touch()
24
+ (sockets/'X6').touch()
25
+ self.assertEqual(cli.free_display(sockets, first=5), 7)
26
+
27
+
28
+ class Confinement(unittest.TestCase):
29
+ def policy_for(self, number):
30
+ with tempfile.TemporaryDirectory() as directory:
31
+ sockets = Path(directory)/'.X11-unix'
32
+ sockets.mkdir()
33
+ return connect_filter.rules_policy(cli.confine_rules(number, sockets)), os.path.realpath(sockets)
34
+
35
+ def test_only_the_own_display_and_non_x_sockets_are_allowed(self):
36
+ policy, base = self.policy_for(90)
37
+ unix = lambda name: connect_filter.Address(1, name, b'')
38
+ self.assertTrue(policy(unix(f'{base}/X90')))
39
+ self.assertFalse(policy(unix(f'{base}/X0')))
40
+ self.assertFalse(policy(unix(f'{base}/X900')))
41
+ self.assertFalse(policy(unix('@/tmp/.X11-unix/X0')))
42
+ self.assertFalse(policy(unix('@anything')))
43
+ self.assertTrue(policy(unix('/run/user/1000/bus')))
44
+
45
+
46
+ class Registry(unittest.TestCase):
47
+ def record(self, name, server_pid=None):
48
+ return {'name': name, 'display': ':90', 'server_pid': server_pid or os.getpid(),
49
+ 'pid': os.getpid(), 'command': ['xclock'], 'started': 0}
50
+
51
+ def test_round_trip_and_listing(self):
52
+ with tempfile.TemporaryDirectory() as directory:
53
+ where = Path(directory)/'state'
54
+ self.assertEqual(registry.records(where), [])
55
+ registry.save(self.record('b'), where)
56
+ registry.save(self.record('a'), where)
57
+ self.assertEqual([r['name'] for r in registry.records(where)], ['a', 'b'])
58
+ registry.remove('a', where)
59
+ self.assertIsNone(registry.load('a', where))
60
+ self.assertEqual(registry.load('b', where)['display'], ':90')
61
+
62
+ def test_stale_record_is_pruned(self):
63
+ with tempfile.TemporaryDirectory() as directory:
64
+ where = Path(directory)
65
+ registry.save(self.record('gone', server_pid=2**22 + 12345), where)
66
+ self.assertIsNone(registry.load('gone', where))
67
+ self.assertFalse((where/'gone.json').exists())
68
+
69
+ def test_rejects_unsafe_names(self):
70
+ for bad in ('', '../x', 'a b', 'a/b'):
71
+ with self.assertRaises(ValueError):
72
+ registry.validate_name(bad)
73
+
74
+
75
+ if __name__ == '__main__':
76
+ unittest.main()
@@ -0,0 +1,231 @@
1
+ import errno
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ import socket
7
+ import struct
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import threading
12
+ import unittest
13
+
14
+ from xephyr_run import connect_filter as cf
15
+ from xephyr_run import native
16
+
17
+
18
+ def sockaddr_un(path):
19
+ return struct.pack('=H', socket.AF_UNIX) + path.encode() + b'\0'
20
+
21
+
22
+ class Policy(unittest.TestCase):
23
+ def test_first_matching_rule_wins_and_default_allows(self):
24
+ policy = cf.rules_policy([('allow', r'^/run/x/X90$'), ('deny', r'^@'), ('deny', r'^/run/x/')])
25
+ unix = lambda name: cf.Address(socket.AF_UNIX, name, b'')
26
+ self.assertTrue(policy(unix('/run/x/X90')))
27
+ self.assertFalse(policy(unix('/run/x/X0')))
28
+ self.assertFalse(policy(unix('@/tmp/.X11-unix/X0')))
29
+ self.assertTrue(policy(unix('/elsewhere/sock')))
30
+ self.assertTrue(policy(cf.Address(socket.AF_INET, None, b'')))
31
+
32
+ def test_rejects_unknown_verdict(self):
33
+ with self.assertRaises(ValueError):
34
+ cf.rules_policy([('permit', '.*')])
35
+
36
+
37
+ class ParseAddress(unittest.TestCase):
38
+ def test_abstract_path_and_unnamed(self):
39
+ pid = os.getpid()
40
+ self.assertEqual(cf.parse_address(struct.pack('=H', 1) + b'\0hello', pid).name, '@hello')
41
+ self.assertEqual(cf.parse_address(struct.pack('=H', 1), pid).name, '')
42
+ address = cf.parse_address(sockaddr_un('/tmp/../tmp/./some.sock'), pid)
43
+ self.assertEqual(address.name, os.path.realpath('/tmp/some.sock'))
44
+ self.assertEqual(address.raw, sockaddr_un(os.path.realpath('/tmp/some.sock')))
45
+
46
+ def test_relative_path_uses_the_programs_working_directory(self):
47
+ address = cf.parse_address(sockaddr_un('rel.sock'), os.getpid())
48
+ self.assertEqual(address.name, os.path.join(os.path.realpath(os.getcwd()), 'rel.sock'))
49
+
50
+ def test_other_families_are_untouched(self):
51
+ raw = struct.pack('=H', socket.AF_INET) + b'\x00' * 14
52
+ address = cf.parse_address(raw, os.getpid())
53
+ self.assertIsNone(address.name)
54
+ self.assertEqual(address.raw, raw)
55
+
56
+ def test_rejects_short_addresses(self):
57
+ with self.assertRaises(OSError):
58
+ cf.parse_address(b'\x01', os.getpid())
59
+
60
+
61
+ def supported():
62
+ try:
63
+ process = cf.popen_confined(['true'], lambda address: True)
64
+ except (RuntimeError, OSError):
65
+ return False
66
+ process.wait()
67
+ return True
68
+
69
+
70
+ class Server(threading.Thread):
71
+ """Accepts connections on a listening socket and counts them."""
72
+ def __init__(self, listener):
73
+ super().__init__(daemon=True)
74
+ self.listener = listener
75
+ self.listener.settimeout(0.1)
76
+ self.accepted = 0
77
+ self.stopped = False
78
+ self.start()
79
+
80
+ def run(self):
81
+ while not self.stopped:
82
+ try:
83
+ self.listener.accept()[0].close()
84
+ self.accepted += 1
85
+ except socket.timeout:
86
+ pass
87
+ except OSError:
88
+ return
89
+
90
+ def stop(self):
91
+ self.stopped = True
92
+ self.join()
93
+ self.listener.close()
94
+
95
+
96
+ def unix_server(address):
97
+ listener = socket.socket(socket.AF_UNIX)
98
+ listener.bind(address)
99
+ listener.listen(64)
100
+ return Server(listener)
101
+
102
+
103
+ CONNECTS = r'''
104
+ import ctypes, errno, json, socket, sys
105
+ libc = ctypes.CDLL(None, use_errno=True)
106
+ def attempt(family, address):
107
+ s = socket.socket(family)
108
+ try:
109
+ s.connect(address)
110
+ return 'ok'
111
+ except OSError as error:
112
+ return errno.errorcode.get(error.errno, str(error.errno))
113
+ finally:
114
+ s.close()
115
+ targets = json.loads(sys.argv[1])
116
+ results = {
117
+ 'allowed': attempt(socket.AF_UNIX, targets['allowed']),
118
+ 'denied': attempt(socket.AF_UNIX, targets['denied']),
119
+ 'abstract': attempt(socket.AF_UNIX, ('\0' + targets['abstract']).encode()),
120
+ 'tcp': attempt(socket.AF_INET, ('127.0.0.1', targets['port'])),
121
+ }
122
+ ctypes.set_errno(0)
123
+ results['io_uring'] = 'ok' if libc.syscall(425, 1, None) >= 0 else errno.errorcode[ctypes.get_errno()]
124
+ print(json.dumps(results))
125
+ '''
126
+
127
+ RACE = r'''
128
+ import ctypes, socket, struct, sys, threading
129
+ good, bad, count = sys.argv[1], sys.argv[2], int(sys.argv[3])
130
+ libc = ctypes.CDLL(None, use_errno=True)
131
+ pack = lambda path: struct.pack('=H', 1) + path.encode() + b'\0'
132
+ a, b = pack(good), pack(bad)
133
+ buffer = ctypes.create_string_buffer(a, len(a))
134
+ stop = False
135
+ def flip():
136
+ while not stop:
137
+ ctypes.memmove(buffer, b, len(b))
138
+ ctypes.memmove(buffer, a, len(a))
139
+ threading.Thread(target=flip, daemon=True).start()
140
+ for _ in range(count):
141
+ s = socket.socket(socket.AF_UNIX)
142
+ libc.connect(s.fileno(), buffer, len(a))
143
+ s.close()
144
+ stop = True
145
+ '''
146
+
147
+
148
+ class RaceHarness(unittest.TestCase):
149
+ def test_the_flipping_program_does_reach_the_denied_socket_when_unconfined(self):
150
+ directory = os.path.realpath(tempfile.mkdtemp())
151
+ self.addCleanup(shutil.rmtree, directory, True)
152
+ servers = {name: unix_server(os.path.join(directory, name + '.sock')) for name in ('good', 'evil')}
153
+ for server in servers.values():
154
+ self.addCleanup(server.stop)
155
+ subprocess.run([sys.executable, '-c', RACE, os.path.join(directory, 'good.sock'),
156
+ os.path.join(directory, 'evil.sock'), '150'], check=True, timeout=60)
157
+ self.assertGreater(servers['evil'].accepted, 0)
158
+ self.assertGreater(servers['good'].accepted, 0)
159
+
160
+
161
+ def native_available():
162
+ try:
163
+ native.binary()
164
+ except RuntimeError:
165
+ return False
166
+ return True
167
+
168
+
169
+ class ConfinedTests:
170
+ """The same behaviour, whichever supervisor enforces it."""
171
+ def setUp(self):
172
+ self.directory = os.path.realpath(tempfile.mkdtemp())
173
+ self.addCleanup(shutil.rmtree, self.directory, True)
174
+ self.allowed = os.path.join(self.directory, 'good.sock')
175
+ self.denied = os.path.join(self.directory, 'evil.sock')
176
+ self.rules = [('allow', '^' + cf.ere_escape(self.allowed) + '$'), ('deny', '.*')]
177
+ self.servers = {}
178
+ for name, path in (('allowed', self.allowed), ('denied', self.denied)):
179
+ self.servers[name] = unix_server(path)
180
+ self.addCleanup(self.servers[name].stop)
181
+
182
+ def run_confined(self, *script, expect=0):
183
+ process = self.launch([sys.executable, '-c', *script], stdout=subprocess.PIPE, text=True)
184
+ output = process.communicate(timeout=60)[0]
185
+ self.assertEqual(process.returncode, expect)
186
+ return output
187
+
188
+ def test_exit_status_is_passed_through(self):
189
+ self.run_confined('import sys; sys.exit(7)', expect=7)
190
+
191
+ def test_limits(self):
192
+ abstract = socket.socket(socket.AF_UNIX)
193
+ abstract.bind(b'\0confined-test-%d' % os.getpid())
194
+ abstract.listen(4)
195
+ self.addCleanup(abstract.close)
196
+ tcp = socket.socket()
197
+ tcp.bind(('127.0.0.1', 0))
198
+ tcp.listen(4)
199
+ self.addCleanup(tcp.close)
200
+ targets = {'allowed': self.allowed, 'denied': self.denied, 'port': tcp.getsockname()[1],
201
+ 'abstract': 'confined-test-%d' % os.getpid()}
202
+ results = json.loads(self.run_confined(CONNECTS, json.dumps(targets)))
203
+ self.assertEqual(results, {'allowed': 'ok', 'denied': 'EACCES', 'abstract': 'ECONNREFUSED',
204
+ 'tcp': 'ok', 'io_uring': 'EPERM'})
205
+ self.assertEqual(self.servers['denied'].accepted, 0)
206
+
207
+ def test_changing_the_address_during_the_check_cannot_reach_a_denied_socket(self):
208
+ self.run_confined(RACE, self.allowed, self.denied, '150')
209
+ self.assertEqual(self.servers['denied'].accepted, 0)
210
+ self.assertGreater(self.servers['allowed'].accepted, 0)
211
+
212
+
213
+ @unittest.skipUnless(supported(), 'seccomp user notification is not available here')
214
+ class PythonConfined(ConfinedTests, unittest.TestCase):
215
+ def launch(self, argv, **kwargs):
216
+ return cf.popen_confined(argv, cf.rules_policy(self.rules), **kwargs)
217
+
218
+
219
+ @unittest.skipUnless(native_available(), 'the compiled supervisor cannot be built here')
220
+ class NativeConfined(ConfinedTests, unittest.TestCase):
221
+ def launch(self, argv, **kwargs):
222
+ return native.popen(argv, self.rules, **kwargs)
223
+
224
+ def test_bad_regular_expression_is_refused(self):
225
+ process = native.popen([sys.executable, '-c', 'pass'], [('deny', '(')], stderr=subprocess.PIPE, text=True)
226
+ self.assertIn('bad regular expression', process.communicate(timeout=60)[1])
227
+ self.assertEqual(process.returncode, 2)
228
+
229
+
230
+ if __name__ == '__main__':
231
+ unittest.main()
File without changes
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())