Skip to content

Commit 8ea279b

Browse files
authored
Merge pull request #469 from jupyter/secureWriteCon
Secure write for connection file
2 parents 3e8ee4a + f780fee commit 8ea279b

File tree

5 files changed

+154
-15
lines changed

5 files changed

+154
-15
lines changed

docs/changelog.rst

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
Changes in Jupyter Client
55
=========================
66

7+
5.3.2
8+
=====
9+
10+
- Important files creation now checks umask permissions (:ghpull:`469`).
11+
712
5.3.1
813
=====
914

@@ -16,7 +21,7 @@ Changes in Jupyter Client
1621
New Features:
1722

1823
- Multiprocessing and Threading support (:ghpull:`437`) and (:ghpull:`450`)
19-
- Setup package long_description (:ghpull:`411`)
24+
- Setup package long_description (:ghpull:`411`)
2025

2126
Changes:
2227

@@ -120,9 +125,9 @@ Breaking changes:
120125
- Define Jupyter protocol version 5.2,
121126
resolving ambiguity of ``cursor_pos`` field in the presence
122127
of unicode surrogate pairs.
123-
128+
124129
.. seealso::
125-
130+
126131
:ref:`cursor_pos_unicode_note`
127132

128133
- Add :meth:`Session.clone` for making a copy of a Session object

jupyter_client/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
version_info = (5, 3, 1)
1+
version_info = (5, 3, 2)
22
__version__ = '.'.join(map(str, version_info))
33

44
protocol_version_info = (5, 3)

jupyter_client/connect.py

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import tempfile
2020
import warnings
2121
from getpass import getpass
22+
from contextlib import contextmanager
2223

2324
import zmq
2425

@@ -34,6 +35,77 @@
3435
from jupyter_core.paths import jupyter_data_dir, jupyter_runtime_dir
3536

3637

