Skip to content

Commit ed9399d

Browse files
committed
Merge branch 'fix-xsrf' into 4.x
2 parents 6d3cf8b + c6485a3 commit ed9399d

File tree

22 files changed

+266
-128
lines changed

22 files changed

+266
-128
lines changed

docs/source/changelog.rst

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,36 +10,55 @@ For more detailed information, see `GitHub <https://github.com/jupyter/notebook>
1010

1111
Use ``pip install notebook --upgrade`` or ``conda upgrade notebook`` to
1212
upgrade to the latest release.
13-
13+
14+
1415
.. _release-4.3.1:
1516

1617
4.3.1
1718
-----
1819

1920
4.3.1 is a patch release with a security patch, a couple bug fixes, and improvements to the newly-released token authentication.
2021

22+
**Security fix**:
23+
24+
- CVE-2016-9971. Fix CSRF vulnerability,
25+
where malicious forms could create untitled files and start kernels
26+
(no remote execution or modification of existing files)
27+
for users of certain browsers (Firefox, Internet Explorer / Edge).
28+
All previous notebook releases are affected.
29+
2130
Bug fixes:
2231

2332
- Fix carriage return handling
24-
- Make the font size more robust against fickle brow
33+
- Make the font size more robust against fickle browsers
2534
- Ignore resize events that bubbled up and didn't come from window
35+
- Add Authorization to allowed CORS headers
36+
- Downgrade CodeMirror to 5.16 while we figure out issues in Safari
2637

2738
Other improvements:
2839

2940
- Better docs for token-based authentication
3041
- Further highlight token info in log output when autogenerated
31-
- Add Authorization to allowed CORS headers
3242

33-
See the 4.3 milestone on GitHub for a complete list of
34-
`issues <https://github.com/jupyter/notebook/issues?utf8=%E2%9C%93&q=is%3Aissue%20milestone%3A4.3.1%20>`__
35-
and `pull requests <https://github.com/jupyter/notebook/pulls?utf8=%E2%9C%93&q=is%3Apr%20milestone%3A4.3.1%20>`__ involved in this release.
43+
See the 4.3.1 milestone on GitHub for a complete list of
44+
`issues <https://github.com/jupyter/notebook/issues?utf8=%E2%9C%93&q=is%3Aissue%20milestone%3A4.3.1>`__
45+
and `pull requests <https://github.com/jupyter/notebook/pulls?utf8=%E2%9C%93&q=is%3Apr%20milestone%3A4.3.1>`__ involved in this release.
3646

3747
.. _release-4.3:
3848

39-
4.3
40-
---
49+
4.3.0
50+
-----
4151

4252
4.3 is a minor release with many bug fixes and improvements.
53+
The biggest user-facing change is the addition of token authentication,
54+
which is enabled by default.
55+
A token is generated and used when your browser is opened automatically,
56+
so you shouldn't have to enter anything in the default circumstances.
57+
If you see a login page
58+
(e.g. by switching browsers, or launching on a new port with ``--no-browser``),
59+
you get a login URL with the token from the command ``jupyter notebook list``,
60+
which you can paste into your browser.
61+
4362

4463
Highlights:
4564

@@ -87,6 +106,7 @@ See the 4.3 milestone on GitHub for a complete list of
87106
`issues <https://github.com/jupyter/notebook/issues?utf8=%E2%9C%93&q=is%3Aissue%20milestone%3A4.3%20>`__
88107
and `pull requests <https://github.com/jupyter/notebook/pulls?utf8=%E2%9C%93&q=is%3Apr%20milestone%3A4.3%20>`__ involved in this release.
89108

109+
90110
.. _release-4.2.3:
91111

92112
4.2.3

notebook/auth/login.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def set_login_cookie(cls, handler, user_id=None):
9797
auth_header_pat = re.compile('token\s+(.+)', re.IGNORECASE)
9898

