{"schema":"threatline.record/2","record":{"id":"GHSA-rj3w-jrj7-x7c3","aliases":["GHSA-rj3w-jrj7-x7c3"],"summary":"MCP fs.download_url/fs.upload_url URL built from unvalidated Host header leaks transfer token","details":"## Maintainer resolution\n\nFixed in [v2.3.15](https://github.com/nezhahq/nezha/releases/tag/v2.3.15) by commit [36b742a7](https://github.com/nezhahq/nezha/commit/36b742a7e9a81ed782363dd19efd77b7030fbd0f). Affected versions: `>= 2.1.0, < 2.3.15`. Patched versions: `>= 2.3.15`.\n\n## Summary\n\nThe 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.\n\n## Root Cause\n\n`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.\n\n```go\n// cmd/dashboard/controller/mcp_transfer.go (lines 351-356)\nscheme := \"https\"\nif c.Request.TLS == nil && c.Request.Header.Get(\"X-Forwarded-Proto\") != \"https\" {\n    scheme = \"http\"\n}\nhost := c.Request.Host\nurl := fmt.Sprintf(\"%s://%s/mcp/%s/%s\", scheme, host, dir, t)\n```\n\nThe 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`:\n\n```go\n// cmd/dashboard/controller/oauth2.go (lines 40-44)\nhost := c.Request.Host\nif !singleton.IsReservedDashboardHost(host) && singleton.Conf != nil && singleton.Conf.DashboardHost != \"\" {\n    host = singleton.Conf.DashboardHost\n}\nreturn scheme + host + \"/api/v1/oauth2/callback\"\n```\n\n`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.\n\nThe 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`.\n\n## Impact\n\nPrerequisites for exploitation:\n\n- The dashboard has `enable_mcp: true` (default is `false`).\n- The victim runs a legitimate PAT-authenticated MCP client that talks to the dashboard's `POST /mcp` endpoint and invokes `fs.download_url` or `fs.upload_url`.\n- The attacker can cause the victim's client request to reach `mintTransferTool` with an attacker-chosen `Host` header. Concrete paths observed while reviewing this code base: a reverse proxy in front of the dashboard that forwards the client-supplied `Host` verbatim (the default with `nginx proxy_pass` and unset `Host`); 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.\n\nDemonstrated capability:\n\n- Upload path (`fs.upload_url`): the MCP client `POST`s the file body directly to the attacker's origin. The attacker receives the file content the victim intended to store on the agent.\n- Download path (`fs.download_url`): the MCP client `GET`s the URL, sending the one-time transfer token as the last path segment to the attacker's origin. The token is a bearer credential: `transferDownloadHandler` validates 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.\n\nPlausible 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.\n\nScope: 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.\n\n## Proof of Concept\n\nPrerequisites: 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).\n\n```bash\n# Reproduce the URL-construction defect against the dashboard.\n# The attacker sets Host to any value; the dashboard trusts it verbatim.\n\nDASH_IP=\"10.0.0.10:8008\"          # actual dashboard listen address\nFAKE_HOST=\"attacker.example\"      # attacker-chosen Host\nPAT=\"$NEZHA_PAT\"                  # Personal Access Token with nezha:server:read\n\ncurl -sS \"http://${DASH_IP}/mcp\" \\\n  -H \"Host: ${FAKE_HOST}\" \\\n  -H \"X-Forwarded-Proto: https\" \\\n  -H \"Authorization: Bearer ${PAT}\" \\\n  -H 'Content-Type: application/json' \\\n  --data '{\n    \"jsonrpc\": \"2.0\",\n    \"id\": 1,\n    \"method\": \"tools/call\",\n    \"params\": {\n      \"name\": \"fs.download_url\",\n      \"arguments\": {\"server_id\": 1, \"path\": \"/etc/hostname\", \"ttl_seconds\": 300}\n    }\n  }'\n```\n\n```text\n{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"url\\\":\\\"https://attacker.example/mcp/download/<opaque-token>\\\",\\\"method\\\":\\\"GET\\\",\\\"expires_at\\\":\\\"...\\\"}\"}]}}\n```\n\nThe 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.\n\n## Attack Chain\n\n1. **Exposure:** The dashboard has enabled MCP (`enable_mcp: true`), which registers `POST /mcp` in `cmd/dashboard/rpc/rpc.go`. The endpoint is reachable to any network-positioned actor who can complete a TCP connection to the dashboard listener.\n2. **Control:** The attacker chooses the HTTP `Host` header on the request the victim's MCP client issues, either by sitting behind a reverse proxy that forwards the client-supplied `Host` to 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.\n3. **Path:** The request passes `mcpOriginGuard` in `cmd/dashboard/controller/mcp_origin.go`, which returns early (`c.Next()`) whenever the `Origin` header is empty; non-browser MCP clients do not send `Origin`. `apiTokenAuthMiddleware` then 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 into `handleFsDownloadURL` or `handleFsUploadURL` at lines 282-303, which call `mintTransferTool(c, ...)` at line 287 or line 300.\n4. **Guard:** `mintTransferTool` at lines 307-357 enforces `requireServerAccess`, `requireAgentSupportsMCP`, `validateTransferPath`, TTL clamping, and PAT presence, but none of these inspect or normalize `c.Request.Host`. `singleton.IsReservedDashboardHost` and `singleton.Conf.DashboardHost` are never referenced in this file, so the allowlist and fallback that protect `getRedirectURL` have no counterpart here.\n5. **Primitive:** At lines 351-356, the function mints a one-time transfer token, then interpolates the raw `c.Request.Host` into the URL returned in the tool response. The returned map's `url` field carries the attacker-chosen host verbatim.\n6. **Result:** The MCP client acts on the tool response as part of the `fs.download_url` / `fs.upload_url` protocol: upload delivers the file body to the attacker's origin; download delivers the transfer token, which `transferDownloadHandler` accepts from any client until it expires, letting the attacker replay it against the real dashboard to fetch the file the victim asked for.\n\n## Bypass Evidence\n\nThe 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.\n\nAttempts made to invalidate the finding:\n\n- Checked whether `mcpOriginGuard` in `cmd/dashboard/controller/mcp_origin.go` blocks Host-mismatched requests. It short-circuits with `c.Next()` when `Origin` is absent (lines 17-20); MCP clients that are not browsers do not send `Origin`.\n- Checked whether `apiTokenAuthMiddleware`, the rate-limit middleware, `requireServerAccess`, `requireAgentSupportsMCP`, or `validateTransferPath` inspects `c.Request.Host`. None of them do; they validate the PAT, the server ownership, the agent capability, and the requested path only.\n- Checked whether `transferDownloadHandler` or `transferUploadHandler` binds 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.\n- Checked whether any deployment default prevents `c.Request.Host` from being attacker-influenced. `mcpOriginGuard` narrows the strict-loopback rebinding case, but only when `ListenHost` is explicitly a loopback literal (`127.0.0.1`, `::1`, or `localhost`). The common deployments (`0.0.0.0`, empty, public IP, or hostname) do not activate that narrower check.\n- Checked whether `singleton.Conf.DashboardHost` is consulted anywhere in `mcp_transfer.go` at v2.3.14. It is not.\n\nNo 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.\n\n## Affected Versions\n\n- Ecosystem: go\n- Package: github.com/nezhahq/nezha\n- Confirmed affected range: `>= 2.2.11, <= 2.3.14`\n- Latest release checked: git tag `v2.3.14` (commit `c726930833937a0aebb2a924064f236faea889f6`); GitHub releases and git tags are the canonical release channel for this project\n- Fix status: not fixed\n\nThe 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.\n\nThe 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.\n\n## Suggested Fix\n\nEnforce 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.\n\nThe 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.\n\nInterim mitigations for operators who cannot deploy a code change:\n\n- Keep `enable_mcp: false` (the default) until a fixed release is available; the vulnerable code path is unreachable without it.\n- If MCP must remain enabled, terminate all inbound traffic at a reverse proxy that rewrites `Host` to a fixed operator-controlled value before it reaches the dashboard, and reject requests whose original `Host` is not in an operator-maintained allowlist. Also strip or override `X-Forwarded-Proto` at that boundary so that the scheme in the returned URL is deterministic.\n- Bind the dashboard listener to a loopback address (`ListenHost: 127.0.0.1` or `localhost`) when running behind a same-host reverse proxy; this activates the DNS-rebinding branch in `mcpOriginGuard` for browser-originated requests, though it does not by itself constrain the `Host` header on non-browser MCP client requests.\n\n---\n\nReported by zx (GitHub: @manus-pi).\n","categories":["Unclassified"],"product":"github.com/nezhahq/nezha","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-rj3w-jrj7-x7c3","source_modified_at":"2026-10-01T04:43:44Z","fetched_at":"2026-10-26T00:08:39.284423Z","package":{"ecosystem":"go","name":"github.com/nezhahq/nezha"},"range":">= 2.1.0, < 2.3.15"}],"fixed_versions":[],"cvss":4.8,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-01T04:42:20Z","modified_at":"2026-10-01T04:43:44Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/nezhahq/nezha/security/advisories/GHSA-rj3w-jrj7-x7c3","fetched_at":"2026-10-26T00:08:39.284423Z","source_modified_at":"2026-10-01T04:43:44Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-01T04:43:44Z","content_hash":"8a921aed58426cfc993cf4393bf66d1cc63f2b7d09c115321ac735a486d5859c"}}