38+
# TODO: Move to jupyter_core
39+
def win32_restrict_file_to_user(fname):
40+
"""Secure a windows file to read-only access for the user.
41+
Follows guidance from win32 library creator:
42+
http://timgolden.me.uk/python/win32_how_do_i/add-security-to-a-file.html
43+
44+
This method should be executed against an already generated file which
45+
has no secrets written to it yet.
46+
47+
Parameters
48+
----------
49+
50+
fname : unicode
51+
The path to the file to secure
52+
"""
53+
import win32api
54+
import win32security
55+
import ntsecuritycon as con
56+
57+
# everyone, _domain, _type = win32security.LookupAccountName("", "Everyone")
58+
admins, _domain, _type = win32security.LookupAccountName("", "Administrators")
59+
user, _domain, _type = win32security.LookupAccountName("", win32api.GetUserName())
60+
61+
sd = win32security.GetFileSecurity(fname, win32security.DACL_SECURITY_INFORMATION)
62+
63+
dacl = win32security.ACL()
64+
# dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.FILE_ALL_ACCESS, everyone)
65+
dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.FILE_GENERIC_READ | con.FILE_GENERIC_WRITE, user)
66+
dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.FILE_ALL_ACCESS, admins)
67+
68+
sd.SetSecurityDescriptorDacl(1, dacl, 0)
69+
win32security.SetFileSecurity(fname, win32security.DACL_SECURITY_INFORMATION, sd)
70+
71+
72+
# TODO: Move to jupyter_core
73+
@contextmanager
74+
def secure_write(fname, binary=False):
75+
"""Opens a file in the most restricted pattern available for
76+
writing content. This limits the file mode to `600` and yields
77+
the resulting opened filed handle.
78+
79+
Parameters
80+
----------
81+
82+
fname : unicode
83+
The path to the file to write
84+
"""
85+
mode = 'wb' if binary else 'w'
86+
open_flag = os.O_CREAT | os.O_WRONLY | os.O_TRUNC
87+
try:
88+
os.remove(fname)
89+
except (IOError, OSError):
90+
# Skip any issues with the file not existing
91+
pass
92+
93+
if os.name == 'nt':
94+
# Python on windows does not respect the group and public bits for chmod, so we need
95+
# to take additional steps to secure the contents.
96+
# Touch file pre-emptively to avoid editing permissions in open files in Windows
97+
fd = os.open(fname, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
98+
os.close(fd)
99+
open_flag = os.O_WRONLY | os.O_TRUNC
100+
win32_restrict_file_to_user(fname)
101+
102+
with os.fdopen(os.open(fname, open_flag, 0o600), mode) as f:
103+
if os.name != 'nt':
104+
# Enforce that the file got the requested permissions before writing
105+
assert '0600' == oct(stat.S_IMODE(os.stat(fname).st_mode)).replace('0o', '0')
106+
yield f
107+
108+
37109
def write_connection_file(fname=None, shell_port=0, iopub_port=0, stdin_port=0, hb_port=0,
38110
control_port=0, ip='', key=b'', transport='tcp',
39111
signature_scheme='hmac-sha256', kernel_name=''
@@ -134,7 +206,10 @@ def write_connection_file(fname=None, shell_port=0, iopub_port=0, stdin_port=0,
134206
cfg['signature_scheme'] = signature_scheme
135207
cfg['kernel_name'] = kernel_name
136208

137-
with open(fname, 'w') as f:
209+
# Only ever write this file as user read/writeable
210+
# This would otherwise introduce a vulnerability as a file has secrets
211+
# which would let others execute arbitrarily code as you
212+
with secure_write(fname) as f:
138213
f.write(json.dumps(cfg, indent=2))
139214

140215
if hasattr(stat, 'S_ISVTX'):
@@ -193,7 +268,7 @@ def find_connection_file(filename='kernel-*.json', path=None, profile=None):
193268
path = ['.', jupyter_runtime_dir()]
194269
if isinstance(path, string_types):
195270
path = [path]
196-
271+
197272
try:
198273
# first, try explicit name
199274
return filefind(filename, path)
@@ -208,11 +283,11 @@ def find_connection_file(filename='kernel-*.json', path=None, profile=None):
208283
else:
209284
# accept any substring match
210285
pat = '*%s*' % filename
211-
286+
212287
matches = []
213288
for p in path:
214289
matches.extend(glob.glob(os.path.join(p, pat)))
215-
290+
216291
matches = [ os.path.abspath(m) for m in matches ]
217292
if not matches:
218293
raise IOError("Could not find %r in %r" % (filename, path))
@@ -289,11 +364,11 @@ def tunnel_to_kernel(connection_info, sshserver, sshkey=None):
289364

290365
class ConnectionFileMixin(LoggingConfigurable):
291366
"""Mixin for configurable classes that work with connection files"""
292-
367+
293368
data_dir = Unicode()
294369
def _data_dir_default(self):
295370
return jupyter_data_dir()
296-
371+
297372
# The addresses for the communication channels
298373
connection_file = Unicode('', config=True,
299374
help="""JSON file in which to store connection info [default: kernel-<pid>.json]
@@ -480,7 +555,7 @@ def write_connection_file(self):
480555

481556
def load_connection_file(self, connection_file=None):
482557
"""Load connection info from JSON dict in self.connection_file.
483-
558+
484559
Parameters
485560
----------
486561
connection_file: unicode, optional
@@ -496,10 +571,10 @@ def load_connection_file(self, connection_file=None):
496571

497572
def load_connection_info(self, info):
498573
"""Load connection info from a dict containing connection info.
499-
574+
500575
Typically this data comes from a connection file
501576
and is called by load_connection_file.
502-
577+
503578
Parameters
504579
----------
505580
info: dict

jupyter_client/tests/test_connect.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
import json
77
import os
8+
import re
9+
import stat
10+
import tempfile
11+
import shutil
812

913
from traitlets.config import Config
1014
from jupyter_core.application import JupyterApp
@@ -14,6 +18,7 @@
1418
from jupyter_client import connect, KernelClient
1519
from jupyter_client.consoleapp import JupyterConsoleApp
1620
from jupyter_client.session import Session
21+
from jupyter_client.connect import secure_write
1722

1823

1924
class DummyConsoleApp(JupyterApp, JupyterConsoleApp):
@@ -142,7 +147,7 @@ def test_find_connection_file_local():
142147
abs_cf = os.path.abspath(cf)
143148
with open(cf, 'w') as f:
144149
f.write('{}')
145-
150+
146151
for query in (
147152
'test.json',
148153
'test',
@@ -160,7 +165,7 @@ def test_find_connection_file_relative():
160165
abs_cf = os.path.abspath(cf)
161166
with open(cf, 'w') as f:
162167
f.write('{}')
163-
168+
164169
for query in (
165170
os.path.join('.', 'subdir', 'test.json'),
166171
os.path.join('subdir', 'test.json'),
@@ -199,3 +204,56 @@ def test_mixin_cleanup_random_ports():
199204
assert not os.path.exists(filename)
200205
for name in dc._random_port_names:
201206
assert getattr(dc, name) == 0
207+
208+
209+
def test_secure_write():
210+
def fetch_win32_permissions(filename):
211+
'''Extracts file permissions on windows using icacls'''
212+
role_permissions = {}
213+
for index, line in enumerate(os.popen("icacls %s" % filename).read().splitlines()):
214+
if index == 0:
215+
line = line.split(filename)[-1].strip().lower()
216+
match = re.match(r'\s*([^:]+):\(([^\)]*)\)', line)
217+
if match:
218+
usergroup, permissions = match.groups()
219+
usergroup = usergroup.lower().split('\\')[-1]
220+
permissions = set(p.lower() for p in permissions.split(','))
221+
role_permissions[usergroup] = permissions
222+
elif not line.strip():
223+
break
224+
return role_permissions
225+
226+
def check_user_only_permissions(fname):
227+
# Windows has it's own permissions ACL patterns
228+
if os.name == 'nt':
229+
import win32api
230+
username = win32api.GetUserName().lower()
231+
permissions = fetch_win32_permissions(fname)
232+
assert username in permissions
233+
assert permissions[username] == set(['r', 'w'])
234+
assert 'administrators' in permissions
235+
assert permissions['administrators'] == set(['f'])
236+
assert 'everyone' not in permissions
237+
assert len(permissions) == 2
238+
else:
239+
mode = os.stat(fname).st_mode
240+
assert '0600' == oct(stat.S_IMODE(mode)).replace('0o', '0')
241+
242+
directory = tempfile.mkdtemp()
243+
fname = os.path.join(directory, 'check_perms')
244+
try:
245+
with secure_write(fname) as f:
246+
f.write('test 1')
247+
check_user_only_permissions(fname)
248+
with open(fname, 'r') as f:
249+
assert f.read() == 'test 1'
250+
251+
# Try changing file permissions ahead of time
252+
os.chmod(fname, 0o755)
253+
with secure_write(fname) as f:
254+
f.write('test 2')
255+
check_user_only_permissions(fname)
256+
with open(fname, 'r') as f:
257+
assert f.read() == 'test 2'
258+
finally:
259+
shutil.rmtree(directory)

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def run(self):
9292
'pyzmq>=13',
9393
'python-dateutil>=2.1',
9494
'tornado>=4.1',
95+
"pywin32 >=1.0 ; sys_platform == 'win32'"
9596
],
9697
python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*',
9798
extras_require = {

0 commit comments

Comments
 (0)