You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An external good-faith report against a toolhive-doc-mcp deployment pointed at
the telemetry label code and the proxy diagnostics endpoints. A follow-up code
assessment refuted the report's headline mechanism but confirmed several real
defects in the same area. This issue breaks the confirmed defects into
actionable steps.
Scope note. This issue tracks the code fixes only. It does not decide severity,
CVSS, or advisory-vs-issue. The reported live asset carries a build fingerprint
(0.11.0, commit 08ba97f8...) that matches no stacklok/toolhive build, so
disposition against a specific deployment is a separate question.
Correction that frames the rest
The report claimed "the metric registry grows without limit." That is false on
current main. The OpenTelemetry Go SDK caps attribute sets at 2000 per
instrument by default since sdk/metric v1.44.0. ToolHive pins v1.45.0
(go.mod), so series count is hard-capped. ToolHive crossed that line at 430b9cdc8, first released in v0.29.2. The findings below are what remains
real once that cap is accounted for.
The 2000 cap is an inherited transitive default. ToolHive sets no views, no
attribute filters, and no explicit limit of its own. A downgrade or an explicit
unlimited limit would silently remove the protection. See finding C for the
regression test.
Finding B - unbounded label value length leads to memory exhaustion
Priority: highest value per unit of effort.
The 2000 cap bounds series count. It does not bound the length of a label
value. Both the Prometheus reader and the OTLP PeriodicReader aggregate
cumulatively, so every distinct attribute set stays resident until the process
exits. There is no TTL, no LRU, and no scrape-driven reset.
The parsed MCP method is taken verbatim with no truncation
(pkg/mcp/parser.go:323, Method: req.Method). The only bound is the request
body cap (pkg/bodylimit/middleware.go:36, DefaultMaxRequestBodySize = 8 MB).
So a single label value can approach 8 MB and stay resident for the process
lifetime. Roughly 64 requests at 8 MB each retain about 512 MB permanently. That
is enough to OOM a typical pod, and it OOM-loops if the attack repeats after
restart.
Reachability. The metric is recorded after the response, so a backend 404 or
500 still records. An authz (Cedar) 403 still records, because authz sits inside
telemetry. Only an auth 401 short-circuits first, so this is reachable only on
intentionally-unauthenticated deployments (which a public docs server is).
Label sites that take client-controlled strings, pkg/telemetry/middleware.go:
:697mcp_method
:698mcp_resource_id (on the common attribute set for requestCounter
and requestDuration, so every request; set from initialize -> clientInfo.name, a valid method)
:727tool
:743mcp.method.name
:768gen_ai.tool.name
:772gen_ai.prompt.name
Steps
Add a truncateLabelValue(s string) string helper. Cap at 64 to 128 bytes.
Clamp on a UTF-8 boundary. Keep a short marker on truncation, for example a
trailing ....
Apply it to every client-controlled label value at the sites above. Do not
apply it to hardcoded values (jsonrpc.protocol.version, transport).
Keep the raw value on spans. Spans are sampled and ephemeral, so they do not
grow a registry, and the OTEL MCP semconv wants the real value.
Add a unit test that a label value over the cap is truncated, and a test that
confirms the span keeps the full value.
Acceptance
No metric label value can exceed the cap regardless of request body size.
A burst of large method values does not grow retained memory beyond the cap
times the series count.
This step does not depend on the resolved tool or prompt set, so it is
independent of the #6169 blocker. Ship it first.
Finding C - overflow lock leads to permanent observability denial
Once 1999 attribute sets exist on an instrument, every new set collapses into otel.metric.overflow=true permanently
(go.opentelemetry.io/otel/sdk/metric/internal/aggregate/limit.go). Existing
sets keep recording. New ones never get their own series again until restart.
Practical attack. About 2000 cheap junk requests early in a process life consume
all slots. The per-method and per-tool breakdown is then destroyed until
restart. Sums and counts stay correct, so dashboards keep rendering and the
failure is silent. For a security-monitoring product this defeats the exact
signal the telemetry exists to provide.
C has no independent code fix. It closes transitively once the distinct label
values are bounded to a small known set. That is finding B (truncation, which
merges near-identical junk) plus finding D (sentinel for unrecognized method,
tool, and prompt names). The standalone deliverable is proof that junk cannot
exhaust the budget.
Steps
Land B and D first.
Add a regression test. Send many requests with distinct unrecognized method,
tool, and prompt names. Assert the recognized set still gets its own series
afterward, so junk did not consume the slot budget.
Acceptance
After a burst of distinct junk requests, a subsequent recognized method still
records under its own label value, not the overflow sentinel.
Finding D - unbounded client-controlled label values (method, tool, prompt)
This is the hygiene root cause. Two parts, at different states.
Method names and HTTP method. Bounded to the semconv _OTHER sentinel by #5956 (adds IsKnownMethod, deletes the legacy mcp_method label and the toolhive_mcp_* twins). #5956 is open but currently CONFLICTING.
Tool and prompt names. Set from parsedMCP.ResourceID (params.name
verbatim) and not validated against the server resolved set. Tracked by #6169. #6169 is blocked by design, because the telemetry middleware sees a parsed
request, not the backend capability list. Bounding needs the resolved set
threaded into telemetry, or name resolution before the metric records.
No per-request metric carries a client-controlled method, tool, or prompt name
unbounded. Unrecognized values record a bounded sentinel.
Finding E - diagnostics endpoints bypass the middleware chain
/health and /metrics are registered as explicit mux paths. Go ServeMux
longest-match means they always beat the / catch-all that carries the
middleware chain. So they stay unauthenticated even on a fully OIDC-configured
deployment, and they bypass body limits, rate limiting, and audit.
pkg/vmcp/server/server.go:596-599, under the comment
"Optional Prometheus metrics endpoint (unauthenticated)"
The deployed listener is external. defaultProxyHost = "0.0.0.0"
(cmd/thv-operator/controllers/mcpserver_runconfig.go). The CLI default is 127.0.0.1 (cmd/thv/app/run_flags.go). Both endpoints are GET-only, which
limits the practical impact of the bypass. /metrics is off by default at every
layer, so this applies when metrics are enabled.
Constraint. /health must stay reachable without auth for Kubernetes liveness
and readiness probes. So the fix is not "put /health behind auth."
Steps
Bind diagnostics to a separate internal listener, on its own host and port
that deployments do not route to the internet. Move /metrics there. This is
the reporter's original recommendation and matches the sibling
registry-server pattern.
Keep /health reachable for probes, but make it minimal per finding F.
Confirm the split with a test. Assert /metrics is not served on the main
application listener.
Acceptance
/metrics is not reachable on the internet-facing listener.
/health stays reachable for probes and carries no sensitive fields.
Note. NewInternalServer does not exist in this repo today. There is one host
and port per proxy and no option to bind diagnostics separately. This step adds
that option. It is larger than a config change.
Finding F - /health discloses a build fingerprint
pkg/healthcheck/healthcheck.go:85 sets Version: versions.GetVersionInfo()
unconditionally, with no flag and no redaction. pkg/versions/version.go
supplies version, commit, build date, runtime.Version(), and GOOS/GOARCH.
The transparent proxy has no off switch for the /health path.
ToolHive already made the opposite call twice:
pkg/vmcp/server/server.go:911-919 returns only {"status":"ok"}, with a
security comment that it exposes no version information to prevent disclosure.
pkg/api/v1/healthcheck.go returns 204 No Content.
The proxies did not follow that decision. Aligning them needs no new listener
and no deployment change. Version disclosure has limited intelligence value for
an open-source project, so the strongest argument here is internal consistency.
Steps
Drop VersionInfo from the unauthenticated /health response body, or gate
it behind an explicit opt-in that defaults off.
Match the minimal shape already used by vMCP and the v1 API.
Update the healthcheck tests for the minimal body.
Acceptance
The unauthenticated /health response carries no version, commit, build date,
Go version, or platform by default.
Suggested sequencing
B (truncation). Highest value, self-contained, unblocks nothing else.
Summary
An external good-faith report against a
toolhive-doc-mcpdeployment pointed atthe telemetry label code and the proxy diagnostics endpoints. A follow-up code
assessment refuted the report's headline mechanism but confirmed several real
defects in the same area. This issue breaks the confirmed defects into
actionable steps.
Scope note. This issue tracks the code fixes only. It does not decide severity,
CVSS, or advisory-vs-issue. The reported live asset carries a build fingerprint
(
0.11.0, commit08ba97f8...) that matches nostacklok/toolhivebuild, sodisposition against a specific deployment is a separate question.
Correction that frames the rest
The report claimed "the metric registry grows without limit." That is false on
current
main. The OpenTelemetry Go SDK caps attribute sets at 2000 perinstrument by default since
sdk/metric v1.44.0. ToolHive pinsv1.45.0(
go.mod), so series count is hard-capped. ToolHive crossed that line at430b9cdc8, first released inv0.29.2. The findings below are what remainsreal once that cap is accounted for.
The 2000 cap is an inherited transitive default. ToolHive sets no views, no
attribute filters, and no explicit limit of its own. A downgrade or an explicit
unlimited limit would silently remove the protection. See finding C for the
regression test.
Finding B - unbounded label value length leads to memory exhaustion
Priority: highest value per unit of effort.
The 2000 cap bounds series count. It does not bound the length of a label
value. Both the Prometheus reader and the OTLP
PeriodicReaderaggregatecumulatively, so every distinct attribute set stays resident until the process
exits. There is no TTL, no LRU, and no scrape-driven reset.
The parsed MCP method is taken verbatim with no truncation
(
pkg/mcp/parser.go:323,Method: req.Method). The only bound is the requestbody cap (
pkg/bodylimit/middleware.go:36,DefaultMaxRequestBodySize = 8 MB).So a single label value can approach 8 MB and stay resident for the process
lifetime. Roughly 64 requests at 8 MB each retain about 512 MB permanently. That
is enough to OOM a typical pod, and it OOM-loops if the attack repeats after
restart.
Reachability. The metric is recorded after the response, so a backend 404 or
500 still records. An authz (Cedar) 403 still records, because authz sits inside
telemetry. Only an auth 401 short-circuits first, so this is reachable only on
intentionally-unauthenticated deployments (which a public docs server is).
Label sites that take client-controlled strings,
pkg/telemetry/middleware.go::697mcp_method:698mcp_resource_id(on the common attribute set forrequestCounterand
requestDuration, so every request; set frominitialize->clientInfo.name, a valid method):727tool:743mcp.method.name:768gen_ai.tool.name:772gen_ai.prompt.nameSteps
truncateLabelValue(s string) stringhelper. Cap at 64 to 128 bytes.Clamp on a UTF-8 boundary. Keep a short marker on truncation, for example a
trailing
....apply it to hardcoded values (
jsonrpc.protocol.version, transport).grow a registry, and the OTEL MCP semconv wants the real value.
confirms the span keeps the full value.
Acceptance
times the series count.
This step does not depend on the resolved tool or prompt set, so it is
independent of the #6169 blocker. Ship it first.
Finding C - overflow lock leads to permanent observability denial
Once 1999 attribute sets exist on an instrument, every new set collapses into
otel.metric.overflow=truepermanently(
go.opentelemetry.io/otel/sdk/metric/internal/aggregate/limit.go). Existingsets keep recording. New ones never get their own series again until restart.
Practical attack. About 2000 cheap junk requests early in a process life consume
all slots. The per-method and per-tool breakdown is then destroyed until
restart. Sums and counts stay correct, so dashboards keep rendering and the
failure is silent. For a security-monitoring product this defeats the exact
signal the telemetry exists to provide.
C has no independent code fix. It closes transitively once the distinct label
values are bounded to a small known set. That is finding B (truncation, which
merges near-identical junk) plus finding D (sentinel for unrecognized method,
tool, and prompt names). The standalone deliverable is proof that junk cannot
exhaust the budget.
Steps
tool, and prompt names. Assert the recognized set still gets its own series
afterward, so junk did not consume the slot budget.
Acceptance
records under its own label value, not the overflow sentinel.
Finding D - unbounded client-controlled label values (method, tool, prompt)
This is the hygiene root cause. Two parts, at different states.
Method names and HTTP method. Bounded to the semconv
_OTHERsentinel by#5956 (adds
IsKnownMethod, deletes the legacymcp_methodlabel and thetoolhive_mcp_*twins). #5956 is open but currentlyCONFLICTING.Tool and prompt names. Set from
parsedMCP.ResourceID(params.nameverbatim) and not validated against the server resolved set. Tracked by #6169.
#6169 is blocked by design, because the telemetry middleware sees a parsed
request, not the backend capability list. Bounding needs the resolved set
threaded into telemetry, or name resolution before the metric records.
Steps
resolved tool and prompt set into telemetry, or resolve the name before the
metric records, then map unresolved names to a bounded sentinel. Keep the raw
name on the span.
Acceptance
unbounded. Unrecognized values record a bounded sentinel.
Finding E - diagnostics endpoints bypass the middleware chain
/healthand/metricsare registered as explicit mux paths. GoServeMuxlongest-match means they always beat the
/catch-all that carries themiddleware chain. So they stay unauthenticated even on a fully OIDC-configured
deployment, and they bypass body limits, rate limiting, and audit.
pkg/transport/proxy/transparent/transparent_proxy.go:1316(/health),:1323(/metrics),:1341(/),:1355(Handler: mux)pkg/transport/proxy/streamable/streamable_proxy.go:271,275pkg/transport/proxy/httpsse/http_proxy.go:288,292pkg/vmcp/server/server.go:596-599, under the comment"Optional Prometheus metrics endpoint (unauthenticated)"
The deployed listener is external.
defaultProxyHost = "0.0.0.0"(
cmd/thv-operator/controllers/mcpserver_runconfig.go). The CLI default is127.0.0.1(cmd/thv/app/run_flags.go). Both endpoints are GET-only, whichlimits the practical impact of the bypass.
/metricsis off by default at everylayer, so this applies when metrics are enabled.
Constraint.
/healthmust stay reachable without auth for Kubernetes livenessand readiness probes. So the fix is not "put
/healthbehind auth."Steps
that deployments do not route to the internet. Move
/metricsthere. This isthe reporter's original recommendation and matches the sibling
registry-server pattern.
/healthreachable for probes, but make it minimal per finding F./metricsis not served on the mainapplication listener.
Acceptance
/metricsis not reachable on the internet-facing listener./healthstays reachable for probes and carries no sensitive fields.Note.
NewInternalServerdoes not exist in this repo today. There is one hostand port per proxy and no option to bind diagnostics separately. This step adds
that option. It is larger than a config change.
Finding F -
/healthdiscloses a build fingerprintpkg/healthcheck/healthcheck.go:85setsVersion: versions.GetVersionInfo()unconditionally, with no flag and no redaction.
pkg/versions/version.gosupplies version, commit, build date,
runtime.Version(), andGOOS/GOARCH.The transparent proxy has no off switch for the
/healthpath.ToolHive already made the opposite call twice:
pkg/vmcp/server/server.go:911-919returns only{"status":"ok"}, with asecurity comment that it exposes no version information to prevent disclosure.
pkg/api/v1/healthcheck.goreturns 204 No Content.The proxies did not follow that decision. Aligning them needs no new listener
and no deployment change. Version disclosure has limited intelligence value for
an open-source project, so the strongest argument here is internal consistency.
Steps
VersionInfofrom the unauthenticated/healthresponse body, or gateit behind an explicit opt-in that defaults off.
Acceptance
/healthresponse carries no version, commit, build date,Go version, or platform by default.
Suggested sequencing
/metricsexposure.Also worth adding, referenced by #6169 but currently missing: a cardinality
warning in
docs/observability.md.Related
GHSA-grwg-v9p7-76m2- unbounded request bodies exhaust memory. Same class as B.GHSA-hfrv-94x5-85p2- unauthenticated-by-default proxy design, relevant to E reachability.