CVE-2024-30265 (Python, published 2024-04-03T23:15Z)
Description: value: 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.

Severity: nan (CVSS v3 base score: )
Weakness: NVD-CWE-noinfo \u2014 Insufficient Information
There is insufficient information about the issue to classify it; details are unkown or unspecified.
Repository: https://github.com/voila-dashboards/voila
Files changed:
voila/app.py
voila/handler.py
voila/paths.py
voila/server_extension.py
voila/static/main.js
voila/templates/page.html
voila/templates/tree.html
voila/treehandler.py
voila/watchdog.py
Commit message: 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`)


Vulnerable code:
from pathlib import Path

from jupyter_server.utils import url_path_join, url_escape
from jupyter_server.base.handlers import JupyterHandler
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):
        # 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)
        notebook_filename:
        str(DEFAULT_STATIC_ROOT),
        'strip_sources': 'Voila.strip_sources'
        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
        notebook = nbformat.read(self.notebook_filename, as_version=4)

            (
                r'/',
                VoilaHandler,
                {
                    'notebook': notebook,
                    'strip_sources': self.strip_sources
                }
            ),
                r"/static/(.*)",
            allow_remote_access=True
    baseUrl: 'static/dist'

Fixed code:
import gettext

import jinja2
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
from .paths import ROOT, STATIC_ROOT, TEMPLATE_ROOT
from .handler import VoilaHandler
from .treehandler import VoilaTreeHandler
from .watchdog import WatchDogHandler
        notebook_path:
    autoreload = Bool(
        False,
        config=True,
        help='Will autoreload to server and the page when a template, js file or Python code changes'
    )
        str(STATIC_ROOT),
        'strip_sources': 'Voila.strip_sources',
        'autoreload': 'Voila.autoreload'
        self.notebook_path = self.extra_args[0] if len(self.extra_args) == 1 else None
                r"/voila/static/(.*)",
        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


            allow_remote_access=True,
            autoreload=self.autoreload,
            voila_jinja2_env=env,
            static_path='/',
            server_root_dir='/',
            contents_manager=contents_manager
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)

import os
from pathlib import Path

ROOT = Path(os.path.dirname(__file__))
STATIC_ROOT = ROOT / 'static'
TEMPLATE_ROOT = ROOT / 'templates'

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)})

    ])
    baseUrl: '/voila/static/dist'
    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)
        }
    }

<!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>
{% 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 %}


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)
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