github.com/nezhahq/nezha: MCP fs.download_url/fs.upload_url URL built from unvalidated Host header leaks transfer token
What fixes it
No source in this snapshot names a fixed version.
Description
Maintainer resolution
Fixed in v2.3.15 by commit 36b742a7. Affected versions: >= 2.1.0, < 2.3.15. Patched versions: >= 2.3.15.
Summary
The MCP fs.download_url and fs.upload_url tools construct the returned transfer URL by concatenating the raw HTTP Host header of the caller with a fixed path, without consulting the operator-declared dashboard host allowlist. On a deployment that has enabled MCP (enable_mcp: true), a network-positioned attacker who can make a legitimate PAT-authenticated MCP client reach POST /mcp with a Host header the attacker chose (through a reverse proxy that forwards the client-supplied Host, through DNS rebinding, or by directing the client at an attacker-owned hostname that then resolves to the dashboard) receives a JSON tool response whose url points at the attacker's origin. The MCP client follows that URL as part of the protocol: on the upload path, the file body is delivered to the attacker's server; on the download path, the one-time transfer token is delivered to the attacker's server, and, until it expires (default 300 s, maximum 600 s), can be replayed against the real dashboard to exfiltrate the file the victim intended to fetch. The same host-header trust pattern was hardened in getRedirectURL by the fix for GHSA-9rc6-8cjv-rcvx / CVE-2026-53523; the MCP transfer path shipped in the same release line and never received the equivalent validation.
Root Cause
mintTransferTool in cmd/dashboard/controller/mcp_transfer.go builds the transfer URL returned to the MCP client from the raw request Host header, with no allowlist check and no fallback to the operator-configured dashboard host. The same source file assembles the scheme from Request.TLS and the X-Forwarded-Proto header, but at no point does it constrain which host may appear in the returned URL.
// cmd/dashboard/controller/mcp_transfer.go (lines 351-356)
scheme := "https"
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
scheme = "http"
}
host := c.Request.Host
url := fmt.Sprintf("%s://%s/mcp/%s/%s", scheme, host, dir, t)The equivalent construction in getRedirectURL was hardened by the fix for GHSA-9rc6-8cjv-rcvx to use the operator-declared reserved-dashboard-host allowlist and fall back to singleton.Conf.DashboardHost:
// cmd/dashboard/controller/oauth2.go (lines 40-44)
host := c.Request.Host
if !singleton.IsReservedDashboardHost(host) && singleton.Conf != nil && singleton.Conf.DashboardHost != "" {
host = singleton.Conf.DashboardHost
}
return scheme + host + "/api/v1/oauth2/callback"mintTransferTool never invokes singleton.IsReservedDashboardHost or reads singleton.Conf.DashboardHost; a repository-wide search confirms IsReservedDashboardHost is called only from cmd/dashboard/controller/oauth2.go, cmd/dashboard/controller/nat.go, and service/singleton/nat.go. The MCP file-transfer path shipped in the same 2.x release line as the OAuth2 fix but was overlooked when the invariant was applied.
The trust invariant that has been violated is that a URL the dashboard tells its own clients to follow, and that carries a security-sensitive one-time token, must be pinned to a host the operator has declared as authoritative for this deployment. Deriving that host from c.Request.Host reintroduces the same input-trust bug that GHSA-9rc6-8cjv-rcvx (CVE-2026-53523) documented in getRedirectURL.
Impact
Prerequisites for exploitation:
- The dashboard has
enable_mcp: true(default isfalse). - The victim runs a legitimate PAT-authenticated MCP client that talks to the dashboard's
POST /mcpendpoint and invokesfs.download_urlorfs.upload_url. - The attacker can cause the victim's client request to reach
mintTransferToolwith an attacker-chosenHostheader. Concrete paths observed while reviewing this code base: a reverse proxy in front of the dashboard that forwards the client-suppliedHostverbatim (the default withnginx proxy_passand unsetHost); DNS rebinding of an attacker-controlled hostname to the dashboard's address at the moment the MCP client connects; or the victim being induced to point their MCP client at an attacker-owned hostname that resolves to the dashboard.
Demonstrated capability:
- Upload path (
fs.upload_url): the MCP clientPOSTs the file body directly to the attacker's origin. The attacker receives the file content the victim intended to store on the agent. - Download path (
fs.download_url): the MCP clientGETs the URL, sending the one-time transfer token as the last path segment to the attacker's origin. The token is a bearer credential:transferDownloadHandlervalidates the token but not the requester, so the attacker replays it against the real dashboard within the TTL (default 300 s, capped at 600 s) and receives the file the victim requested.
Plausible consequence: on the download path, the fetched content is served by the attacker, so an MCP client that treats the download as trusted (for example a local LLM agent that indexes or executes fetched content) can be fed attacker-controlled content instead of the file it asked for.
Scope: each exploited transfer yields at most one file's worth of content or one replayable token. There is no direct escalation to arbitrary file read, remote code execution, or account takeover; the impact is bounded by which file the victim's MCP client asked to move at the moment the Host was influenced.
Proof of Concept
Prerequisites: a running Nezha dashboard with enable_mcp: true, a Personal Access Token whose scopes cover the target server (nezha:server:read for the download tool, nezha:server:write for the upload tool), and network positioning that lets the attacker choose the Host header the dashboard sees on the victim's request. The reproduction below uses curl from the attacker's own host, which is sufficient to demonstrate the URL-construction defect; a full end-to-end exploit additionally requires that the victim's MCP client be the one whose Host is influenced (see the Impact and Attack Chain sections for the observed positioning).
# Reproduce the URL-construction defect against the dashboard.
# The attacker sets Host to any value; the dashboard trusts it verbatim.
DASH_IP="10.0.0.10:8008" # actual dashboard listen address
FAKE_HOST="attacker.example" # attacker-chosen Host
PAT="$NEZHA_PAT" # Personal Access Token with nezha:server:read
curl -sS "http://${DASH_IP}/mcp" \
-H "Host: ${FAKE_HOST}" \
-H "X-Forwarded-Proto: https" \
-H "Authorization: Bearer ${PAT}" \
-H 'Content-Type: application/json' \
--data '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "fs.download_url",
"arguments": {"server_id": 1, "path": "/etc/hostname", "ttl_seconds": 300}
}
}'{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"{\"url\":\"https://attacker.example/mcp/download/<opaque-token>\",\"method\":\"GET\",\"expires_at\":\"...\"}"}]}}The returned URL contains attacker.example, a host the operator never declared. An MCP client receiving this response as part of the fs.download_url protocol follows the URL and sends the opaque token to attacker.example, from which the attacker can replay it against http://${DASH_IP}/mcp/download/<opaque-token> inside the TTL window and receive the file contents. The same request shape with fs.upload_url and a matching write-scoped PAT causes the client to POST the file body to attacker.example directly.
Attack Chain
- Exposure: The dashboard has enabled MCP (
enable_mcp: true), which registersPOST /mcpincmd/dashboard/rpc/rpc.go. The endpoint is reachable to any network-positioned actor who can complete a TCP connection to the dashboard listener. - Control: The attacker chooses the HTTP
Hostheader on the request the victim's MCP client issues, either by sitting behind a reverse proxy that forwards the client-suppliedHostto the dashboard backend, by DNS-rebinding an attacker-controlled name that the victim's client is configured to use, or by inducing the victim's client to send requests to an attacker-owned hostname that resolves to the dashboard. - Path: The request passes
mcpOriginGuardincmd/dashboard/controller/mcp_origin.go, which returns early (c.Next()) whenever theOriginheader is empty; non-browser MCP clients do not sendOrigin.apiTokenAuthMiddlewarethen validates the PAT (which is the victim's own token) and stores the authorized user in the gin context. The rate-limit middleware and JSON-RPC dispatcher forward the call intohandleFsDownloadURLorhandleFsUploadURLat lines 282-303, which callmintTransferTool(c, ...)at line 287 or line 300. - Guard:
mintTransferToolat lines 307-357 enforcesrequireServerAccess,requireAgentSupportsMCP,validateTransferPath, TTL clamping, and PAT presence, but none of these inspect or normalizec.Request.Host.singleton.IsReservedDashboardHostandsingleton.Conf.DashboardHostare never referenced in this file, so the allowlist and fallback that protectgetRedirectURLhave no counterpart here. - Primitive: At lines 351-356, the function mints a one-time transfer token, then interpolates the raw
c.Request.Hostinto the URL returned in the tool response. The returned map'surlfield carries the attacker-chosen host verbatim. - Result: The MCP client acts on the tool response as part of the
fs.download_url/fs.upload_urlprotocol: upload delivers the file body to the attacker's origin; download delivers the transfer token, whichtransferDownloadHandleraccepts from any client until it expires, letting the attacker replay it against the real dashboard to fetch the file the victim asked for.
Bypass Evidence
The published advisory GHSA-9rc6-8cjv-rcvx (CVE-2026-53523) documents the same host-header trust class and remediates it in getRedirectURL, but its scope is limited to cmd/dashboard/controller/oauth2.go. The commit added singleton.IsReservedDashboardHost and the singleton.Conf.DashboardHost fallback only inside getRedirectURL; the identical construction inside mintTransferTool in cmd/dashboard/controller/mcp_transfer.go was not modified. Running grep -rn IsReservedDashboardHost cmd service on the repository at tag v2.3.14 confirms the allowlist is invoked from cmd/dashboard/controller/oauth2.go, cmd/dashboard/controller/nat.go, and service/singleton/nat.go only. mcp_transfer.go never appears.
Attempts made to invalidate the finding:
- Checked whether
mcpOriginGuardincmd/dashboard/controller/mcp_origin.goblocks Host-mismatched requests. It short-circuits withc.Next()whenOriginis absent (lines 17-20); MCP clients that are not browsers do not sendOrigin. - Checked whether
apiTokenAuthMiddleware, the rate-limit middleware,requireServerAccess,requireAgentSupportsMCP, orvalidateTransferPathinspectsc.Request.Host. None of them do; they validate the PAT, the server ownership, the agent capability, and the requested path only. - Checked whether
transferDownloadHandlerortransferUploadHandlerbinds the token to a specific requesting client (address, TLS identity, second header). They validate the opaque token and its embedded content (entry.ServerID,entry.Path,entry.TokenID,entry.ExpiresAt) and treat it as a bearer credential. - Checked whether any deployment default prevents
c.Request.Hostfrom being attacker-influenced.mcpOriginGuardnarrows the strict-loopback rebinding case, but only whenListenHostis explicitly a loopback literal (127.0.0.1,::1, orlocalhost). The common deployments (0.0.0.0, empty, public IP, or hostname) do not activate that narrower check. - Checked whether
singleton.Conf.DashboardHostis consulted anywhere inmcp_transfer.goat v2.3.14. It is not.
No guard between the network attacker's positioning and the sink at mintTransferTool line 356 constrains the Host header. The fix for GHSA-9rc6-8cjv-rcvx does not extend to this call site, and no later commit adds equivalent validation on the branch under review.
Affected Versions
- Ecosystem: go
- Package: github.com/nezhahq/nezha
- Confirmed affected range:
>= 2.2.11, <= 2.3.14 - Latest release checked: git tag
v2.3.14(commitc726930833937a0aebb2a924064f236faea889f6); GitHub releases and git tags are the canonical release channel for this project - Fix status: not fixed
The file cmd/dashboard/controller/mcp_transfer.go was introduced by commit 11eb339, first tagged as v2.2.11; the previous tag v2.0.13 does not contain the file. git show v2.2.11:cmd/dashboard/controller/mcp_transfer.go and git show v2.3.14:cmd/dashboard/controller/mcp_transfer.go both show the same unvalidated host := c.Request.Host construction at what is line 355 in v2.3.14. No intermediate tag (v2.3.0 through v2.3.13) contains a fix.
The Go module path declared in go.mod is github.com/nezhahq/nezha (no /v2 major-version suffix), so Go proxy resolution of 2.x releases uses the +incompatible convention and the go-proxy @latest endpoint reports an older tag as its declared latest; the git tag v2.3.14 is the authoritative shipped release. There is no unreleased fix on the default branch: at commit c726930833937a0aebb2a924064f236faea889f6 the vulnerable construction is still in place.
Suggested Fix
Enforce the same trust invariant on mintTransferTool that GHSA-9rc6-8cjv-rcvx established on getRedirectURL: the host embedded in the MCP transfer URL must be an operator-declared authoritative dashboard host, or the operator-configured DashboardHost, rather than the raw request Host header. In cmd/dashboard/controller/mcp_transfer.go, immediately after reading c.Request.Host at line 355, check the value against singleton.IsReservedDashboardHost and fall back to singleton.Conf.DashboardHost when the request host is not in the allowlist, mirroring the pattern already used at cmd/dashboard/controller/oauth2.go lines 40-44. Applying the invariant at the URL-construction site (rather than at each caller) keeps every current and future MCP tool that mints a callback URL covered by one check.
The remediation should also verify that no other MCP tool response builds a URL from c.Request.Host or c.Request.Header.Get("X-Forwarded-Host") without the same allowlist check, and that any future call sites (for example new fs.* tools) are covered by a shared helper rather than by copying the current inline construction.
Interim mitigations for operators who cannot deploy a code change:
- Keep
enable_mcp: false(the default) until a fixed release is available; the vulnerable code path is unreachable without it. - If MCP must remain enabled, terminate all inbound traffic at a reverse proxy that rewrites
Hostto a fixed operator-controlled value before it reaches the dashboard, and reject requests whose originalHostis not in an operator-maintained allowlist. Also strip or overrideX-Forwarded-Protoat that boundary so that the scheme in the returned URL is deterministic. - Bind the dashboard listener to a loopback address (
ListenHost: 127.0.0.1orlocalhost) when running behind a same-host reverse proxy; this activates the DNS-rebinding branch inmcpOriginGuardfor browser-originated requests, though it does not by itself constrain theHostheader on non-browser MCP client requests.
---
Reported by zx (GitHub: @manus-pi).
Text from GitHub Advisory Database
Affected versions 1
- github.com/nezhahq/nezha, go>= 2.1.0, < 2.3.15GitHub