9999
@classmethod
100-
def get_user_token(cls, handler):
100+
def get_token(cls, handler):
101101
"""Get the user token from a request
102102
103103
Default:
@@ -117,14 +117,29 @@ def get_user_token(cls, handler):
117117
@classmethod
118118
def should_check_origin(cls, handler):
119119
"""Should the Handler check for CORS origin validation?
120-
120+
121121
Origin check should be skipped for token-authenticated requests.
122+
123+
Returns:
124+
- True, if Handler must check for valid CORS origin.
125+
- False, if Handler should skip origin check since requests are token-authenticated.
126+
"""
127+
return not cls.is_token_authenticated(handler)
128+
129+
@classmethod
130+
def is_token_authenticated(cls, handler):
131+
"""Returns True if handler has been token authenticated. Otherwise, False.
132+
133+
Login with a token is used to signal certain things, such as:
134+
135+
- permit access to REST API
136+
- xsrf protection
137+
- skip origin-checks for scripts
122138
"""
123139
if getattr(handler, '_user_id', None) is None:
124140
# ensure get_user has been called, so we know if we're token-authenticated
125141
handler.get_current_user()
126-
token_authenticated = getattr(handler, '_token_authenticated', False)
127-
return not token_authenticated
142+
return getattr(handler, '_token_authenticated', False)
128143

129144
@classmethod
130145
def get_user(cls, handler):
@@ -136,40 +151,56 @@ def get_user(cls, handler):
136151
# called on LoginHandler itself.
137152
if getattr(handler, '_user_id', None):
138153
return handler._user_id
139-
user_id = handler.get_secure_cookie(handler.cookie_name)
140-
if not user_id:
154+
user_id = cls.get_user_token(handler)
155+
if user_id is None:
156+
user_id = handler.get_secure_cookie(handler.cookie_name)
157+
else:
158+
cls.set_login_cookie(handler, user_id)
159+
# Record that the current request has been authenticated with a token.
160+
# Used in is_token_authenticated above.
161+
handler._token_authenticated = True
162+
if user_id is None:
141163
# prevent extra Invalid cookie sig warnings:
142164
handler.clear_login_cookie()
143-
token = handler.token
144-
if not token and not handler.login_available:
165+
if not handler.login_available:
145166
# Completely insecure! No authentication at all.
146167
# No need to warn here, though; validate_security will have already done that.
147-
return 'anonymous'
148-
if token:
149-
# check login token from URL argument or Authorization header
150-
user_token = cls.get_user_token(handler)
151-
one_time_token = handler.one_time_token
152-
authenticated = False
153-
if user_token == token:
154-
# token-authenticated, set the login cookie
155-
handler.log.info("Accepting token-authenticated connection from %s", handler.request.remote_ip)
156-
authenticated = True
157-
elif one_time_token and user_token == one_time_token:
158-
# one-time-token-authenticated, only allow this token once
159-
handler.settings.pop('one_time_token', None)
160-
handler.log.info("Accepting one-time-token-authenticated connection from %s", handler.request.remote_ip)
161-
authenticated = True
162-
if authenticated:
163-
user_id = uuid.uuid4().hex
164-
cls.set_login_cookie(handler, user_id)
165-
# Record that we've been authenticated with a token.
166-
# Used in should_check_origin above.
167-
handler._token_authenticated = True
168+
user_id = 'anonymous'
168169

169170
# cache value for future retrievals on the same request
170171
handler._user_id = user_id
171172
return user_id
172173

174+
@classmethod
175+
def get_user_token(cls, handler):
176+
"""Identify the user based on a token in the URL or Authorization header
177+
178+
Returns:
179+
- uuid if authenticated
180+
- None if not
181+
"""
182+
token = handler.token
183+
if not token:
184+
return
185+
# check login token from URL argument or Authorization header
186+
user_token = cls.get_token(handler)
187+
one_time_token = handler.one_time_token
188+
authenticated = False
189+
if user_token == token:
190+
# token-authenticated, set the login cookie
191+
handler.log.debug("Accepting token-authenticated connection from %s", handler.request.remote_ip)
192+
authenticated = True
193+
elif one_time_token and user_token == one_time_token:
194+
# one-time-token-authenticated, only allow this token once
195+
handler.settings.pop('one_time_token', None)
196+
handler.log.info("Accepting one-time-token-authenticated connection from %s", handler.request.remote_ip)
197+
authenticated = True
198+
199+
if authenticated:
200+
return uuid.uuid4().hex
201+
else:
202+
return None
203+
173204

174205
@classmethod
175206
def validate_security(cls, app, ssl_options=None):

notebook/base/handlers.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ def log():
4848

4949
class AuthenticatedHandler(web.RequestHandler):
5050
"""A RequestHandler with an authenticated user."""
51-
51+
5252
@property
5353
def content_security_policy(self):
5454
"""The default Content-Security-Policy header
@@ -95,6 +95,13 @@ def skip_check_origin(self):
9595
return False
9696
return not self.login_handler.should_check_origin(self)
9797

