CVE-2024-30265 (published 2024-04-03T23:15Z)

Collabora Online is a collaborative online office suite based on LibreOffice technology. Any deployment of voilà dashboard allow local file inclusion. Any file on a filesystem that is readable by the user that runs the voilà dashboard server can be downloaded by someone with network access to the server. Whether this still requires authentication depends on how voilà is deployed. This issue has been patched in 0.2.17, 0.3.8, 0.4.4 and 0.5.6.\n


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/voila-dashboards/voila
commit_details: repo_url: https://github.com/voila-dashboards/voila
author: Maarten Breddels
author_date: 2018-09-06 12:44:14+02:00
msg: refactor: reuse voila to be a server extension + tree view + autoreload

The voila handler can be used both in the standalone version, as well as
jupyter server extension. The three handler which is based on the notebook
code shows a list of directories and notebooks.
For development purposes the watchdog handler will send a reload message
over the websocket when either the javascript, templates, or the notebook
file is modified, or when the server autoreloads due to code changes.
(Use with `voila --autoreload=True`)
repository: repo_url: https://github.com/voila-dashboards/voila
repo_name: voila-dashboards/voila
description: Voilà turns Jupyter notebooks into standalone web applications
repo_language: Python
owner: voila-dashboards
file_changes: filename: app.py
change_type: ModificationType.MODIFY
diff: @@ -7,57 +7,25 @@ import shutil
 import tempfile
 import json
 import logging
+import gettext
+
+import jinja2
 
 import tornado.ioloop
 import tornado.web
 
-from pathlib import Path
-
 from traitlets.config.application import Application
 from traitlets import Unicode, Integer, Bool, default
 
-from jupyter_server.utils import url_path_join, url_escape
-from jupyter_server.base.handlers import JupyterHandler
 from jupyter_server.services.kernels.kernelmanager import MappingKernelManager
-from jupyter_server.services.kernels.handlers import KernelHandler, MainKernelHandler, ZMQChannelsHandler
-
-from jupyter_client.jsonutil import date_default
-
-import nbformat
-from nbconvert.preprocessors.execute import executenb
-from nbconvert import HTMLExporter
-
-ROOT = Path(os.path.dirname(__file__))
-DEFAULT_STATIC_ROOT = ROOT / 'static'
-TEMPLATE_ROOT = ROOT / 'templates'
-
-class VoilaHandler(JupyterHandler):
-
-    def initialize(self, notebook=None, strip_sources=False):
-        self.notebook = notebook
-        self.strip_sources = strip_sources
-
-    @tornado.web.authenticated
-    @tornado.gen.coroutine
-    def get(self):
+from jupyter_server.services.kernels.handlers import KernelHandler, ZMQChannelsHandler
+from jupyter_server.base.handlers import path_regex
+from jupyter_server.services.contents.largefilemanager import LargeFileManager
 
-        # Ignore requested kernel name and make use of the one specified in the notebook.
-        kernel_name = self.notebook.metadata.get('kernelspec', {}).get('name', self.kernel_manager.default_kernel_name)
-
-        # Launch kernel and execute notebook.
-        kernel_id = yield tornado.gen.maybe_future(self.kernel_manager.start_kernel(kernel_name=kernel_name))
-        km = self.kernel_manager.get_kernel(kernel_id)
-        result = executenb(self.notebook, km=km)
-
-        # render notebook to html
-        resources = dict(kernel_id=kernel_id)
-        html, resources = HTMLExporter(template_file=str(TEMPLATE_ROOT / 'voila.tpl'), exclude_input=self.strip_sources,
-                                       exclude_output_prompt=self.strip_sources, exclude_input_prompt=self.strip_sources
-                                      ).from_notebook_node(result, resources=resources)
-
-        # Compose reply
-        self.set_header('Content-Type', 'text/html')
-        self.write(html)
+from .paths import ROOT, STATIC_ROOT, TEMPLATE_ROOT
+from .handler import VoilaHandler
+from .treehandler import VoilaTreeHandler
+from .watchdog import WatchDogHandler
 
 _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
 
