A Bare File Path Was Enough — Grafana’s mcp-k6 Returned SSH Keys From a Prompt Argument

Grafana published CVE-2026-89039 on 5 October 2026: a CVSS 6.5 arbitrary file read in mcp-k6, its Model Context Protocol server for the k6 load-testing tool. Affected versions are 0.3.0 up to but not including 0.7.0; the fix shipped in v0.7.0, released the same day at 14:54 UTC, fourteen minutes after the fix commit merged.

The mechanism is three sentences long and worth reading in full, because the interesting part is not the traversal — it is where the traversal lives. Per the vendor advisory: a caller who can invoke the convert_playwright_script prompt can pass a bare file path as the playwright_script argument and receive the contents of any file readable by the user running the server, “including SSH keys and cloud credentials in that user’s home directory.” The working-directory restriction that the server did apply to @-prefixed paths could additionally be bypassed with a symlink inside the working directory pointing outside it.

Convenience was the vulnerability

The server was trying to be helpful. convert_playwright_script takes a Playwright script and converts it to k6; the author made it accept either literal script text or a file reference, and built an explicit, deliberate syntax for the file case — the @ prefix — with a working-directory restriction attached to it. That is a reasonable design. The bug is that the server also auto-detected bare single-line arguments as file paths, and that inferred path never went through the restriction the explicit syntax had.

So the security control was correctly written and correctly scoped to a syntax that an attacker simply did not have to use. The guarded door was next to an unguarded one, and the unguarded one existed because someone wanted the prompt to “just work” when a user typed a filename. This is the same structural failure as Langflow’s MCP stdio allowlist guarding the REST model but not the execution sink — validation attached to one input path into a sink that had two.

What the fix actually changed

The remediation (PR #191, merged 14:40 UTC on 5 October, 112 added and 53 removed lines in prompts/convert_playwright_script.go, plus 326 lines of new tests) is a clean example of how to close a path-resolution hole rather than patch around it:

  • Auto-detection is gone. Single-line arguments are no longer treated as file paths. Only @-prefixed references read a file; everything else is script text. The unguarded door is removed, not guarded.
  • Resolution moved to os.Root. @ references are opened through a root anchored at the server’s working directory, so .., absolute paths outside it, and symlinks pointing outside it are rejected by the runtime rather than by string inspection. Go’s os.Root enforces containment at the syscall layer; hand-rolled prefix checks do not.
  • Extension allowlist on both ends. Only .js, .mjs, .cjs, .ts, .mts, .cts are readable, case-insensitively — and critically, the extension is checked on the requested name and on the final symlink target. Checking only the requested name is the classic way an extension allowlist gets bypassed.
  • Tilde handling tightened. Expansion now applies only to ~ and ~/ (plus ~\ on Windows); names like ~user or ~draft.js are no longer rewritten.
  • CI now runs on Windows too. The test job was extended to windows-latest — an acknowledgement that path-handling bugs are platform-specific and a Linux-only matrix will not find them.

The maintainers also documented the behaviour changes honestly: bare paths no longer read files, non-script files can no longer be referenced, and symlinks with absolute targets are rejected even when they resolve inside the working directory. Those are breaking changes for anyone who relied on the convenient behaviour, and the release notes say so instead of hiding them.

Why a 6.5 deserves attention anyway

The CVSS vector is AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N — confidentiality only, no integrity or availability impact, low privileges required. On a severity dashboard it is a medium and it scrolls past. Two things make it matter more than the number suggests.

First, the PR:L assumption. “A caller who can invoke the prompt” sounds like an authenticated user. In practice the caller is usually an LLM, and the arguments it passes are frequently derived from content the model read somewhere else. Any indirect prompt injection reaching an agent wired to mcp-k6 converts this into unauthenticated credential theft with the model as the confused deputy. mcp-k6 supports Streamable HTTP as of v0.5.0 for “remote and shared team deployments,” so the server is not necessarily a local stdio process on the operator’s laptop.

Second, the blast radius of C:H on a developer workstation. The advisory names SSH keys and cloud credentials explicitly. A load-testing MCP server runs as the engineer who runs load tests, and that account’s home directory holds ~/.ssh, ~/.aws, and ~/.config/gh. Confidentiality-only impact on that file set is a lateral-movement starting position, not an information leak.

The prompt surface is under-reviewed

Most MCP vulnerability research — including ours — has concentrated on tools: what they execute, what credentials they hold, what they return. MCP also exposes prompts and resources, and those take attacker-influenced arguments through the same channel with considerably less scrutiny. This CVE is a prompt handler. The Langflow cross-tenant disclosure published the same day (CVE-2026-105699) was a resources/read handler that authenticated the connection but never authorized the resource URI. Two primitives, same week, both outside the tool surface everyone is scanning.

It is also a second Grafana MCP advisory in under two weeks, after the off-by-one patched-version confusion around CVE-2026-15583. Grafana handled this one better: a dated vendor advisory page, an explicit fixed version, a linked fix commit, and a release the same day. That is the disclosure quality bar for MCP servers, and most projects shipping them are nowhere near it.

What to do

  • Upgrade mcp-k6 to 0.7.0 or later. Versions 0.3.0 through 0.6.1 are affected; 0.6.1 dates from 11 May 2026, so most deployments are in range.
  • Expect breakage and treat it as correct. If a workflow passed bare filenames to convert_playwright_script, it must now use the @ prefix and a script-extension file inside the working directory. Do not patch around the change.
  • Rotate anything readable by the server’s user if the endpoint was exposed. For a Streamable HTTP deployment reachable beyond localhost, assume home-directory files were readable and treat SSH keys and cloud credentials accordingly.
  • Run MCP servers as a dedicated low-privilege user. The impact here is entirely a function of whose home directory the process can read. A service account with no ~/.ssh and no cloud profile turns a CVSS 6.5 into a non-event.
  • Inventory your prompt and resource handlers, not just tools. For every MCP server you run, list which prompts and resources accept a path, URI or identifier, and check each one resolves through a containment primitive rather than a string comparison.
  • Prefer syscall-level containment over path string checks. os.Root in Go, openat2 with RESOLVE_BENEATH on Linux. Prefix matching on a cleaned path loses to a symlink every time.

Verification note: we read the Grafana Labs security advisory page for CVE-2026-89039 (published 5 October 2026; CVSS 6.5, vector CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N, fixed versions ≥ 0.7.0) and the corresponding CVE record from the MITRE CVE Services API (published 2026-10-05T14:52:41Z, assigner GRAFANA, CWE-22 and CWE-424, affected range 0.3.0 to < 0.7.0). Fix details, line counts and behaviour changes come from the grafana/mcp-k6 repository: commit a652257 and pull request #191 (merged 2026-10-05T14:40:50Z) via the GitHub API, and the v0.7.0 release (published 2026-10-05T14:54:19Z). The v0.5.0 Streamable HTTP support and v0.6.1 date come from the same repository’s release list. We did not test the vulnerability and did not contact Grafana before publication.

Sources: