//nefariousplan

CVE-2026-27825: mcp-atlassian's Path Patch Guarded Every Write. open(file_path, "rb") Is a Read.

patterns

cve

proof of concept

mcp-atlassian shipped commit 52b9b099 on 2026-02-24 to fix GHSA-xjgw-4wvw-rgm4. The diff added a validate_safe_path helper, imported it into src/mcp_atlassian/confluence/attachments.py and src/mcp_atlassian/jira/attachments.py, and called it on the functions that write attachment bytes to local disk. Each file has another open() call that reads bytes from local disk. CVE-2026-27825 is those.

The CVE description names the bug "Path Traversal in mcp-atlassian via zip extraction in upload_attachment." The codebase imports no archive library.

The CVE description names a mechanism that doesn't exist in this codebase

grep -r "zipfile\|ZipFile\|tarfile\|extractall" src/ against sooperset/mcp-atlassian at every released tag from v0.17.0 through v0.21.1 returns zero hits. The project imports no archive library. confluence_upload_attachment, the MCP tool the CVE names, does not unpack archives. It does this:

# src/mcp_atlassian/confluence/attachments.py:477
files = {"file": (filename, open(file_path, "rb"))}
response = self.confluence._session.put(
    url, headers=headers, files=files, data=data
)

file_path is the unconstrained string the MCP tool's caller passes. open(file_path, "rb") reads it. self.confluence._session.put posts the bytes to the Confluence REST API the server is configured to talk to. There is no archive in the chain. The path is opened, the bytes are exfiltrated, the multipart body contains the file. CWE-22 in spirit, CWE-22 in cve.org's catalog, and the mechanism the description names is fictional.

The reason this matters: a defender reading "zip extraction" reaches for a different control. They check whether they extract attachments anywhere. They do not. They mark themselves not-affected, because they don't unpack archives. The MCP server keeps reading whatever path the next caller names.

What confluence_upload_attachment actually is

The tool is registered in src/mcp_atlassian/servers/confluence.py:1295. Its file_path argument is declared as:

file_path: Annotated[
    str,
    Field(
        description=(
            "Full path to the file to upload. Can be absolute "
            "(e.g., '/home/user/document.pdf' or 'C:\\Users\\name\\file.docx') "
            "or relative to the current working directory "
            "(e.g., './uploads/document.pdf'). "
            "If a file with the same name already exists, a new version "
            "will be created."
        )
    ),
],

No pattern= constraint. No validator. The field's documented contract is "absolute or relative path on the server's filesystem." That is not a typo; it is the design.

confluence_fetcher.upload_attachment runs three preparatory checks: content_id is non-empty, file_path is non-empty, the file exists. It then calls os.path.abspath(file_path), takes os.path.basename as the filename, and dispatches to _upload_attachment_direct. That helper assembles a multipart body where the file field is (filename, open(file_path, "rb")) and PUTs it to <config.url>/rest/api/content/<content_id>/child/attachment.

The simple version of the attack is: caller controls file_path, server reads any file the server process can read, server uploads the bytes to wherever the server's Confluence config points. If the operator owns the upstream Confluence tenant, the attacker reads the attachment back from that tenant under the operator's account. The four-line PoC published by Romain Deperne, the bug's discoverer, names /etc/passwd and confirms the bytes appear in the captured multipart body. ~/.ssh/id_rsa, /proc/self/environ, the server's .env, and the CONFLUENCE_API_TOKEN env var fit the same shape.

Reachability: the default transport is streamable-http and the default host is 0.0.0.0

# src/mcp_atlassian/__init__.py:150
"--host",
default="0.0.0.0",  # noqa: S104
help="Host to bind to for SSE or Streamable HTTP transport (default: 0.0.0.0)",

The MCP protocol assumes the client and the server share a process boundary. Stdio transport delivers that. The streamable-http transport does not. mcp-atlassian binds it on every interface by default and inherits the protocol's local-trust assumption regardless. There is a header-extraction middleware that reads an Atlassian token off inbound requests for proxying to upstream Confluence. With IGNORE_HEADER_AUTH=true set, even that is skipped and the server uses its configured service-account credentials for every call. There is no MCP-client authentication anywhere in the codebase.

The project's README presents docker run sooperset/mcp-atlassian --transport streamable-http as a deployment example. That command, with the operator's Atlassian credentials baked into the container at build or via environment, exposes every registered tool on port 8000 of every interface, with no client auth. confluence_upload_attachment is one of those tools. file_path is its first argument.

The patch read this file. It guarded two open() calls. There are four.

CVE-2026-27825's older, twinned advisory is GHSA-xjgw-4wvw-rgm4, reported by yotampe-pluto on 2026-02-19 and patched by sooperset on 2026-02-24 in commit 52b9b099. The patch added validate_safe_path to utils/io.py, imported it into both attachment modules, and applied it where target paths are about to receive bytes from a network response.