@@ -73,7 +41,7 @@ class Voila(Application):
     )
     option_description = Unicode(
         """
-        notebook_filename:
+        notebook_path:
             File name of the Jupyter notebook to display.
         """
     )
@@ -84,15 +52,21 @@ class Voila(Application):
         config=True,
         help='Port of the voila server. Default 8866.'
     )
+    autoreload = Bool(
+        False,
+        config=True,
+        help='Will autoreload to server and the page when a template, js file or Python code changes'
+    )
     static_root = Unicode(
-        str(DEFAULT_STATIC_ROOT),
+        str(STATIC_ROOT),
         config=True,
         help='Directory holding static assets (HTML, JS and CSS files).'
     )
     aliases = {
         'port': 'Voila.port',
         'static': 'Voila.static_root',
-        'strip_sources': 'Voila.strip_sources'
+        'strip_sources': 'Voila.strip_sources',
+        'autoreload': 'Voila.autoreload'
     }
     connection_dir_root = Unicode(
         config=True,
@@ -116,14 +90,7 @@ class Voila(Application):
 
     def parse_command_line(self, argv=None):
         super(Voila, self).parse_command_line(argv)
-        try:
-            notebook_filename = self.extra_args[0]
-        except IndexError:
-            self.log.critical('Bad command line parameters.')
-            self.log.critical('Missing NOTEBOOK_FILENAME parameter.')
-            self.log.critical('Run `voila --help` for help on command line parameters.')
-            exit(1)
-        self.notebook_filename = notebook_filename
+        self.notebook_path = self.extra_args[0] if len(self.extra_args) == 1 else None
 
     def start(self):
         connection_dir = tempfile.mkdtemp(
@@ -143,21 +110,11 @@ class Voila(Application):
             ]
         )
 
