VYPR
High severity7.5NVD Advisory· Published Jun 1, 2026

CVE-2026-49136

CVE-2026-49136

Description

Banana Slides through 0.4.0, patched in commit e8bc490, contains a path traversal vulnerability in the generate_image() function within the AI service backend that allows unauthenticated attackers to read arbitrary image-format files outside the intended uploads directory by exploiting an incomplete path prefix check using os.path.startswith() without a trailing separator. Attackers can supply crafted markdown image references in user-controlled page descriptions that resolve to sibling directories whose names share the uploads folder prefix, bypassing the directory confinement check and causing the application to read files from unintended locations via PIL Image.open().

Affected products

1

Patches

2
e8bc490ec8b4

fix(security): block sibling-prefix path traversal in /files image refs

https://github.com/Anionex/banana-slidessysyMay 31, 2026via nvd-ref
1 file changed · +1 1
  • backend/services/ai_service.py+1 1 modified
    @@ -949,7 +949,7 @@ def generate_image(self, prompt: str, ref_image_path: Optional[str] = None,
                                 upload_folder = get_config().UPLOAD_FOLDER
                                 relative_path = ref_img[len('/files/'):].lstrip('/')
                                 local_path = os.path.abspath(os.path.join(upload_folder, relative_path))
    -                            if not local_path.startswith(os.path.abspath(upload_folder)):
    +                            if not local_path.startswith(os.path.abspath(upload_folder) + os.sep):
                                     logger.warning(f"Path traversal attempt blocked: {ref_img}, skipping...")
                                 elif os.path.exists(local_path):
                                     opened = Image.open(local_path)
    
82d870f6d5c9

Merge pull request #430 from AAtomical/fix/uploads-prefix-traversal

https://github.com/Anionex/banana-slidesAnionMay 31, 2026via nvd-ref
4 files changed · +222 13
  • backend/services/ai_service.py+12 5 modified
    @@ -947,17 +947,24 @@ def generate_image(self, prompt: str, ref_image_path: Optional[str] = None,
                             elif ref_img.startswith('/files/'):
                                 # 通用 /files/ 路径(materials、项目文件等),转换为文件系统路径
                                 upload_folder = get_config().UPLOAD_FOLDER
    -                            relative_path = ref_img[len('/files/'):].lstrip('/')
    -                            local_path = os.path.abspath(os.path.join(upload_folder, relative_path))
    -                            if not local_path.startswith(os.path.abspath(upload_folder)):
    +                            upload_folder_real = os.path.realpath(upload_folder)
    +                            relative_path = ref_img[len('/files/'):].lstrip('/\\')
    +                            local_path = os.path.realpath(os.path.join(upload_folder, relative_path))
    +                            try:
    +                                is_inside_upload_folder = (
    +                                    os.path.commonpath([local_path, upload_folder_real]) == upload_folder_real
    +                                )
    +                            except ValueError:
    +                                is_inside_upload_folder = False
    +                            if not is_inside_upload_folder:
                                     logger.warning(f"Path traversal attempt blocked: {ref_img}, skipping...")
    -                            elif os.path.exists(local_path):
    +                            elif os.path.isfile(local_path):
                                     opened = Image.open(local_path)
                                     ref_images.append(opened)
                                     owned_images.append(opened)
                                     logger.debug(f"Loaded image from local path: {local_path}")
                                 else:
    -                                logger.warning(f"Local file not found: {local_path} (from {ref_img}), skipping...")
    +                                logger.warning(f"Local file not found or not a file: {local_path} (from {ref_img}), skipping...")
                             else:
                                 logger.warning(f"Invalid image reference: {ref_img}, skipping...")
     
    
  • backend/tests/unit/test_ai_service_file_refs.py+127 0 added
    @@ -0,0 +1,127 @@
    +import os
    +
    +from PIL import Image
    +
    +from config import get_config
    +from services.ai_service import AIService
    +
    +
    +class FakeImageProvider:
    +    def __init__(self):
    +        self.ref_images = None
    +
    +    def generate_image(self, **kwargs):
    +        self.ref_images = kwargs.get("ref_images")
    +        return Image.new("RGB", (4, 4), color="blue")
    +
    +
    +def _save_image(path):
    +    path.parent.mkdir(parents=True, exist_ok=True)
    +    Image.new("RGB", (4, 4), color="red").save(path)
    +
    +
    +def test_files_reference_blocks_sibling_prefix_traversal(monkeypatch, tmp_path, caplog):
    +    upload_dir = tmp_path / "uploads"
    +    allowed_image = upload_dir / "materials" / "ok.png"
    +    secret_image = tmp_path / "uploads_secret" / "flag.png"
    +    _save_image(allowed_image)
    +    _save_image(secret_image)
    +
    +    monkeypatch.setattr(get_config(), "UPLOAD_FOLDER", str(upload_dir))
    +    image_provider = FakeImageProvider()
    +    service = AIService(
    +        text_provider=object(),
    +        image_provider=image_provider,
    +        caption_provider=object(),
    +    )
    +
    +    result = service.generate_image(
    +        "prompt",
    +        additional_ref_images=[
    +            "/files/materials/ok.png",
    +            "/files/../uploads_secret/flag.png",
    +        ],
    +    )
    +
    +    assert result is not None
    +    assert image_provider.ref_images is not None
    +    assert len(image_provider.ref_images) == 1
    +    assert "Path traversal attempt blocked: /files/../uploads_secret/flag.png" in caplog.text
    +
    +
    +def test_files_reference_blocks_commonpath_value_error(monkeypatch, tmp_path, caplog):
    +    upload_dir = tmp_path / "uploads"
    +    _save_image(upload_dir / "materials" / "ok.png")
    +
    +    def raise_value_error(_paths):
    +        raise ValueError("Paths don't have the same drive")
    +
    +    monkeypatch.setattr(get_config(), "UPLOAD_FOLDER", str(upload_dir))
    +    monkeypatch.setattr("services.ai_service.os.path.commonpath", raise_value_error)
    +    image_provider = FakeImageProvider()
    +    service = AIService(
    +        text_provider=object(),
    +        image_provider=image_provider,
    +        caption_provider=object(),
    +    )
    +
    +    result = service.generate_image(
    +        "prompt",
    +        additional_ref_images=["/files/materials/ok.png"],
    +    )
    +
    +    assert result is not None
    +    assert image_provider.ref_images is None
    +    assert "Path traversal attempt blocked: /files/materials/ok.png" in caplog.text
    +
    +
    +def test_files_reference_blocks_symlink_escape(monkeypatch, tmp_path, caplog):
    +    upload_dir = tmp_path / "uploads"
    +    outside_image = tmp_path / "outside" / "flag.png"
    +    symlink_path = upload_dir / "materials" / "linked.png"
    +    _save_image(outside_image)
    +    symlink_path.parent.mkdir(parents=True, exist_ok=True)
    +
    +    try:
    +        os.symlink(outside_image, symlink_path)
    +    except (OSError, NotImplementedError):
    +        return
    +
    +    monkeypatch.setattr(get_config(), "UPLOAD_FOLDER", str(upload_dir))
    +    image_provider = FakeImageProvider()
    +    service = AIService(
    +        text_provider=object(),
    +        image_provider=image_provider,
    +        caption_provider=object(),
    +    )
    +
    +    result = service.generate_image(
    +        "prompt",
    +        additional_ref_images=["/files/materials/linked.png"],
    +    )
    +
    +    assert result is not None
    +    assert image_provider.ref_images is None
    +    assert "Path traversal attempt blocked: /files/materials/linked.png" in caplog.text
    +
    +
    +def test_files_reference_skips_directories(monkeypatch, tmp_path, caplog):
    +    upload_dir = tmp_path / "uploads"
    +    (upload_dir / "materials").mkdir(parents=True)
    +
    +    monkeypatch.setattr(get_config(), "UPLOAD_FOLDER", str(upload_dir))
    +    image_provider = FakeImageProvider()
    +    service = AIService(
    +        text_provider=object(),
    +        image_provider=image_provider,
    +        caption_provider=object(),
    +    )
    +
    +    result = service.generate_image(
    +        "prompt",
    +        additional_ref_images=["/files/materials/"],
    +    )
    +
    +    assert result is not None
    +    assert image_provider.ref_images is None
    +    assert "Local file not found or not a file:" in caplog.text
    
  • backend/tests/unit/test_mineru_path_utils.py+53 0 added
    @@ -0,0 +1,53 @@
    +import os
    +
    +from PIL import Image
    +
    +from utils.path_utils import convert_mineru_path_to_local, find_mineru_file_with_prefix
    +
    +
    +def _save_image(path):
    +    path.parent.mkdir(parents=True, exist_ok=True)
    +    Image.new("RGB", (4, 4), color="red").save(path)
    +
    +
    +def test_convert_mineru_path_blocks_traversal(tmp_path):
    +    assert convert_mineru_path_to_local(
    +        "/files/mineru/../../outside.png",
    +        project_root=tmp_path,
    +    ) is None
    +
    +
    +def test_find_mineru_file_allows_valid_file(tmp_path):
    +    image_path = tmp_path / "uploads" / "mineru_files" / "extract" / "image.png"
    +    _save_image(image_path)
    +
    +    assert find_mineru_file_with_prefix(
    +        "/files/mineru/extract/image.png",
    +        project_root=tmp_path,
    +    ) == image_path
    +
    +
    +def test_convert_mineru_path_strips_leading_slashes(tmp_path):
    +    expected_path = tmp_path / "uploads" / "mineru_files" / "extract" / "image.png"
    +
    +    assert convert_mineru_path_to_local(
    +        "/files/mineru//extract/image.png",
    +        project_root=tmp_path,
    +    ) == expected_path
    +
    +
    +def test_find_mineru_file_blocks_symlink_escape(tmp_path):
    +    outside_image = tmp_path / "outside" / "flag.png"
    +    symlink_path = tmp_path / "uploads" / "mineru_files" / "extract" / "linked.png"
    +    _save_image(outside_image)
    +    symlink_path.parent.mkdir(parents=True, exist_ok=True)
    +
    +    try:
    +        os.symlink(outside_image, symlink_path)
    +    except (OSError, NotImplementedError):
    +        return
    +
    +    assert find_mineru_file_with_prefix(
    +        "/files/mineru/extract/linked.png",
    +        project_root=tmp_path,
    +    ) is None
    
  • backend/utils/path_utils.py+30 8 modified
    @@ -9,6 +9,20 @@
     logger = logging.getLogger(__name__)
     
     
    +def _is_path_within(path: Path, root: Path) -> bool:
    +    try:
    +        real_root = os.path.realpath(root)
    +        return os.path.commonpath([os.path.realpath(path), real_root]) == real_root
    +    except ValueError:
    +        return False
    +
    +
    +def _default_project_root() -> Path:
    +    current_file = Path(__file__).resolve()
    +    backend_dir = current_file.parent.parent
    +    return backend_dir.parent
    +
    +
     def convert_mineru_path_to_local(mineru_path: str, project_root: Optional[Path] = None) -> Optional[Path]:
         """
         将 /files/mineru/{extract_id}/{rel_path} 格式的路径转换为本地文件系统路径
    @@ -25,17 +39,18 @@ def convert_mineru_path_to_local(mineru_path: str, project_root: Optional[Path]
                 return None
             
             # Remove '/files/mineru/' prefix
    -        rel_path = mineru_path.replace('/files/mineru/', '')
    +        rel_path = mineru_path[len('/files/mineru/'):].lstrip('/\\')
             
             # Get project root if not provided
             if project_root is None:
    -            # Navigate to project root (assuming this file is in backend/utils/)
    -            current_file = Path(__file__).resolve()
    -            backend_dir = current_file.parent.parent
    -            project_root = backend_dir.parent
    +            project_root = _default_project_root()
             
             # Construct full path: {project_root}/uploads/mineru_files/{rel_path}
    -        local_path = project_root / 'uploads' / 'mineru_files' / rel_path
    +        mineru_root = project_root / 'uploads' / 'mineru_files'
    +        local_path = Path(os.path.realpath(mineru_root / rel_path))
    +        if not _is_path_within(local_path, mineru_root):
    +            logger.warning(f"Path traversal attempt blocked for MinerU path: {mineru_path}")
    +            return None
             
             return local_path
         except Exception as e:
    @@ -63,13 +78,21 @@ def find_mineru_file_with_prefix(mineru_path: str, project_root: Optional[Path]
         
         if local_path is None:
             return None
    +    if project_root is None:
    +        project_root = _default_project_root()
    +    mineru_root = project_root / 'uploads' / 'mineru_files'
         
         # Direct file matching
         if local_path.exists() and local_path.is_file():
    +        if not _is_path_within(local_path, mineru_root):
    +            return None
             return local_path
         
         # Try prefix match using the generic function
    -    return find_file_with_prefix(local_path)
    +    matched_path = find_file_with_prefix(local_path)
    +    if matched_path and _is_path_within(matched_path, mineru_root):
    +        return Path(os.path.realpath(matched_path))
    +    return None
     
     
     def find_file_with_prefix(file_path: Path) -> Optional[Path]:
    @@ -109,4 +132,3 @@ def find_file_with_prefix(file_path: Path) -> Optional[Path]:
                     logger.warning(f"Failed to list directory {dirpath}: {str(e)}")
         
         return None
    -
    

Vulnerability mechanics

Root cause

"The path traversal check uses a string prefix comparison without a trailing separator, allowing sibling directories with similar names to be accessed."

Attack vector

An unauthenticated attacker can craft markdown image references in user-controlled page descriptions. These references, formatted as `![](/files/../uploads_secret/flag.png)`, are processed by the `generate_image()` function. The incomplete path check `local_path.startswith(os.path.abspath(upload_folder))` incorrectly passes for sibling directories whose names share the `uploads` prefix, such as `/app/uploads_secret`. This allows `PIL Image.open()` to read arbitrary image-format files from unintended locations before the AI API call is made [ref_id=1].

Affected code

The vulnerability exists in the `generate_image()` function within `backend/services/ai_service.py`. Specifically, the path validation logic at line 952 (in v0.4.0) uses `local_path.startswith(os.path.abspath(upload_folder))` which fails to prevent traversal into sibling directories. The file read occurs via `Image.open(local_path)` on line 955 [ref_id=1].

What the fix does

The fix appends `os.sep` to the `upload_folder` prefix in the `startswith()` check. This ensures that the check enforces directory containment rather than just string-prefix containment. By requiring the resolved path to start with the upload folder path *and* a directory separator, paths into sibling directories like `/app/uploads_secret` are correctly rejected, preventing path traversal [ref_id=2].

Preconditions

  • authNo authentication is required by default.
  • inputThe attacker must be able to control page description content, which allows markdown image references.

Reproduction

Exploit Steps: 1. Create a project. 2. Create a page within the project. 3. Inject a crafted markdown image reference like `![](/files/../uploads_secret/flag.png)` into the page description. 4. Trigger image generation for the page. The application will attempt to read the specified file from the sibling directory due to the flawed path check [ref_id=1].

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

References

4

News mentions

0

No linked articles in our index yet.