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.
- xephyr_run-0.1.0/PKG-INFO +154 -0
- xephyr_run-0.1.0/README.md +146 -0
- xephyr_run-0.1.0/pyproject.toml +20 -0
- xephyr_run-0.1.0/setup.cfg +4 -0
- xephyr_run-0.1.0/tests/test_cli.py +76 -0
- xephyr_run-0.1.0/tests/test_connect_filter.py +231 -0
- xephyr_run-0.1.0/xephyr_run/__init__.py +0 -0
- xephyr_run-0.1.0/xephyr_run/__main__.py +3 -0
- xephyr_run-0.1.0/xephyr_run/cli.py +155 -0
- xephyr_run-0.1.0/xephyr_run/connect_filter.c +382 -0
- xephyr_run-0.1.0/xephyr_run/connect_filter.py +316 -0
- xephyr_run-0.1.0/xephyr_run/native.py +56 -0
- xephyr_run-0.1.0/xephyr_run/registry.py +60 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/PKG-INFO +154 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/SOURCES.txt +17 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/dependency_links.txt +1 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/entry_points.txt +2 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/requires.txt +1 -0
- xephyr_run-0.1.0/xephyr_run.egg-info/top_level.txt +1 -0
|
@@ -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,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
|