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`.