diff --git a/IPython/html/services/notebooks/filenbmanager.py b/IPython/html/services/notebooks/filenbmanager.py index 749892f..bac4de2 100644 --- a/IPython/html/services/notebooks/filenbmanager.py +++ b/IPython/html/services/notebooks/filenbmanager.py @@ -207,12 +207,13 @@ class FileNotebookManager(NotebookManager): model['path'] = path model['last_modified'] = last_modified model['created'] = created - if content is True: + if content: with io.open(os_path, 'r', encoding='utf-8') as f: try: nb = current.read(f, u'json') except Exception as e: raise web.HTTPError(400, u"Unreadable Notebook: %s %s" % (os_path, e)) + self.mark_trusted_cells(nb, path, name) model['content'] = nb return model @@ -236,6 +237,9 @@ class FileNotebookManager(NotebookManager): # Save the notebook file os_path = self.get_os_path(new_name, new_path) nb = current.to_notebook_json(model['content']) + + self.check_and_sign(nb, new_path, new_name) + if 'name' in nb['metadata']: nb['metadata']['name'] = u'' try: diff --git a/IPython/html/services/notebooks/nbmanager.py b/IPython/html/services/notebooks/nbmanager.py index 0660e3a..f86e914 100644 --- a/IPython/html/services/notebooks/nbmanager.py +++ b/IPython/html/services/notebooks/nbmanager.py @@ -20,9 +20,9 @@ Authors: import os from IPython.config.configurable import LoggingConfigurable -from IPython.nbformat import current +from IPython.nbformat import current, sign from IPython.utils import py3compat -from IPython.utils.traitlets import Unicode, TraitError +from IPython.utils.traitlets import Instance, Unicode, TraitError #----------------------------------------------------------------------------- # Classes @@ -42,6 +42,30 @@ class NotebookManager(LoggingConfigurable): filename_ext = Unicode(u'.ipynb') + notary = Instance(sign.NotebookNotary) + def _notary_default(self): + return sign.NotebookNotary(parent=self) + + def check_and_sign(self, nb, path, name): + """Check for trusted cells, and sign the notebook. + + Called as a part of saving notebooks. + """ + if self.notary.check_cells(nb): + self.notary.sign(nb) + else: + self.log.warn("Saving untrusted notebook %s/%s", path, name) + + def mark_trusted_cells(self, nb, path, name): + """Mark cells as trusted if the notebook signature matches. + + Called as a part of loading notebooks. + """ + trusted = self.notary.check_signature(nb) + if not trusted: + self.log.warn("Notebook %s/%s is not trusted", path, name) + self.notary.mark_cells(nb, trusted) + def path_exists(self, path): """Does the API-style path (directory) actually exist? diff --git a/IPython/html/static/notebook/js/codecell.js b/IPython/html/static/notebook/js/codecell.js index fed8c31..a36e82a 100644 --- a/IPython/html/static/notebook/js/codecell.js +++ b/IPython/html/static/notebook/js/codecell.js @@ -530,6 +530,7 @@ var IPython = (function (IPython) { } else { this.set_input_prompt(); } + this.output_area.trusted = data.trusted || false; this.output_area.fromJSON(data.outputs); if (data.collapsed !== undefined) { if (data.collapsed) { @@ -552,6 +553,7 @@ var IPython = (function (IPython) { var outputs = this.output_area.toJSON(); data.outputs = outputs; data.language = 'python'; + data.trusted = this.output_area.trusted; data.collapsed = this.collapsed; return data; }; diff --git a/IPython/html/static/notebook/js/outputarea.js b/IPython/html/static/notebook/js/outputarea.js index 763493f..07d6a9d 100644 --- a/IPython/html/static/notebook/js/outputarea.js +++ b/IPython/html/static/notebook/js/outputarea.js @@ -31,6 +31,7 @@ var IPython = (function (IPython) { this.outputs = []; this.collapsed = false; this.scrolled = false; + this.trusted = true; this.clear_queued = null; if (prompt_area === undefined) { this.prompt_area = true; @@ -309,7 +310,7 @@ var IPython = (function (IPython) { }); return json; }; - + OutputArea.prototype.append_output = function (json) { this.expand(); // Clear the output if clear is queued. @@ -331,6 +332,7 @@ var IPython = (function (IPython) { } else if (json.output_type === 'stream') { this.append_stream(json); } + this.outputs.push(json); // Only reset the height to automatic if the height is currently @@ -526,12 +528,26 @@ var IPython = (function (IPython) { 'text/plain' ]; + OutputArea.safe_outputs = { + 'text/plain' : true, + 'image/png' : true, + 'image/jpeg' : true + }; + OutputArea.prototype.append_mime_type = function (json, element) { - for (var type_i in OutputArea.display_order) { var type = OutputArea.display_order[type_i]; var append = OutputArea.append_map[type]; if ((json[type] !== undefined) && append) { + if (!this.trusted && !OutputArea.safe_outputs[type]) { + // not trusted show warning and do not display + var content = { + text : "Untrusted " + type + " output ignored.", + stream : "stderr" + } + this.append_stream(content); + continue; + } var md = json.metadata || {}; append.apply(this, [json[type], md, element]); return true; @@ -753,6 +769,7 @@ var IPython = (function (IPython) { // clear all, no need for logic this.element.html(""); this.outputs = []; + this.trusted = true; this.unscroll_area(); return; }; @@ -765,13 +782,6 @@ var IPython = (function (IPython) { var len = outputs.length; var data; - // We don't want to display javascript on load, so remove it from the - // display order for the duration of this function call, but be sure to - // put it back in there so incoming messages that contain javascript - // representations get displayed - var js_index = OutputArea.display_order.indexOf('application/javascript'); - OutputArea.display_order.splice(js_index, 1); - for (var i=0; i o; + }, + // pass parameter from the test suite js to the browser code js + {c : cell_num, o : out_num}); + }); }, function then() { }, function timeout() { @@ -73,7 +76,7 @@ casper.wait_for_output = function (cell_num) { }); }; -// return the output of a given cell +// return an output of a given cell casper.get_output_cell = function (cell_num, out_num) { out_num = out_num || 0; var result = casper.evaluate(function (c, o) { @@ -81,7 +84,18 @@ casper.get_output_cell = function (cell_num, out_num) { return cell.output_area.outputs[o]; }, {c : cell_num, o : out_num}); - return result; + if (!result) { + var num_outputs = casper.evaluate(function (c) { + var cell = IPython.notebook.get_cell(c); + return cell.output_area.outputs.length; + }, + {c : cell_num}); + this.test.assertTrue(false, + "Cell " + cell_num + " has no output #" + out_num + " (" + num_outputs + " total)" + ); + } else { + return result; + } }; // return the number of cells in the notebook diff --git a/IPython/nbformat/sign.py b/IPython/nbformat/sign.py new file mode 100644 index 0000000..f13d7cd --- /dev/null +++ b/IPython/nbformat/sign.py @@ -0,0 +1,277 @@ +"""Functions for signing notebooks""" +#----------------------------------------------------------------------------- +# Copyright (C) 2014, The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +import base64 +from contextlib import contextmanager +import hashlib +from hmac import HMAC +import io +import os + +from IPython.utils.py3compat import string_types, unicode_type, cast_bytes +from IPython.utils.traitlets import Instance, Bytes, Enum, Any, Unicode, Bool +from IPython.config import LoggingConfigurable, MultipleInstanceError +from IPython.core.application import BaseIPythonApplication, base_flags + +from .current import read, write + +#----------------------------------------------------------------------------- +# Code +#----------------------------------------------------------------------------- +try: + # Python 3 + algorithms = hashlib.algorithms_guaranteed +except AttributeError: + algorithms = hashlib.algorithms + +def yield_everything(obj): + """Yield every item in a container as bytes + + Allows any JSONable object to be passed to an HMAC digester + without having to serialize the whole thing. + """ + if isinstance(obj, dict): + for key in sorted(obj): + value = obj[key] + yield cast_bytes(key) + for b in yield_everything(value): + yield b + elif isinstance(obj, (list, tuple)): + for element in obj: + for b in yield_everything(element): + yield b + elif isinstance(obj, unicode_type): + yield obj.encode('utf8') + else: + yield unicode_type(obj).encode('utf8') + + +@contextmanager +def signature_removed(nb): + """Context manager for operating on a notebook with its signature removed + + Used for excluding the previous signature when computing a notebook's signature. + """ + save_signature = nb['metadata'].pop('signature', None) + try: + yield + finally: + if save_signature is not None: + nb['metadata']['signature'] = save_signature + + +class NotebookNotary(LoggingConfigurable): + """A class for computing and verifying notebook signatures.""" + + profile_dir = Instance("IPython.core.profiledir.ProfileDir") + def _profile_dir_default(self): + from IPython.core.application import BaseIPythonApplication + app = None + try: + if BaseIPythonApplication.initialized(): + app = BaseIPythonApplication.instance() + except MultipleInstanceError: + pass + if app is None: + # create an app, without the global instance + app = BaseIPythonApplication() + app.initialize() + return app.profile_dir + + algorithm = Enum(algorithms, default_value='sha256', config=True, + help="""The hashing algorithm used to sign notebooks.""" + ) + def _algorithm_changed(self, name, old, new): + self.digestmod = getattr(hashlib, self.algorithm) + + digestmod = Any() + def _digestmod_default(self): + return getattr(hashlib, self.algorithm) + + secret_file = Unicode() + def _secret_file_default(self): + if self.profile_dir is None: + return '' + return os.path.join(self.profile_dir.security_dir, 'notebook_secret') + + secret = Bytes(config=True, + help="""The secret key with which notebooks are signed.""" + ) + def _secret_default(self): + # note : this assumes an Application is running + if os.path.exists(self.secret_file): + with io.open(self.secret_file, 'rb') as f: + return f.read() + else: + secret = base64.encodestring(os.urandom(1024)) + self._write_secret_file(secret) + return secret + + def _write_secret_file(self, secret): + """write my secret to my secret_file""" + self.log.info("Writing notebook-signing key to %s", self.secret_file) + with io.open(self.secret_file, 'wb') as f: + f.write(secret) + try: + os.chmod(self.secret_file, 0o600) + except OSError: + self.log.warn( + "Could not set permissions on %s", + self.secret_file + ) + return secret + + def compute_signature(self, nb): + """Compute a notebook's signature + + by hashing the entire contents of the notebook via HMAC digest. + """ + hmac = HMAC(self.secret, digestmod=self.digestmod) + # don't include the previous hash in the content to hash + with signature_removed(nb): + # sign the whole thing + for b in yield_everything(nb): + hmac.update(b) + + return hmac.hexdigest() + + def check_signature(self, nb): + """Check a notebook's stored signature + + If a signature is stored in the notebook's metadata, + a new signature is computed and compared with the stored value. + + Returns True if the signature is found and matches, False otherwise. + + The following conditions must all be met for a notebook to be trusted: + - a signature is stored in the form 'scheme:hexdigest' + - the stored scheme matches the requested scheme + - the requested scheme is available from hashlib + - the computed hash from notebook_signature matches the stored hash + """ + stored_signature = nb['metadata'].get('signature', None) + if not stored_signature \ + or not isinstance(stored_signature, string_types) \ + or ':' not in stored_signature: + return False + stored_algo, sig = stored_signature.split(':', 1) + if self.algorithm != stored_algo: + return False + my_signature = self.compute_signature(nb) + return my_signature == sig + + def sign(self, nb): + """Sign a notebook, indicating that its output is trusted + + stores 'algo:hmac-hexdigest' in notebook.metadata.signature + + e.g. 'sha256:deadbeef123...' + """ + signature = self.compute_signature(nb) + nb['metadata']['signature'] = "%s:%s" % (self.algorithm, signature) + + def mark_cells(self, nb, trusted): + """Mark cells as trusted if the notebook's signature can be verified + + Sets ``cell.trusted = True | False`` on all code cells, + depending on whether the stored signature can be verified. + + This function is the inverse of check_cells + """ + if not nb['worksheets']: + # nothing to mark if there are no cells + return + for cell in nb['worksheets'][0]['cells']: + if cell['cell_type'] == 'code': + cell['trusted'] = trusted + + def check_cells(self, nb): + """Return whether all code cells are trusted + + If there are no code cells, return True. + + This function is the inverse of mark_cells. + """ + if not nb['worksheets']: + return True + for cell in nb['worksheets'][0]['cells']: + if cell['cell_type'] != 'code': + continue + if not cell.get('trusted', False): + return False + return True + + +trust_flags = { + 'reset' : ( + {'TrustNotebookApp' : { 'reset' : True}}, + """Generate a new key for notebook signature. + All previously signed notebooks will become untrusted. + """ + ), +} +trust_flags.update(base_flags) +trust_flags.pop('init') + + +class TrustNotebookApp(BaseIPythonApplication): + + description="""Sign one or more IPython notebooks with your key, + to trust their dynamic (HTML, Javascript) output. + + Otherwise, you will have to re-execute the notebook to see output. + """ + + examples = """ipython trust mynotebook.ipynb and_this_one.ipynb""" + + flags = trust_flags + + reset = Bool(False, config=True, + help="""If True, generate a new key for notebook signature. + After reset, all previously signed notebooks will become untrusted. + """ + ) + + notary = Instance(NotebookNotary) + def _notary_default(self): + return NotebookNotary(parent=self, profile_dir=self.profile_dir) + + def sign_notebook(self, notebook_path): + if not os.path.exists(notebook_path): + self.log.error("Notebook missing: %s" % notebook_path) + self.exit(1) + with io.open(notebook_path, encoding='utf8') as f: + nb = read(f, 'json') + if self.notary.check_signature(nb): + print("Notebook already signed: %s" % notebook_path) + else: + print("Signing notebook: %s" % notebook_path) + self.notary.sign(nb) + with io.open(notebook_path, 'w', encoding='utf8') as f: + write(nb, f, 'json') + + def generate_new_key(self): + """Generate a new notebook signature key""" + print("Generating new notebook key: %s" % self.notary.secret_file) + self.notary._write_secret_file(os.urandom(1024)) + + def start(self): + if self.reset: + self.generate_new_key() + return + if not self.extra_args: + self.log.critical("Specify at least one notebook to sign.") + self.exit(1) + + for notebook_path in self.extra_args: + self.sign_notebook(notebook_path) + diff --git a/IPython/nbformat/tests/test_sign.py b/IPython/nbformat/tests/test_sign.py new file mode 100644 index 0000000..9c437b2 --- /dev/null +++ b/IPython/nbformat/tests/test_sign.py @@ -0,0 +1,108 @@ +"""Test Notebook signing""" +#----------------------------------------------------------------------------- +# Copyright (C) 2014, The IPython Development Team +# +# Distributed under the terms of the BSD License. The full license is in +# the file COPYING, distributed as part of this software. +#----------------------------------------------------------------------------- + +#----------------------------------------------------------------------------- +# Imports +#----------------------------------------------------------------------------- + +from .. import sign +from .base import TestsBase + +from ..current import read +from IPython.core.getipython import get_ipython + +#----------------------------------------------------------------------------- +# Classes and functions +#----------------------------------------------------------------------------- + +class TestNotary(TestsBase): + + def setUp(self): + self.notary = sign.NotebookNotary( + secret=b'secret', + profile_dir=get_ipython().profile_dir + ) + with self.fopen(u'test3.ipynb', u'r') as f: + self.nb = read(f, u'json') + + def test_algorithms(self): + last_sig = '' + for algo in sign.algorithms: + self.notary.algorithm = algo + self.notary.sign(self.nb) + sig = self.nb.metadata.signature + print(sig) + self.assertEqual(sig[:len(self.notary.algorithm)+1], '%s:' % self.notary.algorithm) + self.assertNotEqual(last_sig, sig) + last_sig = sig + + def test_sign_same(self): + """Multiple signatures of the same notebook are the same""" + sig1 = self.notary.compute_signature(self.nb) + sig2 = self.notary.compute_signature(self.nb) + self.assertEqual(sig1, sig2) + + def test_change_secret(self): + """Changing the secret changes the signature""" + sig1 = self.notary.compute_signature(self.nb) + self.notary.secret = b'different' + sig2 = self.notary.compute_signature(self.nb) + self.assertNotEqual(sig1, sig2) + + def test_sign(self): + self.notary.sign(self.nb) + sig = self.nb.metadata.signature + self.assertEqual(sig[:len(self.notary.algorithm)+1], '%s:' % self.notary.algorithm) + + def test_check_signature(self): + nb = self.nb + md = nb.metadata + notary = self.notary + check_signature = notary.check_signature + # no signature: + md.pop('signature', None) + self.assertFalse(check_signature(nb)) + # hash only, no algo + md.signature = notary.compute_signature(nb) + self.assertFalse(check_signature(nb)) + # proper signature, algo mismatch + notary.algorithm = 'sha224' + notary.sign(nb) + notary.algorithm = 'sha256' + self.assertFalse(check_signature(nb)) + # check correctly signed notebook + notary.sign(nb) + self.assertTrue(check_signature(nb)) + + def test_mark_cells_untrusted(self): + cells = self.nb.worksheets[0].cells + self.notary.mark_cells(self.nb, False) + for cell in cells: + if cell.cell_type == 'code': + self.assertIn('trusted', cell) + self.assertFalse(cell.trusted) + else: + self.assertNotIn('trusted', cell) + + def test_mark_cells_trusted(self): + cells = self.nb.worksheets[0].cells + self.notary.mark_cells(self.nb, True) + for cell in cells: + if cell.cell_type == 'code': + self.assertIn('trusted', cell) + self.assertTrue(cell.trusted) + else: + self.assertNotIn('trusted', cell) + + def test_check_cells(self): + nb = self.nb + self.notary.mark_cells(nb, True) + self.assertTrue(self.notary.check_cells(nb)) + self.notary.mark_cells(nb, False) + self.assertFalse(self.notary.check_cells(nb)) + diff --git a/IPython/terminal/ipapp.py b/IPython/terminal/ipapp.py index 2605e16..4f15d20 100755 --- a/IPython/terminal/ipapp.py +++ b/IPython/terminal/ipapp.py @@ -249,6 +249,9 @@ class TerminalIPythonApp(BaseIPythonApplication, InteractiveShellApp): nbconvert=('IPython.nbconvert.nbconvertapp.NbConvertApp', "Convert notebooks to/from other formats." ), + trust=('IPython.nbformat.sign.TrustNotebookApp', + "Sign notebooks to trust their potentially unsafe contents at load." + ), )) # *do* autocreate requested profile, but don't create the config file. diff --git a/IPython/terminal/tests/test_help.py b/IPython/terminal/tests/test_help.py index 1c45093..44cae7c 100644 --- a/IPython/terminal/tests/test_help.py +++ b/IPython/terminal/tests/test_help.py @@ -35,3 +35,6 @@ def test_locate_help(): def test_locate_profile_help(): tt.help_all_output_test("locate profile") + +def test_trust_help(): + tt.help_all_output_test("trust") diff --git a/docs/source/interactive/notebook.rst b/docs/source/interactive/notebook.rst index 29eb711..c0a4880 100644 --- a/docs/source/interactive/notebook.rst +++ b/docs/source/interactive/notebook.rst @@ -462,6 +462,35 @@ on available options, use:: :ref:`notebook_public_server` +.. _signing_notebooks: + +Signing Notebooks +----------------- + +To prevent untrusted code from executing on users' behalf when notebooks open, +we have added a signature to the notebook, stored in metadata. +The notebook server verifies this signature when a notebook is opened. +If the signature stored in the notebook metadata does not match, +javascript and HTML output will not be displayed on load, +and must be regenerated by re-executing the cells. + +Any notebook that you have executed yourself *in its entirety* will be considered trusted, +and its HTML and javascript output will be displayed on load. + +If you need to see HTML or Javascript output without re-executing, +you can explicitly trust notebooks, such as those shared with you, +or those that you have written yourself prior to IPython 2.0, +at the command-line with:: + + $ ipython trust mynotebook.ipynb [other notebooks.ipynb] + +This just generates a new signature stored in each notebook. + +You can generate a new notebook signing key with:: + + $ ipython trust --reset + + Importing ``.py`` files ----------------------- diff --git a/docs/source/whatsnew/pr/signing.rst b/docs/source/whatsnew/pr/signing.rst new file mode 100644 index 0000000..566f64a --- /dev/null +++ b/docs/source/whatsnew/pr/signing.rst @@ -0,0 +1,7 @@ +Signing Notebooks +----------------- + +To prevent untrusted code from executing on users' behalf when notebooks open, +we have added a signature to the notebook, stored in metadata. + +For more information, see :ref:`signing_notebooks`.