VYPR
Critical severity9.0NVD Advisory· Published Mar 10, 2026· Updated Apr 2, 2026

CVE-2026-27825

CVE-2026-27825

Description

MCP Atlassian is a Model Context Protocol (MCP) server for Atlassian products (Confluence and Jira). Prior to version 0.17.0, the confluence_download_attachment MCP tool accepts a download_path parameter that is written to without any directory boundary enforcement. An attacker who can call this tool and supply or access a Confluence attachment with malicious content can write arbitrary content to any path the server process has write access to. Because the attacker controls both the write destination and the written content (via an uploaded Confluence attachment), this constitutes for arbitrary code execution (for example, writing a valid cron entry to /etc/cron.d/ achieves code execution within one scheduler cycle with no server restart required). Version 0.17.0 fixes the issue.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
mcp-atlassianPyPI
< 0.17.00.17.0

Affected products

1

Patches

1
52b9b0997681

fix(confluence): add path traversal guard for attachment downloads (#987)

https://github.com/sooperset/mcp-atlassianHyeonsoo LeeFeb 24, 2026via ghsa
7 files changed · +166 21
  • src/mcp_atlassian/confluence/attachments.py+7 0 modified
    @@ -6,6 +6,7 @@
     from typing import Any
     
     from ..models.confluence import ConfluenceAttachment
    +from ..utils.io import validate_safe_path
     from .client import ConfluenceClient
     from .protocols import AttachmentsOperationsProto
     from .v2_adapter import ConfluenceV2Adapter
    @@ -185,6 +186,9 @@ def download_attachment(self, url: str, target_path: str) -> bool:
                 if not os.path.isabs(target_path):
                     target_path = os.path.abspath(target_path)
     
    +            # Guard against path traversal (resolves symlinks)
    +            validate_safe_path(target_path)
    +
                 logger.info(f"Downloading attachment from {url} to {target_path}")
     
                 # Create the directory if it doesn't exist
    @@ -231,6 +235,9 @@ def download_content_attachments(
             if not os.path.isabs(target_dir):
                 target_dir = os.path.abspath(target_dir)
     
    +        # Guard against path traversal (resolves symlinks)
    +        validate_safe_path(target_dir)
    +
             logger.info(
                 f"Downloading attachments for content {content_id} to directory: {target_dir}"
             )
    
  • src/mcp_atlassian/jira/attachments.py+5 12 modified
    @@ -7,6 +7,7 @@
     from typing import Any
     
     from ..models.jira import JiraAttachment
    +from ..utils.io import validate_safe_path
     from .client import JiraClient
     from .protocols import AttachmentsOperationsProto
     
    @@ -37,12 +38,8 @@ def download_attachment(self, url: str, target_path: str) -> bool:
                 if not os.path.isabs(target_path):
                     target_path = os.path.abspath(target_path)
     
    -            # Guard against path traversal
    -            base_dir = os.getcwd()
    -            if not Path(target_path).is_relative_to(base_dir):
    -                raise ValueError(
    -                    f"Path traversal detected: {target_path} is outside {base_dir}"
    -                )
    +            # Guard against path traversal (resolves symlinks)
    +            validate_safe_path(target_path)
     
                 logger.info(f"Downloading attachment from {url} to {target_path}")
     
    @@ -214,12 +211,8 @@ def download_issue_attachments(
             if not os.path.isabs(target_dir):
                 target_dir = os.path.abspath(target_dir)
     
    -        # Guard against path traversal
    -        base_dir = os.getcwd()
    -        if not Path(target_dir).is_relative_to(base_dir):
    -            raise ValueError(
    -                f"Path traversal detected: {target_dir} is outside {base_dir}"
    -            )
    +        # Guard against path traversal (resolves symlinks)
    +        validate_safe_path(target_dir)
     
             logger.info(
                 f"Downloading attachments for {issue_key} to directory: {target_dir}"
    
  • src/mcp_atlassian/utils/__init__.py+2 1 modified
    @@ -4,7 +4,7 @@
     """
     
     from .date import parse_date
    -from .io import is_read_only_mode
    +from .io import is_read_only_mode, validate_safe_path
     
     # Export lifecycle utilities
     from .lifecycle import (
    @@ -24,6 +24,7 @@
         "configure_ssl_verification",
         "is_atlassian_cloud_url",
         "is_read_only_mode",
    +    "validate_safe_path",
         "setup_logging",
         "parse_date",
         "parse_iso8601_date",
    
  • src/mcp_atlassian/utils/io.py+41 0 modified
    @@ -1,5 +1,8 @@
     """I/O utility functions for MCP Atlassian."""
     
    +import os
    +from pathlib import Path
    +
     from mcp_atlassian.utils.env import is_env_extended_truthy
     
     
    @@ -15,3 +18,41 @@ def is_read_only_mode() -> bool:
             True if read-only mode is enabled, False otherwise
         """
         return is_env_extended_truthy("READ_ONLY_MODE", "false")
    +
    +
    +def validate_safe_path(
    +    path: str | os.PathLike[str],
    +    base_dir: str | os.PathLike[str] | None = None,
    +) -> Path:
    +    """Validate that a path does not escape the base directory.
    +
    +    Resolves symlinks and normalizes the path to prevent path traversal
    +    attacks (e.g., ``../../etc/passwd``).
    +
    +    Args:
    +        path: The path to validate.
    +        base_dir: The directory the path must stay within.
    +            Defaults to the current working directory.
    +
    +    Returns:
    +        The resolved, validated path.
    +
    +    Raises:
    +        ValueError: If the resolved path escapes *base_dir*.
    +    """
    +    if base_dir is None:
    +        base_dir = os.getcwd()
    +
    +    resolved_base = Path(base_dir).resolve(strict=False)
    +    p = Path(path)
    +    # Resolve relative paths against base_dir, not cwd
    +    if not p.is_absolute():
    +        p = resolved_base / p
    +    resolved_path = p.resolve(strict=False)
    +
    +    if not resolved_path.is_relative_to(resolved_base):
    +        raise ValueError(
    +            f"Path traversal detected: {path} resolves outside {resolved_base}"
    +        )
    +
    +    return resolved_path
    
  • tests/unit/confluence/test_attachments.py+64 7 modified
    @@ -402,6 +402,7 @@ def test_download_attachment_success(self, attachments_mixin: AttachmentsMixin):
                 patch("os.path.exists") as mock_exists,
                 patch("os.path.getsize") as mock_getsize,
                 patch("os.makedirs") as mock_makedirs,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 mock_exists.return_value = True
                 mock_getsize.return_value = 12  # Length of "test content"
    @@ -439,6 +440,7 @@ def test_download_attachment_relative_path(
                 patch("os.makedirs") as mock_makedirs,
                 patch("os.path.abspath") as mock_abspath,
                 patch("os.path.isabs") as mock_isabs,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 mock_exists.return_value = True
                 mock_getsize.return_value = 12
    @@ -468,9 +470,10 @@ def test_download_attachment_http_error(self, attachments_mixin: AttachmentsMixi
             mock_response.raise_for_status.side_effect = Exception("HTTP Error")
             attachments_mixin.confluence._session.get.return_value = mock_response
     
    -        result = attachments_mixin.download_attachment(
    -            "https://test.url/attachment", "/tmp/test_file.txt"
    -        )
    +        with patch("mcp_atlassian.confluence.attachments.validate_safe_path"):
    +            result = attachments_mixin.download_attachment(
    +                "https://test.url/attachment", "/tmp/test_file.txt"
    +            )
             assert result is False
     
         def test_download_attachment_file_write_error(
    @@ -487,6 +490,7 @@ def test_download_attachment_file_write_error(
             with (
                 patch("builtins.open", mock_open()) as mock_file,
                 patch("os.makedirs") as mock_makedirs,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 mock_file().write.side_effect = OSError("Write error")
     
    @@ -510,6 +514,7 @@ def test_download_attachment_file_not_created(
                 patch("builtins.open", mock_open()) as mock_file,
                 patch("os.path.exists") as mock_exists,
                 patch("os.makedirs") as mock_makedirs,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 mock_exists.return_value = False  # File doesn't exist after write
     
    @@ -566,6 +571,7 @@ def test_download_content_attachments_success(
                     "mcp_atlassian.models.confluence.ConfluenceAttachment.from_api_response",
                     side_effect=[mock_attachment1, mock_attachment2],
                 ),
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 result = attachments_mixin.download_content_attachments(
                     "123456", "/tmp/attachments"
    @@ -614,6 +620,7 @@ def test_download_content_attachments_relative_path(
                 ),
                 patch("os.path.isabs") as mock_isabs,
                 patch("os.path.abspath") as mock_abspath,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 mock_isabs.return_value = False
                 mock_abspath.return_value = "/absolute/path/attachments"
    @@ -639,6 +646,7 @@ def test_download_content_attachments_no_attachments(
                     return_value={"success": True, "attachments": []},
                 ),
                 patch("pathlib.Path.mkdir") as mock_mkdir,
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 result = attachments_mixin.download_content_attachments(
                     "123456", "/tmp/attachments"
    @@ -656,10 +664,13 @@ def test_download_content_attachments_api_error(
         ):
             """Test download when API error occurs retrieving attachments."""
             # Mock the get_content_attachments to return error
    -        with patch.object(
    -            attachments_mixin,
    -            "get_content_attachments",
    -            return_value={"success": False, "error": "API Error"},
    +        with (
    +            patch.object(
    +                attachments_mixin,
    +                "get_content_attachments",
    +                return_value={"success": False, "error": "API Error"},
    +            ),
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 result = attachments_mixin.download_content_attachments(
                     "123456", "/tmp/attachments"
    @@ -713,6 +724,7 @@ def test_download_content_attachments_some_failures(
                     "mcp_atlassian.models.confluence.ConfluenceAttachment.from_api_response",
                     side_effect=[mock_attachment1, mock_attachment2],
                 ),
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 result = attachments_mixin.download_content_attachments(
                     "123456", "/tmp/attachments"
    @@ -753,6 +765,7 @@ def test_download_content_attachments_missing_url(
                     "mcp_atlassian.models.confluence.ConfluenceAttachment.from_api_response",
                     return_value=mock_attachment,
                 ),
    +            patch("mcp_atlassian.confluence.attachments.validate_safe_path"),
             ):
                 result = attachments_mixin.download_content_attachments(
                     "123456", "/tmp/attachments"
    @@ -1366,3 +1379,47 @@ async def test_skips_attachment_over_size_limit(self):
             assert summary["downloaded"] == 0
             assert len(summary["failed"]) == 1
             assert "50 MB" in summary["failed"][0]["error"]
    +
    +
    +class TestConfluenceAttachmentPathTraversal:
    +    """Security regression tests for path traversal in Confluence attachments."""
    +
    +    @pytest.fixture
    +    def confluence_mixin(self) -> AttachmentsMixin:
    +        """Create an AttachmentsMixin for path traversal testing."""
    +        with patch(
    +            "mcp_atlassian.confluence.attachments.ConfluenceClient.__init__"
    +        ) as mock_init:
    +            mock_init.return_value = None
    +            mixin = AttachmentsMixin()
    +            mixin.confluence = MagicMock()
    +            mixin.config = MagicMock()
    +            mixin.config.url = "https://test.atlassian.net/wiki"
    +            mixin.config.auth_type = "basic"
    +            mixin.preprocessor = MagicMock()
    +            return mixin
    +
    +    def test_download_attachment_absolute_etc_passwd(
    +        self, confluence_mixin: AttachmentsMixin
    +    ) -> None:
    +        """download_attachment rejects absolute path /etc/passwd."""
    +        result = confluence_mixin.download_attachment(
    +            "https://example.com/file", "/etc/passwd"
    +        )
    +        assert result is False
    +
    +    def test_download_attachment_relative_traversal(
    +        self, confluence_mixin: AttachmentsMixin
    +    ) -> None:
    +        """download_attachment rejects relative path traversal."""
    +        result = confluence_mixin.download_attachment(
    +            "https://example.com/file", "../../../etc/passwd"
    +        )
    +        assert result is False
    +
    +    def test_download_content_attachments_absolute_escape(
    +        self, confluence_mixin: AttachmentsMixin
    +    ) -> None:
    +        """download_content_attachments rejects directory escape."""
    +        with pytest.raises(ValueError, match="Path traversal detected"):
    +            confluence_mixin.download_content_attachments("12345", "/etc")
    
  • tests/unit/jira/test_attachments.py+2 0 modified
    @@ -116,6 +116,7 @@ def test_download_attachment_relative_path(
                 patch("os.path.abspath") as mock_abspath,
                 patch("os.path.isabs") as mock_isabs,
                 patch("os.getcwd", return_value="/absolute/path"),
    +            patch("mcp_atlassian.jira.attachments.validate_safe_path"),
             ):
                 mock_exists.return_value = True
                 mock_getsize.return_value = 12
    @@ -293,6 +294,7 @@ def test_download_issue_attachments_relative_path(
                 patch("os.path.isabs") as mock_isabs,
                 patch("os.path.abspath") as mock_abspath,
                 patch("os.getcwd", return_value="/absolute/path"),
    +            patch("mcp_atlassian.jira.attachments.validate_safe_path"),
             ):
                 mock_isabs.return_value = False
                 mock_abspath.return_value = "/absolute/path/attachments"
    
  • tests/unit/utils/test_io.py+45 1 modified
    @@ -1,9 +1,12 @@
     """Tests for the I/O utilities module."""
     
     import os
    +from pathlib import Path
     from unittest.mock import patch
     
    -from mcp_atlassian.utils.io import is_read_only_mode
    +import pytest
    +
    +from mcp_atlassian.utils.io import is_read_only_mode, validate_safe_path
     
     
     def test_is_read_only_mode_default():
    @@ -81,3 +84,44 @@ def test_is_read_only_mode_false():
     
             # Assert
             assert result is False
    +
    +
    +# --- validate_safe_path tests ---
    +
    +
    +class TestValidateSafePath:
    +    """Tests for validate_safe_path."""
    +
    +    def test_safe_relative_path(self, tmp_path: Path) -> None:
    +        """Relative path within base_dir is accepted."""
    +        result = validate_safe_path("subdir/file.txt", base_dir=tmp_path)
    +        assert result == (tmp_path / "subdir" / "file.txt").resolve()
    +
    +    def test_safe_absolute_path_within_base(self, tmp_path: Path) -> None:
    +        """Absolute path inside base_dir is accepted."""
    +        target = tmp_path / "sub" / "file.txt"
    +        result = validate_safe_path(str(target), base_dir=tmp_path)
    +        assert result == target.resolve()
    +
    +    def test_traversal_dotdot(self, tmp_path: Path) -> None:
    +        """Relative path with ../ escaping base_dir raises ValueError."""
    +        with pytest.raises(ValueError, match="Path traversal detected"):
    +            validate_safe_path("../../etc/passwd", base_dir=tmp_path)
    +
    +    def test_traversal_absolute_outside(self, tmp_path: Path) -> None:
    +        """Absolute path outside base_dir raises ValueError."""
    +        with pytest.raises(ValueError, match="Path traversal detected"):
    +            validate_safe_path("/etc/passwd", base_dir=tmp_path)
    +
    +    def test_traversal_nested(self, tmp_path: Path) -> None:
    +        """Nested traversal normalised by resolve() raises ValueError."""
    +        with pytest.raises(ValueError, match="Path traversal detected"):
    +            validate_safe_path("ok/../../../etc/shadow", base_dir=tmp_path)
    +
    +    def test_defaults_to_cwd(
    +        self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
    +    ) -> None:
    +        """When base_dir is None, defaults to os.getcwd()."""
    +        monkeypatch.chdir(tmp_path)
    +        result = validate_safe_path("child.txt")
    +        assert result == (tmp_path / "child.txt").resolve()
    

Vulnerability mechanics

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

References

3

News mentions

0

No linked articles in our index yet.