Skip to content

Commit b869bb6

Browse files
fix: noto 1426 jira mcp atlassian vuln updates (#18)
* fix(confluence): add path traversal guard for attachment downloads (sooperset#987) Add validate_safe_path() utility that resolves symlinks via Path.resolve() and validates containment with is_relative_to(). Guard Confluence download_attachment() and download_content_attachments() against path traversal. Refactor Jira attachment guards to use the shared utility, strengthening them with symlink resolution. Addresses GHSA-xjgw-4wvw-rgm4. Reported-by: yotampe-pluto Github-Issue: sooperset#984 * update version * ai gen test fixes :/ * . * skip instead of ai slop * delete more stuff * more * remove more changes * revert more changes * disable confluence on main --------- Co-authored-by: Hyeonsoo Lee <32061883+sooperset@users.noreply.github.com>
1 parent bd23f31 commit b869bb6

File tree

9 files changed

+132
-29
lines changed

9 files changed

+132
-29
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# MCP Atlassian
22

3+
## WARNING: Confluence not supported in this fork, we will clean up soon
4+
35
![PyPI Version](https://img.shields.io/pypi/v/mcp-atlassian)
46
![PyPI - Downloads](https://img.shields.io/pypi/dm/mcp-atlassian)
57
![PePy - Total Downloads](https://static.pepy.tech/personalized-badge/mcp-atlassian?period=total&units=international_system&left_color=grey&right_color=blue&left_text=Total%20Downloads)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "mcp-atlassian"
3-
version = "0.11.9+cohere.3"
3+
version = "0.11.9+cohere.4"
44
description = "The Model Context Protocol (MCP) Atlassian integration is an open-source implementation that bridges Atlassian products (Jira and Confluence) with AI language models following Anthropic's MCP specification. This project enables secure, contextual AI interactions with Atlassian tools while maintaining data privacy and security. Key features include:"
55
readme = "README.md"
66
requires-python = ">=3.10"

src/mcp_atlassian/jira/attachments.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from ..models.jira import JiraAttachment
9+
from ..utils.io import validate_safe_path
910
from .client import JiraClient
1011
from .protocols import AttachmentsOperationsProto
1112

@@ -36,6 +37,9 @@ def download_attachment(self, url: str, target_path: str) -> bool:
3637
if not os.path.isabs(target_path):
3738
target_path = os.path.abspath(target_path)
3839

40+
# Guard against path traversal (resolves symlinks)
41+
validate_safe_path(target_path)
42+
3943
logger.info(f"Downloading attachment from {url} to {target_path}")
4044

4145
# Create the directory if it doesn't exist
@@ -82,6 +86,9 @@ def download_issue_attachments(
8286
if not os.path.isabs(target_dir):
8387
target_dir = os.path.abspath(target_dir)
8488

89+
# Guard against path traversal (resolves symlinks)
90+
validate_safe_path(target_dir)
91+
8592
logger.info(
8693
f"Downloading attachments for {issue_key} to directory: {target_dir}"
8794
)

src/mcp_atlassian/servers/main.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
from mcp_atlassian.utils.logging import mask_sensitive
2525
from mcp_atlassian.utils.tools import get_enabled_tools, should_include_tool
2626

27-
from .confluence import confluence_mcp
2827
from .context import MainAppContext
2928
from .jira import jira_mcp
3029

@@ -61,19 +60,7 @@ async def main_lifespan(app: FastMCP[MainAppContext]) -> AsyncIterator[dict]:
6160
logger.error(f"Failed to load Jira configuration: {e}", exc_info=True)
6261

6362
if services.get("confluence"):
64-
try:
65-
confluence_config = ConfluenceConfig.from_env()
66-
if confluence_config.is_auth_configured():
67-
loaded_confluence_config = confluence_config
68-
logger.info(
69-
"Confluence configuration loaded and authentication is configured."
70-
)
71-
else:
72-
logger.warning(
73-
"Confluence URL found, but authentication is not fully configured. Confluence tools will be unavailable."
74-
)
75-
except Exception as e:
76-
logger.error(f"Failed to load Confluence configuration: {e}", exc_info=True)
63+
raise NotImplementedError("confluence unsupported for now")
7764

7865
app_context = MainAppContext(
7966
full_jira_config=loaded_jira_config,
@@ -161,6 +148,8 @@ async def _mcp_list_tools(self) -> list[MCPTool]:
161148
# Exclude Jira/Confluence tools if config is not fully authenticated
162149
is_jira_tool = "jira" in tool_tags
163150
is_confluence_tool = "confluence" in tool_tags
151+
if is_confluence_tool:
152+
raise NotImplementedError("confluence not supported for now")
164153
service_configured_and_available = True
165154
if app_lifespan_state:
166155
if is_jira_tool and not app_lifespan_state.full_jira_config:
@@ -331,7 +320,8 @@ async def dispatch(
331320

332321
main_mcp = AtlassianMCP(name="Atlassian MCP", lifespan=main_lifespan)
333322
main_mcp.mount(jira_mcp, prefix="jira")
334-
main_mcp.mount(confluence_mcp, prefix="confluence")
323+
# removing confluence for now
324+
# main_mcp.mount(confluence_mcp, prefix="confluence")
335325

336326

337327
@main_mcp.custom_route("/healthz", methods=["GET"], include_in_schema=False)

src/mcp_atlassian/utils/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"""
55

66
from .date import parse_date
7-
from .io import is_read_only_mode
7+
from .io import is_read_only_mode, validate_safe_path
88

99
# Export lifecycle utilities
1010
from .lifecycle import (
@@ -24,6 +24,7 @@
2424
"configure_ssl_verification",
2525
"is_atlassian_cloud_url",
2626
"is_read_only_mode",
27+
"validate_safe_path",
2728
"setup_logging",
2829
"parse_date",
2930
"parse_iso8601_date",

src/mcp_atlassian/utils/io.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
"""I/O utility functions for MCP Atlassian."""
22

3+
import os
4+
from pathlib import Path
5+
36
from mcp_atlassian.utils.env import is_env_extended_truthy
47

58

@@ -15,3 +18,41 @@ def is_read_only_mode() -> bool:
1518
True if read-only mode is enabled, False otherwise
1619
"""
1720
return is_env_extended_truthy("READ_ONLY_MODE", "false")
21+
22+
23+
def validate_safe_path(
24+
path: str | os.PathLike[str],
25+
base_dir: str | os.PathLike[str] | None = None,
26+
) -> Path:
27+
"""Validate that a path does not escape the base directory.
28+
29+
Resolves symlinks and normalizes the path to prevent path traversal
30+
attacks (e.g., ``../../etc/passwd``).
31+
32+
Args:
33+
path: The path to validate.
34+
base_dir: The directory the path must stay within.
35+
Defaults to the current working directory.
36+
37+
Returns:
38+
The resolved, validated path.
39+
40+
Raises:
41+
ValueError: If the resolved path escapes *base_dir*.
42+
"""
43+
if base_dir is None:
44+
base_dir = os.getcwd()
45+
46+
resolved_base = Path(base_dir).resolve(strict=False)
47+
p = Path(path)
48+
# Resolve relative paths against base_dir, not cwd
49+
if not p.is_absolute():
50+
p = resolved_base / p
51+
resolved_path = p.resolve(strict=False)
52+
53+
if not resolved_path.is_relative_to(resolved_base):
54+
raise ValueError(
55+
f"Path traversal detected: {path} resolves outside {resolved_base}"
56+
)
57+
58+
return resolved_path

tests/unit/jira/test_attachments.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def test_download_attachment_success(self, attachments_mixin: AttachmentsMixin):
7272
patch("os.path.exists") as mock_exists,
7373
patch("os.path.getsize") as mock_getsize,
7474
patch("os.makedirs") as mock_makedirs,
75+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
7576
):
7677
mock_exists.return_value = True
7778
mock_getsize.return_value = 12 # Length of "test content"
@@ -108,6 +109,8 @@ def test_download_attachment_relative_path(
108109
patch("os.makedirs") as mock_makedirs,
109110
patch("os.path.abspath") as mock_abspath,
110111
patch("os.path.isabs") as mock_isabs,
112+
patch("os.getcwd", return_value="/absolute/path"),
113+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
111114
):
112115
mock_exists.return_value = True
113116
mock_getsize.return_value = 12
@@ -137,9 +140,10 @@ def test_download_attachment_http_error(self, attachments_mixin: AttachmentsMixi
137140
mock_response.raise_for_status.side_effect = Exception("HTTP Error")
138141
attachments_mixin.jira._session.get.return_value = mock_response
139142

140-
result = attachments_mixin.download_attachment(
141-
"https://test.url/attachment", "/tmp/test_file.txt"
142-
)
143+
with patch("mcp_atlassian.jira.attachments.validate_safe_path"):
144+
result = attachments_mixin.download_attachment(
145+
"https://test.url/attachment", "/tmp/test_file.txt"
146+
)
143147
assert result is False
144148

145149
def test_download_attachment_file_write_error(
@@ -156,6 +160,7 @@ def test_download_attachment_file_write_error(
156160
with (
157161
patch("builtins.open", mock_open()) as mock_file,
158162
patch("os.makedirs") as mock_makedirs,
163+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
159164
):
160165
mock_file().write.side_effect = OSError("Write error")
161166

@@ -179,6 +184,7 @@ def test_download_attachment_file_not_created(
179184
patch("builtins.open", mock_open()) as mock_file,
180185
patch("os.path.exists") as mock_exists,
181186
patch("os.makedirs") as mock_makedirs,
187+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
182188
):
183189
mock_exists.return_value = False # File doesn't exist after write
184190

@@ -231,6 +237,7 @@ def test_download_issue_attachments_success(
231237
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
232238
side_effect=[mock_attachment1, mock_attachment2],
233239
),
240+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
234241
):
235242
result = attachments_mixin.download_issue_attachments(
236243
"TEST-123", "/tmp/attachments"
@@ -281,6 +288,8 @@ def test_download_issue_attachments_relative_path(
281288
),
282289
patch("os.path.isabs") as mock_isabs,
283290
patch("os.path.abspath") as mock_abspath,
291+
patch("os.getcwd", return_value="/absolute/path"),
292+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
284293
):
285294
mock_isabs.return_value = False
286295
mock_abspath.return_value = "/absolute/path/attachments"
@@ -302,7 +311,10 @@ def test_download_issue_attachments_no_attachments(
302311
mock_issue = {"fields": {"attachment": []}}
303312
attachments_mixin.jira.issue.return_value = mock_issue
304313

305-
with patch("pathlib.Path.mkdir") as mock_mkdir:
314+
with (
315+
patch("pathlib.Path.mkdir") as mock_mkdir,
316+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
317+
):
306318
result = attachments_mixin.download_issue_attachments(
307319
"TEST-123", "/tmp/attachments"
308320
)
@@ -320,9 +332,12 @@ def test_download_issue_attachments_issue_not_found(
320332
"""Test download when issue cannot be retrieved."""
321333
attachments_mixin.jira.issue.return_value = None
322334

323-
with pytest.raises(
324-
TypeError,
325-
match="Unexpected return value type from `jira.issue`: <class 'NoneType'>",
335+
with (
336+
pytest.raises(
337+
TypeError,
338+
match="Unexpected return value type from `jira.issue`: <class 'NoneType'>",
339+
),
340+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
326341
):
327342
attachments_mixin.download_issue_attachments("TEST-123", "/tmp/attachments")
328343

@@ -334,9 +349,10 @@ def test_download_issue_attachments_no_fields(
334349
mock_issue = {} # Missing 'fields' key
335350
attachments_mixin.jira.issue.return_value = mock_issue
336351

337-
result = attachments_mixin.download_issue_attachments(
338-
"TEST-123", "/tmp/attachments"
339-
)
352+
with patch("mcp_atlassian.jira.attachments.validate_safe_path"):
353+
result = attachments_mixin.download_issue_attachments(
354+
"TEST-123", "/tmp/attachments"
355+
)
340356

341357
# Assertions
342358
assert result["success"] is False
@@ -386,6 +402,7 @@ def test_download_issue_attachments_some_failures(
386402
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
387403
side_effect=[mock_attachment1, mock_attachment2],
388404
),
405+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
389406
):
390407
result = attachments_mixin.download_issue_attachments(
391408
"TEST-123", "/tmp/attachments"
@@ -430,6 +447,7 @@ def test_download_issue_attachments_missing_url(
430447
"mcp_atlassian.models.jira.JiraAttachment.from_api_response",
431448
return_value=mock_attachment,
432449
),
450+
patch("mcp_atlassian.jira.attachments.validate_safe_path"),
433451
):
434452
result = attachments_mixin.download_issue_attachments(
435453
"TEST-123", "/tmp/attachments"

tests/unit/utils/test_io.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Tests for the I/O utilities module."""
22

33
import os
4+
from pathlib import Path
45
from unittest.mock import patch
56

6-
from mcp_atlassian.utils.io import is_read_only_mode
7+
import pytest
8+
9+
from mcp_atlassian.utils.io import is_read_only_mode, validate_safe_path
710

811

912
def test_is_read_only_mode_default():
@@ -81,3 +84,44 @@ def test_is_read_only_mode_false():
8184

8285
# Assert
8386
assert result is False
87+
88+
89+
# --- validate_safe_path tests ---
90+
91+
92+
class TestValidateSafePath:
93+
"""Tests for validate_safe_path."""
94+
95+
def test_safe_relative_path(self, tmp_path: Path) -> None:
96+
"""Relative path within base_dir is accepted."""
97+
result = validate_safe_path("subdir/file.txt", base_dir=tmp_path)
98+
assert result == (tmp_path / "subdir" / "file.txt").resolve()
99+
100+
def test_safe_absolute_path_within_base(self, tmp_path: Path) -> None:
101+
"""Absolute path inside base_dir is accepted."""
102+
target = tmp_path / "sub" / "file.txt"
103+
result = validate_safe_path(str(target), base_dir=tmp_path)
104+
assert result == target.resolve()
105+
106+
def test_traversal_dotdot(self, tmp_path: Path) -> None:
107+
"""Relative path with ../ escaping base_dir raises ValueError."""
108+
with pytest.raises(ValueError, match="Path traversal detected"):
109+
validate_safe_path("../../etc/passwd", base_dir=tmp_path)
110+
111+
def test_traversal_absolute_outside(self, tmp_path: Path) -> None:
112+
"""Absolute path outside base_dir raises ValueError."""
113+
with pytest.raises(ValueError, match="Path traversal detected"):
114+
validate_safe_path("/etc/passwd", base_dir=tmp_path)
115+
116+
def test_traversal_nested(self, tmp_path: Path) -> None:
117+
"""Nested traversal normalised by resolve() raises ValueError."""
118+
with pytest.raises(ValueError, match="Path traversal detected"):
119+
validate_safe_path("ok/../../../etc/shadow", base_dir=tmp_path)
120+
121+
def test_defaults_to_cwd(
122+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
123+
) -> None:
124+
"""When base_dir is None, defaults to os.getcwd()."""
125+
monkeypatch.chdir(tmp_path)
126+
result = validate_safe_path("child.txt")
127+
assert result == (tmp_path / "child.txt").resolve()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)