VYPR
High severityNVD Advisory· Published Jun 14, 2022· Updated Apr 23, 2025

Known or guessable hidden files may be accessed in Jupyter Server

CVE-2022-29241

Description

Jupyter Server provides the backend (i.e. the core services, APIs, and REST endpoints) for Jupyter web applications like Jupyter Notebook. Prior to version 1.17.1, if notebook server is started with a value of root_dir that contains the starting user's home directory, then the underlying REST API can be used to leak the access token assigned at start time by guessing/brute forcing the PID of the jupyter server. While this requires an authenticated user session, this URL can be used from a cross-site scripting payload or from a hooked or otherwise compromised browser to leak this access token to a malicious third party. This token can be used along with the REST API to interact with Jupyter services/notebooks such as modifying or overwriting critical files, such as .bashrc or .ssh/authorized_keys, allowing a malicious user to read potentially sensitive data and possibly gain control of the impacted system. This issue is patched in version 1.17.1.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Jupyter Server before 1.17.1 leaks its access token via REST API by guessing the server PID when root_dir contains the home directory.

Vulnerability

Overview

CVE-2022-29241 is an information disclosure vulnerability in Jupyter Server (the backend for Jupyter web applications). Prior to version 1.17.1, if the server is started with a root_dir that contains the user's home directory, the REST API can be exploited to leak the server's access token. The token is inferred by brute-forcing the process ID (PID) of the Jupyter Server, as the token is derived from or associated with the PID [1].

Attack

Vector and Exploitation

To exploit this vulnerability, an attacker needs an authenticated user session, but the real threat arises when combined with cross-site scripting (XSS) or a compromised browser. A malicious actor can inject a payload or hook the browser to make requests to the Jupyter Server's REST API. By guessing the server PID, the attacker can retrieve the access token [1]. The attack does not require direct network access to the server if the victim's browser can reach it.

Impact

Once the attacker obtains the access token, they can use the REST API to interact with Jupyter services. This includes reading, modifying, or overwriting critical files such as .bashrc or .ssh/authorized_keys on the server. Consequently, the attacker could read sensitive data, gain persistent remote access, or escalate to full control of the impacted system [1].

Mitigation