98+
@property
99+
def token_authenticated(self):
100+
"""Have I been authenticated with a token?"""
101+
if self.login_handler is None or not hasattr(self.login_handler, 'is_token_authenticated'):
102+
return False
103+
return self.login_handler.is_token_authenticated(self)
104+
98105
@property
99106
def cookie_name(self):
100107
default_cookie_name = non_alphanum.sub('-', 'username-{}'.format(
@@ -285,8 +292,12 @@ def check_origin(self, origin_to_satisfy_tornado=""):
285292
host = self.request.headers.get("Host")
286293
origin = self.request.headers.get("Origin")
287294

288-
# If no header is provided, assume it comes from a script/curl.
289-
# We are only concerned with cross-site browser stuff here.
295+
# If no header is provided, let the request through.
296+
# Origin can be None for:
297+
# - same-origin (IE, Firefox)
298+
# - Cross-site POST form (IE, Firefox)
299+
# - Scripts
300+
# The cross-site POST (XSRF) case is handled by tornado's xsrf_token
290301
if origin is None or host is None:
291302
return True
292303

@@ -310,7 +321,15 @@ def check_origin(self, origin_to_satisfy_tornado=""):
310321
self.request.path, origin, host,
311322
)
312323
return allow
313-
324+
325+
def check_xsrf_cookie(self):
326+
"""Bypass xsrf cookie checks when token-authenticated"""
327+
if self.token_authenticated or self.settings.get('disable_check_xsrf', False):
328+
# Token-authenticated requests do not need additional XSRF-check
329+
# Servers without authentication are vulnerable to XSRF
330+
return
331+
return super(IPythonHandler, self).check_xsrf_cookie()
332+
314333
#---------------------------------------------------------------
315334
# template rendering
316335
#---------------------------------------------------------------
@@ -338,6 +357,9 @@ def template_namespace(self):
338357
contents_js_source=self.contents_js_source,
339358
version_hash=self.version_hash,
340359
ignore_minified_js=self.ignore_minified_js,
360+
xsrf_form_html=self.xsrf_form_html,
361+
token=self.token,
362+
xsrf_token=self.xsrf_token.decode('utf8'),
341363
**self.jinja_template_vars
342364
)
343365

notebook/nbconvert/tests/test_nbconvert_handlers.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@
2020

2121
class NbconvertAPI(object):
2222
"""Wrapper for nbconvert API calls."""
23-
def __init__(self, base_url):
24-
self.base_url = base_url
23+
def __init__(self, request):
24+
self.request = request
2525

2626
def _req(self, verb, path, body=None, params=None):
27-
response = requests.request(verb,
28-
url_path_join(self.base_url, 'nbconvert', path),
27+
response = self.request(verb,
28+
url_path_join('nbconvert', path),
2929
data=body, params=params,
3030
)
3131
response.raise_for_status()
@@ -69,7 +69,7 @@ def setUp(self):
6969
encoding='utf-8') as f:
7070
write(nb, f, version=4)
7171

72-
self.nbconvert_api = NbconvertAPI(self.base_url())
72+
self.nbconvert_api = NbconvertAPI(self.request)
7373

7474
def tearDown(self):
7575
nbdir = self.notebook_dir.name
@@ -109,8 +109,7 @@ def test_from_file_zip(self):
109109