# src/mcp_atlassian/confluence/attachments.py: download_attachment
+            # Guard against path traversal (resolves symlinks)
+            validate_safe_path(target_path)
             logger.info(f"Downloading attachment from {url} to {target_path}")
             os.makedirs(os.path.dirname(target_path), exist_ok=True)
             response = self.confluence._session.get(url, stream=True)
             response.raise_for_status()
             with open(target_path, "wb") as f:
                 for chunk in response.iter_content(chunk_size=8192):
                     f.write(chunk)
# src/mcp_atlassian/confluence/attachments.py: download_content_attachments
+        # Guard against path traversal (resolves symlinks)
+        validate_safe_path(target_dir)

The same diff lands in jira/attachments.py, replacing an older inline is_relative_to guard on the download path with the new shared helper.

A grep open\( against confluence/attachments.py and jira/attachments.py at the head of master returns four hits:

src/mcp_atlassian/confluence/attachments.py:235  with open(target_path, "wb") as f:    # download write   guarded line 223
src/mcp_atlassian/confluence/attachments.py:477       open(file_path, "rb")            # upload   read    no guard
src/mcp_atlassian/jira/attachments.py:55         with open(target_path, "wb") as f:    # download write   guarded line 43
src/mcp_atlassian/jira/attachments.py:386        with open(file_path, "rb") as file:   # upload   read    no guard

Two wb. Two rb. The patch hardened two open() calls. There are four. The patch guards the writes. The reads are the other direction.

The patch's author imported validate_safe_path into both files in the same commit. They walked into jira/attachments.py, deleted an existing inline guard on the download path, and rewrote it to call the shared helper. They did not look thirty lines further into the same file to where upload_attachment opens whatever the caller named. They walked into confluence/attachments.py, added two guards near the top, and did not look five hundred lines further into the same file. The security utility was in their hand at both worksites. They did not bring it to the second worksite in either module.

This is the parallel-implementation-gap pattern

Astro shipped two /_image endpoints, one in the canonical assets/endpoint/generic.ts and one in the Cloudflare adapter, where the canonical imported isRemoteAllowed from assets/utils and the adapter's twelve-line proxy never did. The CVE named the adapter. The unimported gate was the difference. That is the parallel-implementation-gap shape: a capability implemented in two parallel modules, one of which imports the shared security utility and one of which doesn't, both exposing the same client-facing contract.

mcp-atlassian's variant is symmetric in a different axis. The capability is "filesystem path you got across an MCP boundary, used in an open() call." The implementation is in two parallel modules (confluence, jira). Each module has two functions that exercise the capability: one as the destination of a network response, one as the source of a network request. The shared utility validate_safe_path is imported into both modules. The patch invoked it at two of the four sites. The two it skipped were the reads.

The reads are the harder direction to write a guard for. validate_safe_path validates a path against a base directory and the upload tool's documented contract is "any absolute path on the server's filesystem." There is no obvious base directory to constrain against. A correct guard would either reject absolute paths entirely (and contradict the tool's documented behavior) or constrain reads to a configured allowlist (and require new configuration). The patch took neither route. It took the route where the writer of a network response has to land in a directory the operator chose, and stopped where the writer of a network request gets to read whatever the caller asks for.

"MCP servers are local-only" is the unenforced contract

The MCP protocol's threat model assumes the server runs on the user's machine and is invoked by an editor or shell on the same machine. Under that assumption, "the MCP tool reads any path on the local filesystem" is not a vulnerability; it is a feature. The user already has a shell. Asking the user's editor to ask the user's local MCP server to open /etc/passwd is the user opening /etc/passwd.

The streamable-http transport breaks that assumption. The server now accepts tool invocations from any client that can speak HTTP at port 8000. The default bind is every interface. The fastmcp streamable-http transport ships no auth middleware that the MCP protocol requires the client to satisfy; the only middleware that runs is the one that proxies an Atlassian token to upstream Atlassian, and that middleware does nothing about whether the inbound caller had authority to invoke the tool. Each individual tool handler then reads its arguments under the assumption that the protocol gated the caller. The protocol did not.

This is the internal-only-by-convention shape, applied to a whole protocol instead of a single header. The convention says "MCP servers are local." The deployment example in the README puts one on 0.0.0.0. The tool definitions read their arguments as if the convention held. None of those three statements is wrong on its own. Together they are CVE-2026-27825.

The CVE record names the wrong mechanism

A defender who reads CVE-2026-27825's NVD entry and never reads the code looks for an extractor, fails to find one, and ships the next release with the bug. A defender who reads the patch (commit 52b9b099, the only patch the CVE record currently points at) sees validate_safe_path calls and concludes the file is hardened. The string "zip extraction" survives as the only public artifact that says what to look for. It does not say what to look for.

The PoC author found the bug by searching for every open( call in attachments.py after reading the GHSA-xjgw-4wvw-rgm4 patch. The patch named its own gap by what it didn't touch. The CVE description renamed the gap to something the codebase has never contained. cve.org and NVD repeat the rename. Wordfence, vulncheck, and every advisory aggregator that pulls from NVD will repeat it again, because none of them clone the repo.

PoC: romain-deperne/CVE-2026-27825

The patch closed the writes. The reads are still open.