The vulnerability is fixed in Jupyter Server version 1.17.1. Patches were included in commits that follow the advisory GHSA-q874-g24w-4q9g [2][3]. Users should upgrade to the latest version. No known workarounds exist beyond upgrading [4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
jupyter-serverPyPI
< 1.17.11.17.1
jupyter-serverPyPI
>= 2.0.0a0, < 2.0.0a12.0.0a1

Affected products

5

Patches

2
877da10cd0d7

Merge pull request from GHSA-q874-g24w-4q9g

4 files changed · +479 5
  • jupyter_server/services/contents/filemanager.py+30 5 modified
    @@ -188,6 +188,12 @@ def _base_model(self, path):
             os_path = self._get_os_path(path)
             info = os.lstat(os_path)
     
    +        four_o_four = "file or directory does not exist: %r" % path
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
    +            raise web.HTTPError(404, four_o_four)
    +
             try:
                 # size of file
                 size = info.st_size
    @@ -365,11 +371,16 @@ def get(self, path, content=True, type=None, format=None):
                 of the file or directory as well.
             """
             path = path.strip("/")
    +        os_path = self._get_os_path(path)
    +        four_o_four = "file or directory does not exist: %r" % path
     
             if not self.exists(path):
    -            raise web.HTTPError(404, "No such file or directory: %s" % path)
    +            raise web.HTTPError(404, four_o_four)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
    +            raise web.HTTPError(404, four_o_four)
     
    -        os_path = self._get_os_path(path)
             if os.path.isdir(os_path):
                 if type not in (None, "directory"):
                     raise web.HTTPError(
    @@ -389,7 +400,7 @@ def get(self, path, content=True, type=None, format=None):
         def _save_directory(self, os_path, model, path=""):
             """create a directory"""
             if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    -            raise web.HTTPError(400, "Cannot create hidden directory %r" % os_path)
    +            raise web.HTTPError(400, "Cannot create directory %r" % os_path)
             if not os.path.exists(os_path):
                 with self.perm_to_403():
                     os.mkdir(os_path)
    @@ -410,6 +421,10 @@ def save(self, model, path=""):
                 raise web.HTTPError(400, "No file content provided")
     
             os_path = self._get_os_path(path)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot create file or directory {os_path!r}")
    +
             self.log.debug("Saving %s", os_path)
     
             validation_error: dict = {}
    @@ -452,8 +467,13 @@ def delete_file(self, path):
             path = path.strip("/")
             os_path = self._get_os_path(path)
             rm = os.unlink
    -        if not os.path.exists(os_path):
    -            raise web.HTTPError(404, "File or directory does not exist: %s" % os_path)
    +        four_o_four = "file or directory does not exist: %r" % path
    +
    +        if not self.exists(path):
    +            raise web.HTTPError(404, four_o_four)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}")
     
             def _check_trash(os_path):
                 if sys.platform in {"win32", "darwin"}:
    @@ -518,6 +538,11 @@ def rename_file(self, old_path, new_path):
             new_os_path = self._get_os_path(new_path)
             old_os_path = self._get_os_path(old_path)
     
    +        if (
    +            is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir)
    +        ) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}")
    +
             # Should we proceed with the move?
             if os.path.exists(new_os_path) and not samefile(old_os_path, new_os_path):
                 raise web.HTTPError(409, "File already exists: %s" % new_path)
    
  • jupyter_server/services/contents/handlers.py+38 0 modified
    @@ -95,6 +95,8 @@ async def get(self, path=""):
             of the files and directories it contains.
             """
             path = path or ""
    +        cm = self.contents_manager
    +
             type = self.get_query_argument("type", default=None)
             if type not in {None, "directory", "file", "notebook"}:
                 raise web.HTTPError(400, "Type %r is invalid" % type)
    @@ -107,6 +109,9 @@ async def get(self, path=""):
                 raise web.HTTPError(400, "Content %r is invalid" % content_str)
             content = int(content_str or "")
     
    +        if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
    +            raise web.HTTPError(404, f"file or directory {path!r} does not exist")
    +
             model = await ensure_async(
                 self.contents_manager.get(
                     path=path,
    @@ -126,6 +131,17 @@ async def patch(self, path=""):
             model = self.get_json_body()
             if model is None:
                 raise web.HTTPError(400, "JSON body missing")
    +
    +        old_path = model.get("path")
    +        if (
    +            old_path
    +            and (
    +                await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path))
    +            )
    +            and not cm.allow_hidden
    +        ):
    +            raise web.HTTPError(400, f"Cannot rename file or directory {path!r}")
    +
             model = await ensure_async(cm.update(model, path))
             validate_model(model, expect_content=False)
             self._finish_model(model)
    @@ -191,6 +207,16 @@ async def post(self, path=""):
                 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
     
             model = self.get_json_body()
    +        copy_from = model.get("copy_from")
    +        if (
    +            copy_from
    +            and (
    +                await ensure_async(cm.is_hidden(path))
    +                or await ensure_async(cm.is_hidden(copy_from))
    +            )
    +            and not cm.allow_hidden
    +        ):
    +            raise web.HTTPError(400, f"Cannot copy file or directory {path!r}")
     
             if model is not None:
                 copy_from = model.get("copy_from")
    @@ -217,9 +243,17 @@ async def put(self, path=""):
               create a new empty notebook.
             """
             model = self.get_json_body()
    +        cm = self.contents_manager
    +
             if model:
                 if model.get("copy_from"):
                     raise web.HTTPError(400, "Cannot copy with PUT, only POST")
    +            if (
    +                (model.get("path") and await ensure_async(cm.is_hidden(model.get("path"))))
    +                or await ensure_async(cm.is_hidden(path))
    +            ) and not cm.allow_hidden:
    +                raise web.HTTPError(400, f"Cannot create file or directory {path!r}")
    +
                 exists = await ensure_async(self.contents_manager.file_exists(path))
                 if exists:
                     await self._save(model, path)
    @@ -233,6 +267,10 @@ async def put(self, path=""):
         async def delete(self, path=""):
             """delete a file in the given path"""
             cm = self.contents_manager
    +
    +        if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot delete file or directory {path!r}")
    +
             self.log.warning("delete %s", path)
             await ensure_async(cm.delete(path))
             self.set_status(204)
    
  • tests/services/contents/test_api.py+251 0 modified
    @@ -230,6 +230,36 @@ async def test_get_text_file_contents(jp_fetch, contents, path, name):
             )
         assert expected_http_error(e, 400)
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled retrieving hidden files on Windows")
    +async def test_get_404_hidden(jp_fetch, contents, contents_dir):
    +    # Create text files
    +    hidden_dir = contents_dir / '.hidden'
    +    hidden_dir.mkdir(parents=True, exist_ok=True)
    +    txt = f"visible text file in hidden dir"
    +    txtname = hidden_dir.joinpath(f"visible.txt")
    +    txtname.write_text(txt, encoding="utf-8")
    +
    +    txt2 = f"hidden text file"
    +    txtname2 = contents_dir.joinpath(f".hidden.txt")
    +    txtname2.write_text(txt2, encoding="utf-8")
    +   
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/visible.txt",
    +            method="GET",
    +        )
    +    assert expected_http_error(e, 404)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="GET",
    +        )
    +    assert expected_http_error(e, 404)
     
     @pytest.mark.parametrize("path,name", dirs)
     async def test_get_binary_file_contents(jp_fetch, contents, path, name):
    @@ -408,6 +438,55 @@ async def test_upload_txt(jp_fetch, contents, contents_dir, _check_created):
         assert model["content"] == body
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled uploading hidden files on Windows")
    +async def test_upload_txt_hidden(jp_fetch, contents, contents_dir):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +        }
    +        path = '.hidden/Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +            'path': '.hidden/test.txt'
    +        }
    +        path = 'Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +        }
    +        path = '.hidden.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +            'path': '.hidden.txt'
    +        }
    +        path = 'Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +
     async def test_upload_b64(jp_fetch, contents, contents_dir, _check_created):
         body = b"\xFFblob"
         b64body = encodebytes(body).decode("ascii")
    @@ -501,6 +580,49 @@ async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created):
         assert expected_http_error(e, 400)
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_copy_put_400_hidden(jp_fetch, contents, contents_dir,):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": ".hidden/new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": ".hidden.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +
     async def test_copy_dir_400(jp_fetch, contents, contents_dir, _check_created):
         with pytest.raises(tornado.httpclient.HTTPClientError) as e:
             await jp_fetch(
    @@ -513,6 +635,63 @@ async def test_copy_dir_400(jp_fetch, contents, contents_dir, _check_created):
         assert expected_http_error(e, 400)
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_copy_400_hidden(jp_fetch, contents, contents_dir,):
    +
    +    # Create text files
    +    hidden_dir = contents_dir / '.hidden'
    +    hidden_dir.mkdir(parents=True, exist_ok=True)
    +    txt = f"visible text file in hidden dir"
    +    txtname = hidden_dir.joinpath(f"new.txt")
    +    txtname.write_text(txt, encoding="utf-8")
    +
    +    paths = ['new.txt', '.hidden.txt']
    +    for name in paths:
    +        txt = f"{name} text file"
    +        txtname = contents_dir.joinpath(f"{name}.txt")
    +        txtname.write_text(txt, encoding="utf-8")
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": ".hidden/new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": ".hidden.txt"}),
    +        )
    +    assert expected_http_error(e, 400)    
    +
    +
     @pytest.mark.parametrize("path,name", dirs)
     async def test_delete(jp_fetch, contents, contents_dir, path, name, _check_created):
         nbname = name + ".ipynb"
    @@ -550,6 +729,24 @@ async def test_delete_non_empty_dir(jp_fetch, contents):
             await jp_fetch("api", "contents", "å b", method="GET")
         assert expected_http_error(e, 404)
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
    +async def test_delete_hidden_dir(jp_fetch, contents):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
    +async def test_delete_hidden_file(jp_fetch, contents):
    +    #Test deleting file in a hidden directory
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden/test.txt", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
    +    #Test deleting a hidden file
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden.txt", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
     
     async def test_rename(jp_fetch, jp_base_url, contents, contents_dir):
         path = "foo"
    @@ -581,6 +778,60 @@ async def test_rename(jp_fetch, jp_base_url, contents, contents_dir):
         assert "z.ipynb" in nbnames
         assert "a.ipynb" not in nbnames
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_rename_400_hidden(jp_fetch, jp_base_url, contents, contents_dir):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = '.hidden/old.txt'
    +        new_path = 'new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +        
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = 'old.txt'
    +        new_path = '.hidden/new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +        
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = '.hidden.txt'
    +        new_path = 'new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = 'old.txt'
    +        new_path = '.hidden.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +
     
     async def test_checkpoints_follow_file(jp_fetch, contents):
         path = "foo"
    
  • tests/services/contents/test_manager.py+160 0 modified
    @@ -259,6 +259,166 @@ async def test_403(jp_file_contents_manager_class, tmp_path):
         except HTTPError as e:
             assert e.status_code == 403
     
    +@pytest.mark.skipif(sys.platform.startswith('win'), reason="Can't test hidden files on Windows")
    +async def test_400(jp_file_contents_manager_class, tmp_path):
    +    #Test Delete behavior
    +    #Test delete of file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.delete_file(os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test delete hidden file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.delete_file(os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test Save behavior
    +    #Test save of file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.save(model,path=os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test save hidden file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.save(model,path=os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename behavior
    +    #Test rename with source file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        old_path = cm._get_os_path(model['path'])
    +        new_path = "new.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename of dest file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        new_path = cm._get_os_path(model['path'])
    +        old_path = "old.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename with hidden source file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        old_path = cm._get_os_path(model['path'])
    +        new_path = "new.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename with hidden dest file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        new_path = cm._get_os_path(model['path'])
    +        old_path = "old.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +@pytest.mark.skipif(sys.platform.startswith('win'), reason="Can't test hidden files on Windows")
    +async def test_404(jp_file_contents_manager_class, tmp_path):
    +    #Test visible file in hidden folder
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.get(os_path, 'w'))
    +        except HTTPError as e:
    +            assert e.status_code == 404
    +
    +    #Test hidden file in visible folder
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.get(os_path, 'w'))
    +        except HTTPError as e:
    +            assert e.status_code == 404
     
     async def test_escape_root(jp_file_contents_manager_class, tmp_path):
         td = str(tmp_path)
    
3485007abbb4

Merge pull request from GHSA-q874-g24w-4q9g

4 files changed · +479 5
  • jupyter_server/services/contents/filemanager.py+30 5 modified
    @@ -188,6 +188,12 @@ def _base_model(self, path):
             os_path = self._get_os_path(path)
             info = os.lstat(os_path)
     
    +        four_o_four = "file or directory does not exist: %r" % path
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
    +            raise web.HTTPError(404, four_o_four)
    +
             try:
                 # size of file
                 size = info.st_size
    @@ -365,11 +371,16 @@ def get(self, path, content=True, type=None, format=None):
                 of the file or directory as well.
             """
             path = path.strip("/")
    +        os_path = self._get_os_path(path)
    +        four_o_four = "file or directory does not exist: %r" % path
     
             if not self.exists(path):
    -            raise web.HTTPError(404, "No such file or directory: %s" % path)
    +            raise web.HTTPError(404, four_o_four)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            self.log.info("Refusing to serve hidden file or directory %r, via 404 Error", os_path)
    +            raise web.HTTPError(404, four_o_four)
     
    -        os_path = self._get_os_path(path)
             if os.path.isdir(os_path):
                 if type not in (None, "directory"):
                     raise web.HTTPError(
    @@ -389,7 +400,7 @@ def get(self, path, content=True, type=None, format=None):
         def _save_directory(self, os_path, model, path=""):
             """create a directory"""
             if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    -            raise web.HTTPError(400, "Cannot create hidden directory %r" % os_path)
    +            raise web.HTTPError(400, "Cannot create directory %r" % os_path)
             if not os.path.exists(os_path):
                 with self.perm_to_403():
                     os.mkdir(os_path)
    @@ -410,6 +421,10 @@ def save(self, model, path=""):
                 raise web.HTTPError(400, "No file content provided")
     
             os_path = self._get_os_path(path)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot create file or directory {os_path!r}")
    +
             self.log.debug("Saving %s", os_path)
     
             validation_error: dict = {}
    @@ -452,8 +467,13 @@ def delete_file(self, path):
             path = path.strip("/")
             os_path = self._get_os_path(path)
             rm = os.unlink
    -        if not os.path.exists(os_path):
    -            raise web.HTTPError(404, "File or directory does not exist: %s" % os_path)
    +        four_o_four = "file or directory does not exist: %r" % path
    +
    +        if not self.exists(path):
    +            raise web.HTTPError(404, four_o_four)
    +
    +        if is_hidden(os_path, self.root_dir) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot delete file or directory {os_path!r}")
     
             def _check_trash(os_path):
                 if sys.platform in {"win32", "darwin"}:
    @@ -518,6 +538,11 @@ def rename_file(self, old_path, new_path):
             new_os_path = self._get_os_path(new_path)
             old_os_path = self._get_os_path(old_path)
     
    +        if (
    +            is_hidden(old_os_path, self.root_dir) or is_hidden(new_os_path, self.root_dir)
    +        ) and not self.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot rename file or directory {old_os_path!r}")
    +
             # Should we proceed with the move?
             if os.path.exists(new_os_path) and not samefile(old_os_path, new_os_path):
                 raise web.HTTPError(409, "File already exists: %s" % new_path)
    
  • jupyter_server/services/contents/handlers.py+38 0 modified
    @@ -95,6 +95,8 @@ async def get(self, path=""):
             of the files and directories it contains.
             """
             path = path or ""
    +        cm = self.contents_manager
    +
             type = self.get_query_argument("type", default=None)
             if type not in {None, "directory", "file", "notebook"}:
                 raise web.HTTPError(400, "Type %r is invalid" % type)
    @@ -107,6 +109,9 @@ async def get(self, path=""):
                 raise web.HTTPError(400, "Content %r is invalid" % content_str)
             content = int(content_str or "")
     
    +        if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
    +            raise web.HTTPError(404, f"file or directory {path!r} does not exist")
    +
             model = await ensure_async(
                 self.contents_manager.get(
                     path=path,
    @@ -126,6 +131,17 @@ async def patch(self, path=""):
             model = self.get_json_body()
             if model is None:
                 raise web.HTTPError(400, "JSON body missing")
    +
    +        old_path = model.get("path")
    +        if (
    +            old_path
    +            and (
    +                await ensure_async(cm.is_hidden(path)) or await ensure_async(cm.is_hidden(old_path))
    +            )
    +            and not cm.allow_hidden
    +        ):
    +            raise web.HTTPError(400, f"Cannot rename file or directory {path!r}")
    +
             model = await ensure_async(cm.update(model, path))
             validate_model(model, expect_content=False)
             self._finish_model(model)
    @@ -191,6 +207,16 @@ async def post(self, path=""):
                 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
     
             model = self.get_json_body()
    +        copy_from = model.get("copy_from")
    +        if (
    +            copy_from
    +            and (
    +                await ensure_async(cm.is_hidden(path))
    +                or await ensure_async(cm.is_hidden(copy_from))
    +            )
    +            and not cm.allow_hidden
    +        ):
    +            raise web.HTTPError(400, f"Cannot copy file or directory {path!r}")
     
             if model is not None:
                 copy_from = model.get("copy_from")
    @@ -217,9 +243,17 @@ async def put(self, path=""):
               create a new empty notebook.
             """
             model = self.get_json_body()
    +        cm = self.contents_manager
    +
             if model:
                 if model.get("copy_from"):
                     raise web.HTTPError(400, "Cannot copy with PUT, only POST")
    +            if (
    +                (model.get("path") and await ensure_async(cm.is_hidden(model.get("path"))))
    +                or await ensure_async(cm.is_hidden(path))
    +            ) and not cm.allow_hidden:
    +                raise web.HTTPError(400, f"Cannot create file or directory {path!r}")
    +
                 exists = await ensure_async(self.contents_manager.file_exists(path))
                 if exists:
                     await self._save(model, path)
    @@ -233,6 +267,10 @@ async def put(self, path=""):
         async def delete(self, path=""):
             """delete a file in the given path"""
             cm = self.contents_manager
    +
    +        if await ensure_async(cm.is_hidden(path)) and not cm.allow_hidden:
    +            raise web.HTTPError(400, f"Cannot delete file or directory {path!r}")
    +
             self.log.warning("delete %s", path)
             await ensure_async(cm.delete(path))
             self.set_status(204)
    
  • tests/services/contents/test_api.py+251 0 modified
    @@ -230,6 +230,36 @@ async def test_get_text_file_contents(jp_fetch, contents, path, name):
             )
         assert expected_http_error(e, 400)
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled retrieving hidden files on Windows")
    +async def test_get_404_hidden(jp_fetch, contents, contents_dir):
    +    # Create text files
    +    hidden_dir = contents_dir / '.hidden'
    +    hidden_dir.mkdir(parents=True, exist_ok=True)
    +    txt = f"visible text file in hidden dir"
    +    txtname = hidden_dir.joinpath(f"visible.txt")
    +    txtname.write_text(txt, encoding="utf-8")
    +
    +    txt2 = f"hidden text file"
    +    txtname2 = contents_dir.joinpath(f".hidden.txt")
    +    txtname2.write_text(txt2, encoding="utf-8")
    +   
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/visible.txt",
    +            method="GET",
    +        )
    +    assert expected_http_error(e, 404)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="GET",
    +        )
    +    assert expected_http_error(e, 404)
     
     @pytest.mark.parametrize("path,name", dirs)
     async def test_get_binary_file_contents(jp_fetch, contents, path, name):
    @@ -408,6 +438,55 @@ async def test_upload_txt(jp_fetch, contents, contents_dir, _check_created):
         assert model["content"] == body
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled uploading hidden files on Windows")
    +async def test_upload_txt_hidden(jp_fetch, contents, contents_dir):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +        }
    +        path = '.hidden/Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +            'path': '.hidden/test.txt'
    +        }
    +        path = 'Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +        }
    +        path = '.hidden.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        body = 'ünicode téxt'
    +        model = {
    +            'content' : body,
    +            'format'  : 'text',
    +            'type'    : 'file',
    +            'path': '.hidden.txt'
    +        }
    +        path = 'Upload tést.txt'
    +        await jp_fetch("api", "contents", path, method="PUT", body=json.dumps(model))
    +    assert expected_http_error(e, 400)
    +
    +
     async def test_upload_b64(jp_fetch, contents, contents_dir, _check_created):
         body = b"\xFFblob"
         b64body = encodebytes(body).decode("ascii")
    @@ -501,6 +580,49 @@ async def test_copy_put_400(jp_fetch, contents, contents_dir, _check_created):
         assert expected_http_error(e, 400)
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_copy_put_400_hidden(jp_fetch, contents, contents_dir,):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": ".hidden/new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="PUT",
    +            body=json.dumps({"copy_from": ".hidden.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +
     async def test_copy_dir_400(jp_fetch, contents, contents_dir, _check_created):
         with pytest.raises(tornado.httpclient.HTTPClientError) as e:
             await jp_fetch(
    @@ -513,6 +635,63 @@ async def test_copy_dir_400(jp_fetch, contents, contents_dir, _check_created):
         assert expected_http_error(e, 400)
     
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_copy_400_hidden(jp_fetch, contents, contents_dir,):
    +
    +    # Create text files
    +    hidden_dir = contents_dir / '.hidden'
    +    hidden_dir.mkdir(parents=True, exist_ok=True)
    +    txt = f"visible text file in hidden dir"
    +    txtname = hidden_dir.joinpath(f"new.txt")
    +    txtname.write_text(txt, encoding="utf-8")
    +
    +    paths = ['new.txt', '.hidden.txt']
    +    for name in paths:
    +        txt = f"{name} text file"
    +        txtname = contents_dir.joinpath(f"{name}.txt")
    +        txtname.write_text(txt, encoding="utf-8")
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden/old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +    
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": ".hidden/new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            ".hidden.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": "new.txt"}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch(
    +            "api",
    +            "contents",
    +            "old.txt",
    +            method="POST",
    +            body=json.dumps({"copy_from": ".hidden.txt"}),
    +        )
    +    assert expected_http_error(e, 400)    
    +
    +
     @pytest.mark.parametrize("path,name", dirs)
     async def test_delete(jp_fetch, contents, contents_dir, path, name, _check_created):
         nbname = name + ".ipynb"
    @@ -550,6 +729,24 @@ async def test_delete_non_empty_dir(jp_fetch, contents):
             await jp_fetch("api", "contents", "å b", method="GET")
         assert expected_http_error(e, 404)
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
    +async def test_delete_hidden_dir(jp_fetch, contents):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled deleting hidden dirs on Windows")
    +async def test_delete_hidden_file(jp_fetch, contents):
    +    #Test deleting file in a hidden directory
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden/test.txt", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
    +    #Test deleting a hidden file
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        await jp_fetch("api", "contents", ".hidden.txt", method="DELETE")
    +    assert expected_http_error(e, 400)
    +
     
     async def test_rename(jp_fetch, jp_base_url, contents, contents_dir):
         path = "foo"
    @@ -581,6 +778,60 @@ async def test_rename(jp_fetch, jp_base_url, contents, contents_dir):
         assert "z.ipynb" in nbnames
         assert "a.ipynb" not in nbnames
     
    +@pytest.mark.skipif(sys.platform == "win32", reason="Disabled copying hidden files on Windows")
    +async def test_rename_400_hidden(jp_fetch, jp_base_url, contents, contents_dir):
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = '.hidden/old.txt'
    +        new_path = 'new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +        
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = 'old.txt'
    +        new_path = '.hidden/new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +        
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = '.hidden.txt'
    +        new_path = 'new.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +
    +    with pytest.raises(tornado.httpclient.HTTPClientError) as e:
    +        old_path = 'old.txt'
    +        new_path = '.hidden.txt'
    +        # Rename the file
    +        r = await jp_fetch(
    +            "api",
    +            "contents",
    +            old_path,
    +            method="PATCH",
    +            body=json.dumps({"path": new_path}),
    +        )
    +    assert expected_http_error(e, 400)
    +
     
     async def test_checkpoints_follow_file(jp_fetch, contents):
         path = "foo"
    
  • tests/services/contents/test_manager.py+160 0 modified
    @@ -259,6 +259,166 @@ async def test_403(jp_file_contents_manager_class, tmp_path):
         except HTTPError as e:
             assert e.status_code == 403
     
    +@pytest.mark.skipif(sys.platform.startswith('win'), reason="Can't test hidden files on Windows")
    +async def test_400(jp_file_contents_manager_class, tmp_path):
    +    #Test Delete behavior
    +    #Test delete of file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.delete_file(os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test delete hidden file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.delete_file(os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test Save behavior
    +    #Test save of file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.save(model,path=os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test save hidden file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.save(model,path=os_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename behavior
    +    #Test rename with source file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        old_path = cm._get_os_path(model['path'])
    +        new_path = "new.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename of dest file in hidden directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        new_path = cm._get_os_path(model['path'])
    +        old_path = "old.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename with hidden source file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        old_path = cm._get_os_path(model['path'])
    +        new_path = "new.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +    #Test rename with hidden dest file in visible directory
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        new_path = cm._get_os_path(model['path'])
    +        old_path = "old.txt"
    +
    +        try:
    +            result = await ensure_async(cm.rename_file(old_path, new_path))
    +        except HTTPError as e:
    +            assert e.status_code == 400
    +
    +@pytest.mark.skipif(sys.platform.startswith('win'), reason="Can't test hidden files on Windows")
    +async def test_404(jp_file_contents_manager_class, tmp_path):
    +    #Test visible file in hidden folder
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = '.hidden'
    +        file_in_hidden_path = os.path.join(hidden_dir,'visible.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.get(os_path, 'w'))
    +        except HTTPError as e:
    +            assert e.status_code == 404
    +
    +    #Test hidden file in visible folder
    +    with pytest.raises(HTTPError) as excinfo:
    +        td = str(tmp_path)
    +        cm = jp_file_contents_manager_class(root_dir=td)
    +        hidden_dir = 'visible'
    +        file_in_hidden_path = os.path.join(hidden_dir,'.hidden.txt')
    +        _make_dir(cm, hidden_dir)
    +        model = await ensure_async(cm.new(path=file_in_hidden_path))
    +        os_path = cm._get_os_path(model['path'])
    +
    +        try:
    +            result = await ensure_async(cm.get(os_path, 'w'))
    +        except HTTPError as e:
    +            assert e.status_code == 404
     
     async def test_escape_root(jp_file_contents_manager_class, tmp_path):
         td = str(tmp_path)
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.