On September 22, NVD published 29 CVE records for a single Python package: mcp-atlassian, the MCP bridge that lets AI agents read and write Jira and Confluence. One record is a CVSS 10.0. Thirteen of them describe the same file-read primitive from thirteen slightly different angles. Two exist because the project's February fix that claimed to stop DNS rebinding did not survive contact with urllib3.
I nearly scrolled past it as another CVE dump. What stopped me was the timeline: the fixes shipped on July 10, the records landed 74 days later, and as of this morning there was zero coverage on Hacker News and Dev.to and exactly one blog post anywhere that covers two of these CVEs. If you run this package, and, at roughly 161,000 downloads last week, plenty of agent stacks do, the interesting question is not the CVE numbers. It is why one codebase needed the same three lessons twice. Here is the trace, the four root causes I landed on, and the one version number that actually matters.
# What version is your mcp-atlassian actually running?
uvx mcp-atlassian --version
# or, if it lives in a venv:
pip show mcp-atlassian | grep -E "^(Name|Version)"
# or, if you pulled the docker image:
docker ps --format '{{.Image}}' | grep mcp-atlassian
Honesty note before the trace: everything below is derived from the NVD records, the GitHub advisories, and the release notes, pulled on September 23. I did not run the server in a lab. Treat every "what the fix does" line as advisory-derived, and re-pull the records before you act, because NVD still marks them "Received" and they move.
Why did 29 CVE records land on one Tuesday?
Because record publication and fixing are different machines. The advisories behind this batch went public on July 10, the same day as release 0.22.0, whose notes say a security audit of "37 advisories, consolidated into root-cause families" was resolved. NVD ingested them in about an hour on the night of September 22, between 18:17 and 19:17 UTC, which is 2:17 AM Singapore time where I sit. A trickle of earlier records had already appeared: two in March, one on August 12, two more on September 14.
That gap is the part worth acting on. If your patch pipeline triggers on NVD publication, 29 records arrived at once. If it triggers on advisory publication, you were 74 days late. Neither watchlist gave you the full picture; only reading the advisories against the release did.
Why does DNS rebinding keep beating this codebase?
This is the family that pulled me in, because it is the same story told twice across six months. In February, CVE-2026-27826 described how two request headers, X-Atlassian-Jira-Url and X-Atlassian-Confluence-Url, could force the server to make outbound requests to any URL an attacker chose, including the cloud metadata endpoint at 169.254.169.254 where instance credentials live. The record also notes that attacker-controlled responses get injected straight into LLM tool results, a prompt-injection channel wearing an SSRF costume. I argued earlier this month that a planted prompt is a valid credential; here is a server handing the attacker that channel by default.
The February fix, version 0.17.0, added a URL validator, and its release notes claimed, verbatim, that it "blocks private IPs, DNS rebinding, and redirect-based attacks." The claim was true for the address the validator resolved, and false for the address the HTTP client connected to, because Requests and urllib3 resolve the hostname a second time when the connection opens. A DNS rebinding server answers the first lookup with a public IP and the second with an internal one. The September records say this out loud: CVE-2026-77242, scored 7.5, describes "a short-lived DNS answer that is public during validation and private during connection, preserving unauthenticated access to internal or metadata endpoints despite the earlier CVE-2026-27826 remediation."
The three-step race in one code path
# The shape of a resolve-check-connect gap (illustrative, not project source)
ip = socket.getaddrinfo(host, 443)[0][4][0] # 1. resolve for the check
if not is_public(ip): # 2. validate that answer
raise Blocked(host)
resp = requests.get(f"https://{host}/rest/api/2/myself")
# 3. urllib3 resolves host AGAIN. A rebinding nameserver can answer
# 169.254.169.254 here, and the check never sees it.
Steps 1 and 3 are two different DNS transactions. Any validator that answers "is this host safe?" by resolving DNS separately from the connection is checking one world and connecting in another. CVE-2026-77265, scored 5.9, is the same gap reached through those request headers, and a September 14 record (CVE-2026-73497) documents the rebinding window spanning 0.17.0 through 0.22.0: no IP pinning.
What "pinning" actually changes
The 0.22.0 fix is the only pattern I know that closes this class, and the release notes describe it precisely: "a DNS-pinning transport adapter resolves each host exactly once (validate → connect on the same address), eliminating the DNS-rebinding TOCTOU against the CVE-2026-27826 fix." Resolve once, connect to that exact address, so validation and connection cannot disagree. One nuance from the same notes: your configured JIRA_URL and CONFLUENCE_URL plus the MCP_ALLOWED_URL_DOMAINS allowlist are exempt from the private-address rejection, so on-prem Data Center instances keep working; caller-supplied URLs stay guarded.
If you maintain anything that validates URLs, this is the transferable lesson from the DNS side of the house: a validator that re-resolves is decoration, and the fix is pinning, not a longer blocklist.
How does a server end up spending your credentials?
CVE-2026-77244 is the 10.0, and its description reads: "the HTTP transport accepts requests without a verified user identity and downstream fetcher construction falls back to the operator's globally configured Jira or Confluence credentials. A network client that can reach the MCP endpoint can invoke Atlassian tools as the operator, including read and write operations available to that account." Its sibling CVE-2026-77254, a 9.1, is the same shape with the caveat "unless the deployment has an independent authentication boundary."
Whether you have such a boundary depends on defaults the advisories spell out. The HTTP server binds to all interfaces by default, DEFAULT_HOST = "0.0.0.0" in servers/main.py, the CLI default host for HTTP transports is also 0.0.0.0, and the shipped Docker image publishes the port on the host. Unauthenticated reachability plus a fall-back-to-operator-credentials fetcher is the whole 10.0. The 0.22.0 fix rejects unauthenticated requests with a 401 at the transport boundary, and the credential fallback is now opt-in through ALLOW_GLOBAL_CRED_FALLBACK, default off.
This is where I want the argument, and it is the same position as a happy-path MCP demo proves almost nothing about tenant isolation: if a transport cannot tell who is calling, serving the request with the operator's credentials is a convenience that does not survive contact with a network. The counterargument is real, though. The fallback flag exists because single-operator local deployments are the common case, and forcing per-user identity on them would break the quickstart that makes the tool adoptable. An opt-in default-off fallback is a defensible middle. I still think the 10.0 score is the ecosystem telling us where that middle lands when the bind default is 0.0.0.0.
Why did the tool allowlist fail silently?
CVE-2026-77243, an 8.8, is the batch's biggest design lesson. Per the record, ENABLED_TOOLS and TOOLSETS were "applied when tools are listed but are not rechecked when a tools/call request is dispatched. A client that knows a hidden tool name can directly invoke excluded read, write, or delete tools despite the operator's configured least-privilege restrictions."
Read it again: the allowlist worked at listing time and was absent at dispatch time. A deny applied to the catalog is not a deny at the call site. I covered a cousin of this in the Cursor allowlist bypass, where the check trusted a command name instead of the file the name resolved to; here the check trusted a list instead of the dispatch path. The 0.22.0 note is the correct fix in eight words: "authorization is enforced at dispatch, not just listing." Two filter siblings in the batch repeat it elsewhere: CVE-2026-77251 bypasses the filter boundary through board APIs, and CVE-2026-77252 lets caller-supplied project and space filter values replace the operator's configured allowlists.
Why did one trusting file_path turn into 13 CVEs?
Thirteen of the 29 records share one primitive: attachment upload tools that accept a caller-controlled file_path, pass it to open(file_path, "rb"), and upload whatever they find into Confluence, where the attacker can read it back. That is arbitrary file read on the server host, and the August 12 record outside the batch, CVE-2026-73498, names the interesting targets: server environment variables, up to and including the CONFLUENCE_API_TOKEN the server itself runs on. The variants differ in reach: CVE-2026-77248 (8.6) chains it with the unauthenticated HTTP transport, CVE-2026-77246 (7.4) with attacker-supplied Atlassian headers, and the rest fill in the matrix.
The one that got my attention is CVE-2026-77271, because it is a bypass of the project's own March fix. The record says validate_safe_path "defaults its base directory to os.getcwd(), and affected Confluence attachment call sites omit base_dir, allowing attacker-selected writes within the working directory. This Python module overwrite can provide code execution when the application later imports the modified module, bypassing the remediation tracked as CVE-2026-27825." I wrote about that March bug, a download-path write that reached a shell, in my scanner post about write sinks; seeing the same codebase need a second pass for a defaulted parameter is the strongest argument I have seen for treating path confinement as an interface property, not a call-site habit.
One scoring footnote: the 8.3 on CVE-2026-77271 is CVSS 4.0 only. The record carries no CVSS 3.1 score at all. Quote the system when you quote the number.
Did the 0.22.0 wave actually close the book?
No, and this is the part I would put in front of anyone setting an upgrade target. On August 19 the project published its own advisory GHSA-5j8j-256g-vvp5: a CVSS 10.0 authentication bypass on the SSE transport, vulnerable through 0.23.0, fixed in 0.23.1. The batch's fix release was itself superseded by another 10.0 on a different transport within six weeks.
So the arc reads: 0.17.0 in February, bypassed by rebinding; 0.22.0 in July, closing the audit batch; 0.23.1 in August, closing an SSE 10.0. That is not a scandal, it is what remediation under a full audit honestly looks like, but it changes the answer to "am I fixed": the floor is 0.23.1, which is also the current PyPI release, not the 0.22.0 the September records name. The same Tuesday's NVD window carried two more of the species elsewhere: a 9.1 (CVSS 4.0) in Google's mcp-toolbox-sdk-python, a token cache not keyed by audience, fixed in toolbox-core 1.2.0 per the release notes, and a 6.3 in Kimi Code, where an untrusted .mcp.json could auto-spawn a command, fixed per the record in 0.31.1 with the exploit already public. One week, three ecosystems, one lesson: trust the thing you configured, not the request that arrives.
What should you actually do in the next 15 minutes?
Three checks, record-derived rather than lab-run, so treat the expected outputs as shapes.
Check 1, the version, is the one that decides everything else:
pip show mcp-atlassian | grep Version
# Anything below 0.23.1 means the batch is unfixed for you,
# and so is the August SSE 10.0. Upgrade target: 0.23.1 or later.
Check 2, the transport audit. When I audit my own MCP servers now, the bind address is the first question, before any tool inventory. If you run streamable-http or SSE, assume the endpoint is network-reachable until you prove otherwise, then make the post-0.22.0 behavior your expectation: unauthenticated requests get a 401 at the boundary, and ALLOW_GLOBAL_CRED_FALLBACK is unset in the server's environment. If the deployment predates the fix or you cannot upgrade today, put an authenticating reverse proxy in front and bind away from 0.0.0.0.
Check 3, enumerate the batch yourself, because 29 records are too many to trust a summary, including mine:
curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=MCP%20Atlassian&resultsPerPage=50" \
| python -c "import json,sys; d=json.load(sys.stdin); print(d['totalResults'])"
# Returned 35 when I ran it on Sep 23; one row is a LibreChat record
# that merely mentions Atlassian. Skim the descriptions for your config.
Two questions I could not settle, for the comments. First: with no identity at the transport, should operator-credential fallback ever exist, even opt-in, or should identity-missing mean connection-refused? I lean refuse, and I accept that breaks quickstarts. Second: is any URL validator that re-resolves at connect time worth shipping, or is resolve-and-pin the only acceptable pattern? The 0.17.0 notes claimed rebinding was blocked and the records disagree. Tell me where your line is, because mine moved this week.
Primary sources: NVD records CVE-2026-77244, CVE-2026-77254, CVE-2026-77243, CVE-2026-77242, CVE-2026-77265, CVE-2026-77271, CVE-2026-73497, CVE-2026-27826 · GHSA-wrhw-j3f9-8vc6 · GHSA-3r68-hf9h-887v · GHSA-vc8m-84rp-53hx · GHSA-72fm-whvq-jghf · GHSA-7r34-79r5-rcc9 · GHSA-5j8j-256g-vvp5 · v0.22.0 release notes · v0.17.0 release notes · Manifold Security on two of the CVEs (Jul 27) · pypistats for mcp-atlassian, fetched Sep 23.
Top comments (0)