# CVE-2026-108863: Katanemo Plano through 0.4.37 Missing Authentication on Envoy Admin Interface

The whole dictionary, including the `model_providers` list and each provider `access_key`, is serialized to YAML with no redaction at `cli/planoai/config_generator.py:621-622`.
plano_config_string = yaml.dump(config_yaml)
plano_llm_config_string = yaml.dump(config_yaml)
`plano_llm_config_string` enters the Jinja render context at `cli/planoai/config_generator.py:654-656`.
"plano_llm_config": plano_llm_config_string,
The Envoy template embeds that value verbatim into the WASM LLM-gateway filter as an opaque `StringValue`, in the egress filter at `config/envoy.template.yaml:584-589` and again in the ingress filter at `config/envoy.template.yaml:181-186`.
"@type": "type.googleapis.com/google.protobuf.StringValue"
The WASM LLM gateway uses that `access_key` to authenticate outbound requests to the provider, so the real credential value has to be present in the loaded configuration for the gateway to function.
### The environment reference resolves to the real key before Envoy loads the config
The sample configuration writes each key as a bare environment reference, confirmed at `demos/getting_started/weather_forecast/config.yaml`.
`yaml.dump` preserves the literal token `$OPENAI_API_KEY` inside the embedded StringValue. The launcher runs `envsubst` over the rendered Envoy configuration before Envoy reads it, at `config/supervisord.conf:8-9`.
envsubst /app/plano_config_rendered.env_sub.yaml && \
envsubst /etc/envoy.env_sub.yaml && \
Envoy then starts against the substituted file at `config/supervisord.conf:39`.
After this substitution the real provider key is present inside the WASM StringValue of the configuration Envoy has loaded.
### Envoy does not redact the opaque WASM StringValue
Envoy's admin documentation states that before dumping the configuration Envoy attempts to redact the `private_key` and `password` fields of any certificates it finds, and that this redaction relies on the configuration being a strongly-typed protobuf message. The WASM `configuration` field is a `google.protobuf.Any` that wraps a `google.protobuf.StringValue` whose payload is free-form YAML text. Envoy does not parse or interpret that payload, so it applies no redaction to it. The serialized provider `access_key` is returned in full inside the `/config_dump` response.
participant Admin as Envoy admin listener on all interfaces
participant Wasm as WASM llm_gateway StringValue
Attacker->>Port: GET request to the config_dump path
Port->>Admin: forwarded to container port 9901
Dump->>Wasm: read loaded listener filter configuration
Wasm-->>Dump: opaque StringValue returned without redaction
Dump-->>Attacker: response body contains provider access_key values
Prerequisites. The target runs Plano started with `plano up` or the shipped development Compose file, so host port 19901 is published and reachable by the attacker. The Plano configuration declares at least one provider with an `access_key` that resolves to a real key, which is the default behavior with environment references and `envsubst`. The attacker has network reach to the host on port 19901 and needs no account and no role on Plano.
1. The operator starts Plano with `plano up`. `docker_start_plano_detached` publishes `19901:9901` as shown at `cli/planoai/docker_cli.py:74-76`, so the Envoy admin interface is reachable on host port 19901 on all host interfaces.
2. During start the configuration generator serializes the parsed configuration, including `model_providers` and each `access_key`, into `plano_llm_config` at `cli/planoai/config_generator.py:621-622`, and the template embeds it into the WASM StringValue at `config/envoy.template.yaml:584-589`.
3. The launcher runs `envsubst` over the rendered Envoy config at `config/supervisord.conf:8-9`, so a provider entry written as `access_key: $OPENAI_API_KEY` now holds the real key inside the loaded configuration, and Envoy starts against that file at `config/supervisord.conf:39`.
4. The attacker sends an unauthenticated request to the admin `config_dump` path on the published port. The following request example is illustrative and was not executed under this review.
5. Envoy returns the loaded configuration. The listener dump contains the WASM `llm_gateway` filter with the opaque StringValue. Because Envoy redacts only recognized TLS certificate fields, the StringValue payload is returned intact, including the YAML `model_providers` block and the resolved `access_key` values.
6. The attacker reads the provider API keys from the response body and can then use them directly against the provider API, outside Plano.
This finding was confirmed by static source review only. Runtime validation was not performed. No Plano instance was started, no Envoy admin endpoint was contacted, and no `/config_dump` response was captured.
Pinned source paths and lines confirmed at commit 72002a62d90ad13dd246cf3ef8d99d8c98b075ff:
- Admin bind to all interfaces with no path restriction, `config/envoy.template.yaml:1-3`.
- Default CLI port publication of the admin port, `cli/planoai/docker_cli.py:74-76`, inside `docker_start_plano_detached` defined at line 66, reached by `start_plano` in `cli/planoai/core.py`.
- Development Compose publication of the admin port, `config/docker-compose.dev.yaml:10`.
- Provider serialization into the render context, `cli/planoai/config_generator.py:588` and `:621-622` and `:654-656`.
- WASM StringValue embedding, `config/envoy.template.yaml:584-589` (egress) and `:181-186` (ingress).
- Environment substitution before Envoy load, `config/supervisord.conf:8-9` and `:39`.
- Environment-reference key form, `demos/getting_started/weather_forecast/config.yaml`.
- Envoy version pin, `Dockerfile`, `ARG ENVOY_VERSION=v1.37.0`.
The claim that `/config_dump` returns the WASM StringValue without redaction is derived from Envoy's official admin documentation applied to the confirmed source configuration. It was not observed against a live endpoint. A safe reproduction would start Plano with a synthetic key such as `OPENAI_API_KEY=sk-test-synthetic-value`, request `config_dump` once from the same network, and grep the response for the synthetic value inside the `llm_gateway` filter. That reproduction was not created or executed under this static-only workflow.
A network caller with reach to the published admin port can read the configured LLM provider API keys without any authentication. The keys grant direct access to the provider account, which can mean billed usage, quota exhaustion, and access to any data or capability the key allows at the provider.
Default `plano up` on a host with a public or shared-network interface. Docker publishes 19901 to 0.0.0.0 by default, so any caller that can route to the host on port 19901 can read the keys. This is the shipped default start behavior and does not require any operator change.
Development Compose on a developer machine or shared build host. `config/docker-compose.dev.yaml` publishes the same mapping, so a caller on the same network reaches the admin port.
Host with a firewall that blocks 19901. The admin port is not reachable from outside, so the disclosure requires local or on-host access. The bind to 0.0.0.0 still means any process or user that can reach the host loopback or an allowed interface can read the keys.
What is not established. No live `/config_dump` response was captured, so the exact response bytes are not shown here. Native mode, started by `native_runner.py`, runs Envoy on the host with the same admin bind to 0.0.0.0:9901, but native mode does not run the supervisord `envsubst` step, so whether environment-reference keys resolve to real values in native mode was not traced and is excluded from the confirmed chain. The finding does not establish remote code execution, does not establish any write to the target, and makes no claim use of the keys after disclosure beyond standard provider API access.
**Where exactly:** `config/envoy.template.yaml:1-3` and `cli/planoai/docker_cli.py:74-76`
Bind the admin listener to loopback and stop publishing the admin port by default.
- socket_address: { address: 0.0.0.0, port_value: 9901 }
+ socket_address: { address: 127.0.0.1, port_value: 9901 }
Short term. Remove the `19901:9901` publication from `docker_start_plano_detached` and from `config/docker-compose.dev.yaml`, so the admin port is not reachable from the host by default. Operators who need admin access can bind it to loopback and use an SSH tunnel.
Medium term. Bind the admin listener to `127.0.0.1` in the template so the admin interface is never exposed on an external interface even if a port is published. Keep the provider credentials out of any admin-reachable field by resolving the `access_key` only in the WASM runtime from the environment, rather than serializing the resolved value into the embedded config.
Defense in depth. Restrict the admin handlers so that `/config_dump` is not served, or place the admin interface behind a Unix domain socket that only the local supervisor can reach. Add a regression test that asserts the start path does not publish 9901 and that no provider secret appears in the rendered Envoy configuration that the admin interface can return.
Recommended fix: bind Envoy admin to `127.0.0.1`, drop the default `19901:9901` publication, and keep resolved provider keys out of any field that `/config_dump` returns.
- No public advisory at time of writing. The katanemo/plano repository has no SECURITY.md and no GitHub security policy at the pinned commit.
- Related prior art of the same class in other products: [CVE-2025-24030 / GHSA-j777-63hf-hx76]( (Envoy Gateway admin interface reachable, config dump exposed) and [CVE-2026-18673]( (Kong kuma-dp forwarded the Envoy admin API). These are separate products and separate root causes.
- Envoy admin interface documentation:
- Pinned source, admin bind:
- Pinned source, WASM StringValue (egress):
- Pinned source, WASM StringValue (ingress):
- Pinned source, CLI port publication:
- Pinned source, dev Compose publication:
- Pinned source, config serialization:
- Pinned source, envsubst and Envoy start:
- Pinned source, sample config key form:
- Pinned source, Envoy version:
Reported by hieuPenguinnn, [github.com/hieuPenguinnn](
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