-        notebook = nbformat.read(self.notebook_filename, as_version=4)
-
         handlers = [
-            (
-                r'/',
-                VoilaHandler,
-                {
-                    'notebook': notebook,
-                    'strip_sources': self.strip_sources
-                }
-            ),
             (r'/api/kernels/%s' % _kernel_id_regex, KernelHandler),
             (r'/api/kernels/%s/channels' % _kernel_id_regex, ZMQChannelsHandler),
             (
-                r"/static/(.*)",
+                r"/voila/static/(.*)",
                 tornado.web.StaticFileHandler,
                 {
                     'path': self.static_root,
@@ -166,10 +123,41 @@ class Voila(Application):
             )
         ]
 
+        if self.notebook_path:
+            handlers.append((
+                r'/',
+                VoilaHandler,
+                {
+                    'notebook_path': self.notebook_path,
+                    'strip_sources': self.strip_sources
+                }
+            ))
+        else:
+            handlers.extend([
+                ('/', VoilaTreeHandler),
+                ('/voila/tree' + path_regex, VoilaTreeHandler),
+                ('/voila/render' + path_regex, VoilaHandler, {'strip_sources': self.strip_sources}),
+            ])
+        if self.autoreload:
+            handlers.append(('/voila/watchdog' + path_regex, WatchDogHandler))
+
+        jenv_opt = {"autoescape": True}  # we might want extra options via cmd line like notebook server
+        env = jinja2.Environment(loader=jinja2.FileSystemLoader(str(TEMPLATE_ROOT)), extensions=['jinja2.ext.i18n'], **jenv_opt)
+        nbui = gettext.translation('nbui', localedir=str(ROOT / 'i18n'), fallback=True)
+        env.install_gettext_translations(nbui, newstyle=False)
+
+        contents_manager = LargeFileManager()  # TODO: make this configurable like notebook
+
+
         app = tornado.web.Application(
             handlers,
             kernel_manager=kernel_manager,
-            allow_remote_access=True
+            allow_remote_access=True,
+            autoreload=self.autoreload,
+            voila_jinja2_env=env,
+            static_path='/',
+            server_root_dir='/',
+            contents_manager=contents_manager
         )
 
         app.listen(self.port)

programming_language: Python
filename: handler.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,45 @@
+import tornado.web
+
+from jupyter_server.base.handlers import JupyterHandler
+
+import nbformat
+from nbconvert.preprocessors.execute import executenb
+from nbconvert import HTMLExporter
+
+from .paths import TEMPLATE_ROOT
+
+
+class VoilaHandler(JupyterHandler):
+    def initialize(self, notebook_path=None, strip_sources=True):
+        self.notebook_path = notebook_path
+        self.strip_sources = strip_sources
+
+    @tornado.web.authenticated
+    @tornado.gen.coroutine
+    def get(self, path=None):
+        if path:
+            path = path.strip('/')  # remove leading /
+            path += '.ipynb'  # when used as a jupyter server extension, we don't use the extension
+        # if the handler got a notebook_path argument, always serve that
+        notebook_path = self.notebook_path or path
+
+        notebook = nbformat.read(notebook_path, as_version=4)
+
+        # Ignore requested kernel name and make use of the one specified in the notebook.
+        kernel_name = notebook.metadata.get('kernelspec', {}).get('name', self.kernel_manager.default_kernel_name)
+
+        # Launch kernel and execute notebook.
+        kernel_id = yield tornado.gen.maybe_future(self.kernel_manager.start_kernel(kernel_name=kernel_name))
+        km = self.kernel_manager.get_kernel(kernel_id)
+        result = executenb(notebook, km=km)
+
+        # render notebook to html
+        resources = dict(kernel_id=kernel_id)
+        html, resources = HTMLExporter(template_file=str(TEMPLATE_ROOT / 'voila.tpl'), exclude_input=self.strip_sources,
+                                       exclude_output_prompt=self.strip_sources, exclude_input_prompt=self.strip_sources
+                                      ).from_notebook_node(result, resources=resources)
+
+        # Compose reply
+        self.set_header('Content-Type', 'text/html')
+        self.write(html)
+

programming_language: Python
filename: paths.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,7 @@
+import os
+from pathlib import Path
+
+ROOT = Path(os.path.dirname(__file__))
+STATIC_ROOT = ROOT / 'static'
+TEMPLATE_ROOT = ROOT / 'templates'
+

programming_language: Python
filename: server_extension.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,37 @@
+import os
+import gettext
+from pathlib import Path
+
+from jinja2 import Environment, FileSystemLoader
+
+import tornado.web
+
+from jupyter_server.utils import url_path_join
+from jupyter_server.base.handlers import path_regex
+
+from .paths import ROOT, TEMPLATE_ROOT, STATIC_ROOT
+from .handler import VoilaHandler
+from .treehandler import VoilaTreeHandler
+from .watchdog import WatchDogHandler
+
+
+def load_jupyter_server_extension(server_app):
+    web_app = server_app.web_app
+
+    jenv_opt = {"autoescape": True}
+    env = Environment(loader=FileSystemLoader(str(TEMPLATE_ROOT)), extensions=['jinja2.ext.i18n'], **jenv_opt)
+    web_app.settings['voila_jinja2_env'] = env
+
+    nbui = gettext.translation('nbui', localedir=str(ROOT / 'i18n'), fallback=True)
+    env.install_gettext_translations(nbui, newstyle=False)
+
+    host_pattern = '.*$'
+    web_app.add_handlers(host_pattern, [
+        (url_path_join(web_app.settings['base_url'], '/voila/render' + path_regex), VoilaHandler),
+        (url_path_join(web_app.settings['base_url'], '/voila/watchdog' + path_regex), WatchDogHandler),
+        (url_path_join(web_app.settings['base_url'], '/voila'), VoilaTreeHandler),
+        (url_path_join(web_app.settings['base_url'], '/voila/tree' + path_regex), VoilaTreeHandler),
+        (url_path_join(web_app.settings['base_url'], '/voila/static/(.*)'),  tornado.web.StaticFileHandler,
+            {'path': str(STATIC_ROOT)})
+
+    ])
\ No newline at end of file

programming_language: Python
filename: main.js
change_type: ModificationType.MODIFY
diff: @@ -9,7 +9,7 @@ Array.prototype.forEach.call(scripts, (script) => {
 })
 
 requirejs.config({
-    baseUrl: 'static/dist'
+    baseUrl: '/voila/static/dist'
 })
 
 require(['libwidgets'], function(lib) {
@@ -25,6 +25,21 @@ require(['libwidgets'], function(lib) {
 
     var widgetApp = new lib.WidgetApplication(BASEURL, WSURL, lib.requireLoader, kernel_id);
 
+    var path = window.location.pathname.substr(14);
+    var wsWatchdog = new WebSocket(WSURL + '/voila/watchdog/' + path);
+    wsWatchdog.onmessage = (evt) => {
+        var msg = JSON.parse(evt.data)
+        console.log('msg', msg)
+        if(msg.type == 'reload') {
+            var timeout = 0;
+            if(msg.delay == 'long')
+                timeout = 1000;
+            setTimeout(() => {
+                location.href = location.href;
+            }, timeout)
+        }
+    }
+
     window.addEventListener("beforeunload", function (e) {
         widgetApp.cleanWidgets();
     });

programming_language: JavaScript
filename: page.html
change_type: ModificationType.ADD
diff: @@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+
+<head>
+    <meta charset="utf-8">
+
+    <title>{% block title %}Voila{% endblock %}</title>
+    {% block favicon %}<link id="favicon" rel="shortcut icon" type="image/x-icon" href="{{static_url("base/images/favicon.ico") }}">{% endblock %}
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    
+    {% block stylesheet %}
+    <!-- <link rel="stylesheet" href="{{ static_url("style/style.min.css") }}" type="text/css"/> -->
+    {% endblock %}
+    <!-- <link rel="stylesheet" href="{{ base_url }}custom/custom.css" type="text/css" /> -->
+    <link rel="stylesheet" href="https://unpkg.com/font-awesome@4.5.0/css/font-awesome.min.css" type="text/css" />
+    {% block meta %}
+    {% endblock %}
+
+</head>
+
+<body class="{% block bodyclasses %}{% endblock %}"
+ {% block params %}
+  {% if logged_in and token %}
+    data-jupyter-api-token="{{token | urlencode}}"
+  {% endif %}
+ {% endblock params %}
+dir="ltr">
+
+<noscript>
+    <div id='noscript'>
+      {% trans %}Voila requires JavaScript.{% endtrans %}<br>
+      {% trans %}Please enable it to proceed. {% endtrans %}
+  </div>
+</noscript>
+
+<div id="header">
+  <div id="header-container" class="container">
+  {% block login_widget %}
+
+    <span id="login_widget">
+      {% if logged_in %}
+        <button id="logout" class="btn btn-sm navbar-btn">{% trans %}Logout{% endtrans %}</button>
+      {% elif login_available and not logged_in %}
+        <button id="login" class="btn btn-sm navbar-btn">{% trans %}Login{% endtrans %}</button>
+      {% endif %}
+    </span>
+
+  {% endblock %}
+  
+  </div>
+  <div class="header-bar"></div>
+
+  {% block header %}
+  {% endblock %}
+</div>
+
+<div id="site">
+{% block site %}
+{% endblock %}
+</div>
+
+{% block after_site %}
+{% endblock %}
+
+{% block script %}
+{% endblock %}
+
+<script type='text/javascript'>
+  function _remove_token_from_url() {
+    if (window.location.search.length <= 1) {
+      return;
+    }
+    var search_parameters = window.location.search.slice(1).split('&');
+    for (var i = 0; i < search_parameters.length; i++) {
+      if (search_parameters[i].split('=')[0] === 'token') {
+        // remote token from search parameters
+        search_parameters.splice(i, 1);
+        var new_search = '';
+        if (search_parameters.length) {
+          new_search = '?' + search_parameters.join('&');
+        }
+        var new_url = window.location.origin + 
+                      window.location.pathname + 
+                      new_search + 
+                      window.location.hash;
+        window.history.replaceState({}, "", new_url);
+        return;
+      }
+    }
+  }
+  _remove_token_from_url();
+</script>
+</body>
+
+</html>

programming_language: HTML
filename: tree.html
change_type: ModificationType.ADD
diff: @@ -0,0 +1,53 @@
+{% extends "page.html" %}
+
+{% block title %}{{page_title}}{% endblock %}
+
+
+{% block params %}
+{{super()}}
+data-base-url="{{base_url | urlencode}}"
+data-notebook-path="{{notebook_path | urlencode}}"
+data-terminals-available="{{terminals_available}}"
+data-server-root="{{server_root}}"
+{% endblock %}
+
+{% block headercontainer %}
+<span class="flex-spacer"></span>
+{% endblock %}
+
+{% block site %}
+
+
+<!-- <ul class="breadcrumb">
+  <li><a href="{{breadcrumbs[0][0]}}"><i class="fa fa-folder" alt="folder icon"></i></a></li>
+{% for crumb in breadcrumbs[1:] %}
+  <li><a href="{{crumb[0]}}">{{crumb[1]}}</a></li>
+{% endfor %}
+</ul>
+ -->
+
+<ul class="voila-notebooks">
+  {% if breadcrumbs|length > 1: %}
+    <li><a href="{{breadcrumbs[-2][0]}}"><i class="fa fa-folder"></i>..</a></li>
+  {% endif %}
+
+  {% for content in contents.content %}
+    {% if content.type == 'notebook' %}
+      <li><a href="/voila/render/{{content.path.rpartition('.')[0]}}"><i class="fa fa-book"></i>{{content.name.rpartition('.')[0]}}</a></li>
+    {% endif %}
+    {% if content.type == 'directory' %}
+      <li><a href="/voila/tree/{{content.path}}"><i class="fa fa-folder"></i>{{content.name}}</a></li>
+    {% endif %}
+  {% endfor %}
+</ul>
+
+
+
+
+{% endblock %}
+
+{% block script %}
+    {{super()}}
+{% endblock %}
+
+

programming_language: HTML
filename: treehandler.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,60 @@
+from jupyter_server.base.handlers import JupyterHandler
+from jupyter_server.utils import url_path_join, url_escape
+
+class VoilaTreeHandler(JupyterHandler):
+    def get_template(self, name):
+        """Return the jinja template object for a given name"""
+        return self.settings['voila_jinja2_env'].get_template(name)
+
+    def generate_breadcrumbs(self, path):
+        breadcrumbs = [(url_path_join(self.base_url, 'voila/tree'), '')]
+        parts = path.split('/')
+        for i in range(len(parts)):
+            if parts[i]:
+                link = url_path_join(self.base_url, 'voila/tree',
+                    url_escape(url_path_join(*parts[:i+1])),
+                )
+                breadcrumbs.append((link, parts[i]))
+        return breadcrumbs
+
+    def generate_page_title(self, path):
+        parts = path.split('/')
+        if len(parts) > 3:  # not too many parts
+            parts = parts[-2:]
+        page_title = url_path_join(*parts)
+        if page_title:
+            return page_title+'/'
+        else:
+            return 'Voila Home'
+
+    def get(self, path=''):
+        cm = self.contents_manager
+        print(path, cm)
+
+        if cm.dir_exists(path=path):
+            if cm.is_hidden(path) and not cm.allow_hidden:
+                self.log.info("Refusing to serve hidden directory, via 404 Error")
+                raise web.HTTPError(404)
+            breadcrumbs = self.generate_breadcrumbs(path)
+            page_title = self.generate_page_title(path)
+            contents = cm.get(path)
+            self.write(self.render_template('tree.html',
+                page_title=page_title,
+                notebook_path=path,
+                breadcrumbs=breadcrumbs,
+                contents=contents,
+                terminals_available=False,
+                server_root=self.settings['server_root_dir'],
+            ))
+        elif cm.file_exists(path):
+            # it's not a directory, we have redirecting to do
+            model = cm.get(path, content=False)
+            # redirect to /api/notebooks if it's a notebook, otherwise /api/files
+            service = 'notebooks' if model['type'] == 'notebook' else 'files'
+            url = url_path_join(
+                self.base_url, service, url_escape(path),
+            )
+            self.log.debug("Redirecting %s to %s", self.request.path, url)
+            self.redirect(url)
+        else:
+            raise web.HTTPError(404)

programming_language: Python
filename: watchdog.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,68 @@
+import asyncio
+
+import watchdog.events
+import watchdog.observers
+
+import tornado.websocket
+import tornado.ioloop
+
+from .paths import ROOT
+
+# we cache event handler for watchdogs not to waste resources
+event_handlers = {}
+
+class WatchDogEventHandler(watchdog.events.RegexMatchingEventHandler):
+    def __init__(self, *args, **kwargs):
+        super(WatchDogEventHandler, self).__init__(*args, **kwargs)
+        self.listeners = []
+
+    def on_any_event(self, event):
+        try:
+            asyncio.get_event_loop()
+        except RuntimeError:
+            asyncio.set_event_loop(asyncio.new_event_loop())
+        for listener in self.listeners:
+            listener()
+
+
+
+class WatchDogHandler(tornado.websocket.WebSocketHandler):
+    #@tornado.gen.coroutine
+    def open(self, path=''):
+        self.callback = tornado.ioloop.PeriodicCallback(lambda: self.ping(''), 6000)
+        path = path.strip('/') + '.ipynb'
+        if path not in event_handlers:
+            watchdog_observer = watchdog.observers.Observer()
+            # sometimes useful to add this when triggering does not work
+            # from watchdog.events import LoggingEventHandler
+            # logging_handler = LoggingEventHandler()
+            # watchdog_observer.schedule(logging_handler, '.', recursive=True)
+            
+            handler = WatchDogEventHandler(regexes=['\\./' + path])
+            watchdog_observer.schedule(handler, '.', recursive=True)
+            
+            handler = WatchDogEventHandler(regexes=[str(ROOT) +r'/templates/.*', str(ROOT / 'static/main.js'), str(ROOT / 'static/dist/libwidgets.js')])
+            watchdog_observer.schedule(handler, str(ROOT), recursive=True)
+            
+            watchdog_observer.start()
+            event_handlers[path] = handler
+            
+            tornado.autoreload.add_reload_hook(self._on_reload)
+        self.handler = event_handlers[path]
+        self.handler.listeners.append(self)
+
+    def __call__(self):
+        self.write_message({'type': 'reload', 'delay': 'no'})
+
+    def on_close(self):
+        self.handler.listeners.remove(self)
+
+    def _on_reload(self):
+        try:
+            self.write_message({'type': 'reload', 'delay': 'long'})
+        except tornado.websocket.WebSocketClosedError:
+            print('a websocket was already closed')
+
+
+    def check_origin(self, origin):
+        return True

programming_language: Python
repo_url: https://github.com/voila-dashboards/voila
commit_details: repo_url: https://github.com/voila-dashboards/voila
author: martinRenou
author_date: 2024-04-02 14:01:41+01:00
msg: Merge pull request from GHSA-2q59-h24c-w6fg
repository: repo_url: https://github.com/voila-dashboards/voila
repo_name: voila-dashboards/voila
description: Voilà turns Jupyter notebooks into standalone web applications
repo_language: Python
owner: voila-dashboards
file_changes: filename: app.py
change_type: ModificationType.MODIFY
diff: @@ -661,7 +661,6 @@ class Voila(Application):
             autoreload=self.autoreload,
             voila_jinja2_env=env,
             jinja2_env=server_env,
-            static_path="/",
             server_root_dir="/",
             contents_manager=self.contents_manager,
             config_manager=self.config_manager,

programming_language: Python