110110
@onlyif_cmds_exist('pandoc')
111111
def test_from_post(self):
112-
nbmodel_url = url_path_join(self.base_url(), 'api/contents/foo/testnb.ipynb')
113-
nbmodel = requests.get(nbmodel_url).json()
112+
nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json()
114113

115114
r = self.nbconvert_api.from_post(format='html', nbmodel=nbmodel)
116115
self.assertEqual(r.status_code, 200)
@@ -124,8 +123,7 @@ def test_from_post(self):
124123

125124
@onlyif_cmds_exist('pandoc')
126125
def test_from_post_zip(self):
127-
nbmodel_url = url_path_join(self.base_url(), 'api/contents/foo/testnb.ipynb')
128-
nbmodel = requests.get(nbmodel_url).json()
126+
nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json()
129127

130128
r = self.nbconvert_api.from_post(format='latex', nbmodel=nbmodel)
131129
self.assertIn(u'application/zip', r.headers['Content-Type'])

notebook/notebookapp.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ def init_settings(self, ipython_app, kernel_manager, contents_manager,
192192
login_handler_class=ipython_app.login_handler_class,
193193
logout_handler_class=ipython_app.logout_handler_class,
194194
password=ipython_app.password,
195+
xsrf_cookies=True,
196+
disable_check_xsrf=ipython_app.disable_check_xsrf,
195197

196198
# managers
197199
kernel_manager=kernel_manager,
@@ -559,6 +561,22 @@ def _token_changed(self, name, old, new):
559561
"""
560562
)
561563

564+
disable_check_xsrf = Bool(False, config=True,
565+
help="""Disable cross-site-request-forgery protection
566+
567+
Jupyter notebook 4.3.1 introduces protection from cross-site request forgeries,
568+
requiring API requests to either:
569+
570+
- originate from pages served by this server (validated with XSRF cookie and token), or
571+
- authenticate with a token
572+
573+
Some anonymous compute resources still desire the ability to run code,
574+
completely without authentication.
575+
These services can disable all authentication and security checks,
576+
with the full knowledge of what that implies.
577+
"""
578+
)
579+
562580
open_browser = Bool(True, config=True,
563581
help="""Whether to open in a browser after starting.
564582
The specific browser used is platform dependent and

notebook/services/config/tests/test_config_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,12 @@
1111

1212
class ConfigAPI(object):
1313
"""Wrapper for notebook API calls."""
14-
def __init__(self, base_url):
15-
self.base_url = base_url
14+
def __init__(self, request):
15+
self.request = request
1616

1717
def _req(self, verb, section, body=None):
18-
response = requests.request(verb,
19-
url_path_join(self.base_url, 'api/config', section),
18+
response = self.request(verb,
19+
url_path_join('api/config', section),
2020
data=body,
2121
)
2222
response.raise_for_status()
@@ -34,7 +34,7 @@ def modify(self, section, values):
3434
class APITest(NotebookTestBase):
3535
"""Test the config web service API"""
3636
def setUp(self):
37-
self.config_api = ConfigAPI(self.base_url())
37+
self.config_api = ConfigAPI(self.request)
3838

3939
def test_create_retrieve_config(self):
4040
sample = {'foo': 'bar', 'baz': 73}

notebook/services/contents/tests/test_contents_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ def dirs_only(dir_model):
4444

4545
class API(object):
4646
"""Wrapper for contents API calls."""
47-
def __init__(self, base_url):
48-
self.base_url = base_url
47+
def __init__(self, request):
48+
self.request = request
4949

5050
def _req(self, verb, path, body=None, params=None):
51-
response = requests.request(verb,
52-
url_path_join(self.base_url, 'api/contents', path),
51+
response = self.request(verb,
52+
url_path_join('api/contents', path),
5353
data=body, params=params,
5454
)
5555
response.raise_for_status()
@@ -209,7 +209,7 @@ def setUp(self):
209209
blob = self._blob_for_name(name)
210210
self.make_blob(u'{}/{}.blob'.format(d, name), blob)
211211

212-
self.api = API(self.base_url())
212+
self.api = API(self.request)
213213

214214
def tearDown(self):
215215
for dname in (list(self.top_level_dirs) + self.hidden_dirs):

0 commit comments

Comments
 (0)