Skip to content

Commit a4d08fd

Browse files
authored
Merge pull request from GHSA-cg54-gpgr-4rm6
use EnvironmentFile to pass environment variables to systemd units
2 parents 6dc1165 + 7d7cf42 commit a4d08fd

File tree

4 files changed

+117
-15
lines changed

4 files changed

+117
-15
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# Changelog
22

3+
## v0.15
4+
5+
Fixes vulnerability [GHSA-cg54-gpgr-4rm6](https://github.com/jupyterhub/systemdspawner/security/advisories/GHSA-cg54-gpgr-4rm6) affecting all previous releases.
6+
7+
- Use EnvironmentFile to pass environment variables to units.
8+
9+
## v0.14
10+
11+
- define entrypoints for JupyterHub spawner configuration
12+
- Fixes for CentOS 7
13+
314
## v0.13
415

516
### Bug Fixes

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
setup(
44
name='jupyterhub-systemdspawner',
5-
version='0.14',
5+
version='0.15.0',
66
description='JupyterHub Spawner using systemd for resource isolation',
77
long_description='See https://github.com/jupyterhub/systemdspawner for more info',
88
url='https://github.com/jupyterhub/systemdspawner',

systemdspawner/systemd.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,62 @@
44
Contains functions to start, stop & poll systemd services.
55
Probably not very useful outside this spawner.
66
"""
7+
78
import asyncio
9+
import os
10+
import re
811
import shlex
12+
import warnings
13+
14+
# light validation of environment variable keys
15+
env_pat = re.compile("[A-Za-z_]+")
16+
17+
RUN_ROOT = "/run"
18+
19+
def ensure_environment_directory(environment_file_directory):
20+
"""Ensure directory for environment files exists and is private"""
21+
# ensure directory exists
22+
os.makedirs(environment_file_directory, mode=0o700, exist_ok=True)
23+
# validate permissions
24+
mode = os.stat(environment_file_directory).st_mode
25+
if mode & 0o077:
26+
warnings.warn(
27+
f"Fixing permissions on environment directory {environment_file_directory}: {oct(mode)}",
28+
RuntimeWarning,
29+
)
30+
os.chmod(environment_file_directory, 0o700)
31+
else:
32+
return
33+
# Check again after supposedly fixing.
34+
# Some filesystems can have weird issues, preventing this from having desired effect
35+
mode = os.stat(environment_file_directory).st_mode
36+
if mode & 0o077:
37+
warnings.warn(
38+
f"Bad permissions on environment directory {environment_file_directory}: {oct(mode)}",
39+
RuntimeWarning,
40+
)
41+
42+
43+
def make_environment_file(environment_file_directory, unit_name, environment_variables):
44+
"""Make a systemd environment file
45+
46+
- ensures environment directory exists and is private
47+
- writes private environment file
48+
- returns path to created environment file
49+
"""
50+
ensure_environment_directory(environment_file_directory)
51+
env_file = os.path.join(environment_file_directory, f"{unit_name}.env")
52+
env_lines = []
53+
for key, value in sorted(environment_variables.items()):
54+
assert env_pat.match(key), f"{key} not a valid environment variable"
55+
env_lines.append(f"{key}={shlex.quote(value)}")
56+
env_lines.append("") # trailing newline
57+
with open(env_file, mode="w") as f:
58+
# make the file itself private as well
59+
os.fchmod(f.fileno(), 0o400)
60+
f.write("\n".join(env_lines))
61+
62+
return env_file
963

1064

1165
async def start_transient_service(
@@ -20,14 +74,32 @@ async def start_transient_service(
2074
slice=None,
2175
):
2276
"""
23-
Start a systemd transient service with given paramters
77+
Start a systemd transient service with given parameters
2478
"""
2579

2680
run_cmd = [
2781
'systemd-run',
2882
'--unit', unit_name,
2983
]
3084

85+
if properties is None:
86+
properties = {}
87+
else:
88+
properties = properties.copy()
89+
90+
# ensure there is a runtime directory where we can put our env file
91+
# If already set, can be space-separated list of paths
92+
runtime_directories = properties.setdefault("RuntimeDirectory", unit_name).split()
93+
94+
# runtime directories are always resolved relative to `/run`
95+
# grab the first item, if more than one
96+
runtime_dir = os.path.join(RUN_ROOT, runtime_directories[0])
97+
# make runtime directories private by default
98+
properties.setdefault("RuntimeDirectoryMode", "700")
99+
# preserve runtime directories across restarts
100+
# allows `systemctl restart` to load the env
101+
properties.setdefault("RuntimeDirectoryPreserve", "restart")
102+
31103
if properties:
32104
for key, value in properties.items():
33105
if isinstance(value, list):
@@ -37,10 +109,10 @@ async def start_transient_service(
37109
run_cmd.append('--property={}={}'.format(key, value))
38110

39111
if environment_variables:
40-
run_cmd += [
41-
'--setenv={}={}'.format(key, value)
42-
for key, value in environment_variables.items()
43-
]
112+
environment_file = make_environment_file(
113+
runtime_dir, unit_name, environment_variables
114+
)
115+
run_cmd.append(f"--property=EnvironmentFile={environment_file}")
44116

45117
# Explicitly check if uid / gid are not None, since 0 is valid value for both
46118
if uid is not None:
@@ -51,7 +123,7 @@ async def start_transient_service(
51123

52124
if slice is not None:
53125
run_cmd += ['--slice={}'.format(slice)]
54-
126+
55127
# We unfortunately have to resort to doing cd with bash, since WorkingDirectory property
56128
# of systemd units can't be set for transient units via systemd-run until systemd v227.
57129
# Centos 7 has systemd 219, and will probably never upgrade - so we need to support them.

tests/test_systemd.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,42 @@ async def test_service_running_fail():
6565
async def test_env_setting():
6666
unit_name = 'systemdspawner-unittest-' + str(time.time())
6767
with tempfile.TemporaryDirectory() as d:
68+
os.chmod(d, 0o777)
6869
await systemd.start_transient_service(
6970
unit_name,
70-
['/bin/bash'],
71-
['-c', 'env > {}/env'.format(d)],
72-
working_dir='/',
71+
["/bin/bash"],
72+
["-c", "pwd; ls -la {0}; env > ./env; sleep 3".format(d)],
73+
working_dir=d,
7374
environment_variables={
74-
'TESTING_SYSTEMD_ENV_1': 'TEST_1',
75-
'TESTING_SYSTEMD_ENV_2': 'TEST_2'
76-
}
75+
"TESTING_SYSTEMD_ENV_1": "TEST 1",
76+
"TESTING_SYSTEMD_ENV_2": "TEST 2",
77+
},
78+
# set user to ensure we are testing permission issues
79+
properties={
80+
"User": "65534",
81+
},
7782
)
83+
env_dir = os.path.join(systemd.RUN_ROOT, unit_name)
84+
assert os.path.isdir(env_dir)
85+
assert (os.stat(env_dir).st_mode & 0o777) == 0o700
7886

7987
# Wait a tiny bit for the systemd unit to complete running
8088
await asyncio.sleep(0.1)
89+
assert await systemd.service_running(unit_name)
90+
91+
env_file = os.path.join(env_dir, f"{unit_name}.env")
92+
assert os.path.exists(env_file)
93+
assert (os.stat(env_file).st_mode & 0o777) == 0o400
94+
# verify that the env had the desired effect
8195
with open(os.path.join(d, 'env')) as f:
8296
text = f.read()
83-
assert 'TESTING_SYSTEMD_ENV_1=TEST_1' in text
84-
assert 'TESTING_SYSTEMD_ENV_2=TEST_2' in text
97+
assert "TESTING_SYSTEMD_ENV_1=TEST 1" in text
98+
assert "TESTING_SYSTEMD_ENV_2=TEST 2" in text
99+
100+
await systemd.stop_service(unit_name)
101+
assert not await systemd.service_running(unit_name)
102+
# systemd cleans up env file
103+
assert not os.path.exists(env_file)
85104

86105

87106
@pytest.mark.asyncio

0 commit comments

Comments
 (0)