CVE-2024-39903 (Python, published 2024-07-12T15:15Z)
Description: value: Solara is a pure Python, React-style framework for scaling Jupyter and web apps. A Local File Inclusion (LFI) vulnerability was identified in widgetti/solara, in version <1.35.1, which was fixed in version 1.35.1. This vulnerability arises from the application's failure to properly validate URI fragments for directory traversal sequences such as '../' when serving static files. An attacker can exploit this flaw by manipulating the fragment part of the URI to read arbitrary files on the local file system.
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/widgetti/solara
Files changed:
solara/server/cdn_helper.py
solara/server/flask.py
solara/server/starlette.py
solara/server/utils.py
tests/integration/apps/not-allowed
tests/integration/apps/public/test.txt
tests/integration/apps/secure/app.py
tests/integration/server_test.py
Commit message: fix: CVE followup, do not serve relative files

We still served files above the directory of intent.
We now test all endpoint, using both starlette and flask to
make sure this will not happen again.


Vulnerable code:
import os
    # We use os.path.normpath() because we do not want to follow symlinks
    # in editable installs, since some packages are symlinked
    if not os.path.normpath(cache_path).startswith(os.path.normpath(base_cache_dir)):
        logger.warning(
            "Trying to read from outside of cache directory: %s is not a subdir of %s (%s - %s)",
            cache_path,
            base_cache_dir,
            os.path.normpath(cache_path),
            os.path.normpath(base_cache_dir),
        )
            return send_from_directory(directory / dir, filename)
        content = cdn_helper.get_data(Path(cache_directory), path)


Fixed code:
from solara.server.utils import path_is_child_of
    if not path_is_child_of(cache_path, base_cache_dir):
        raise PermissionError("Trying to write outside of cache directory")

    if not path_is_child_of(cache_path, base_cache_dir):
        logger.warning("Trying to read from outside of cache directory: %s is not a subdir of %s", cache_path, base_cache_dir)
    if not path_is_child_of(cache_path, base_cache_dir):
        raise PermissionError("Trying to read from outside of cache directory")

            return send_from_directory(directory, dir + os.path.sep + filename)
        try:
            content = cdn_helper.get_data(Path(cache_directory), path)
        except PermissionError:
            return flask.Response("not found", status=404)
from pathlib import Path
from solara.server.utils import path_is_child_of

            # return early if someone tries to access a file outside of the directory
            if not path_is_child_of(Path(original_path), Path(directory)):
                return "", None
from pathlib import Path
def path_is_child_of(path: Path, parent: Path) -> bool:
    # We use os.path.normpath() because we do not want to follow symlinks
    # in editable installs, since some packages are symlinked
    return os.path.normpath(path).startswith(os.path.normpath(parent))


not accessible
test
import solara


page = solara.Button("Click me")
import requests
import solara.server.server
from solara.server import settings


def test_cdn_secure(solara_server, solara_app, extra_include_path):
    cdn_url = solara_server.base_url + "/_solara/cdn"
    assert solara.settings.assets.proxy

    with extra_include_path(HERE), solara_app("server_test:ClickButton"):
        url = cdn_url + "/vue-grid-layout@1.0.2/dist/vue-grid-layout.min.js"
        response = requests.get(url)
        assert response.status_code == 200
        # create a file in /share/solara
        test_file = settings.assets.proxy_cache_dir.parent / "not-allowed"
        test_file.write_text("test")
        url = cdn_url + "/..%2fnot-allowed"
        response = requests.get(url)
        assert response.status_code == 404


def test_nbextension_secure(solara_server, solara_app, extra_include_path):
    nbextensions_url = solara_server.base_url + "/static/nbextensions"
    nbextensions_directories = [k for k in solara.server.server.nbextensions_directories if k.exists()]
    assert nbextensions_directories, "we should at least test one directory"
    nbextensions_directory = nbextensions_directories[0]

    with extra_include_path(HERE), solara_app("server_test:ClickButton"):
        url = nbextensions_url + "/jupyter-vuetify/nodeps.js"
        response = requests.get(url)
        assert response.status_code == 200
        test_file = nbextensions_directory.parent / "not-allowed"
        test_file.write_text("test")
        url = nbextensions_url + "/..%2fnot-allowed"
        response = requests.get(url)
        assert response.status_code == 404

        url = nbextensions_url + "/foo/..%2f..%2fnot-allowed"
        response = requests.get(url)
        assert response.status_code == 404


def test_assets_secure(solara_server, solara_app, extra_include_path):
    assets_url = solara_server.base_url + "/static/assets"
    assets_directory = solara.server.server.solara_static.parent / "assets"

    with extra_include_path(HERE), solara_app("server_test:ClickButton"):
        url = assets_url + "/theme.js"
        response = requests.get(url)
        assert response.status_code == 200
        test_file = assets_directory.parent / "__init__.py"
        assert test_file.exists()
        url = assets_url + "/..%2f__init__.py"
        response = requests.get(url)
        assert response.status_code == 404

        url = assets_url + "/foo/..%2f..%2f__init__.py"
        response = requests.get(url)
        assert response.status_code == 404


def test_public_secure(solara_server, solara_app, extra_include_path):
    public_url = solara_server.base_url + "/static/public"

    with solara_app(str(HERE / "apps/secure/app.py")):
        apps = list(solara.server.app.apps.values())
        assert len(apps) == 1
        app = apps[0]
        public_directory = app.directory.parent / "public"
        url = public_url + "/test.txt"
        response = requests.get(url)
        assert response.status_code == 200
        test_file = public_directory.parent / "not-allowed"
        assert test_file.exists()
        url = public_url + "/..%2fnot-allowed"
        response = requests.get(url)
        assert response.status_code == 404

        url = public_url + "/foo/..%2f..%2fnot-allowed"
        response = requests.get(url)
        assert response.status_code == 404


def test_static_secure(solara_server, solara_app, extra_include_path):
    static_url = solara_server.base_url + "/static"
    static_directory = solara.server.server.solara_static

    with extra_include_path(HERE), solara_app("server_test:ClickButton"):
        url = static_url + "/main.js"
        response = requests.get(url)
        assert response.status_code == 200
        test_file = static_directory.parent / "__init__.py"
        assert test_file.exists()
        url = static_url + "/..%2f__init__.py"
        response = requests.get(url)
        assert response.status_code == 404

        url = static_url + "/foo/..%2f..%2f__init__.py"
        response = requests.get(url)
        assert response.status_code == 404