Skip to content
Katanemo Plano Envoy Admin Config Secret Disclosure

Katanemo Plano Envoy Admin Config Secret Disclosure

hackmd.io • October 11, 2026

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

![status](

![CVE](

![type](

![cwe](

![component](

![severity](

**Project:** [katanemo/plano]( Plano Envoy deployment, admin interface and WASM provider configuration

**Affected:** v0.4.37 at commit 72002a62d90ad13dd246cf3ef8d99d8c98b075ff, Envoy v1.37.0 base image

**Class:** CWE-200 Exposure of Sensitive Information, CWE-522 Insufficiently Protected Credentials, CWE-306 Missing Authentication for a Critical Function

Plano runs an Envoy proxy whose admin listener binds to all interfaces on port 9901 with no path restriction and no authentication. The default `plano up` Docker start path hardcodes a host port publication of 19901 to container 9901, and the shipped development Compose file publishes the same mapping. The Plano configuration generator serializes the entire parsed user configuration, including every provider `access_key`, into a value that the Envoy template embeds inside a WASM filter as an opaque `google.protobuf.StringValue`. The launcher runs `envsubst` over the rendered Envoy config before Envoy loads it, so environment-reference keys such as `$OPENAI_API_KEY` become the real secret inside that embedded value. Envoy redacts only the `private_key` and `password` fields of TLS certificates it recognizes as typed protobuf messages, and it does not parse or redact the opaque WASM StringValue. A network caller that reaches the published admin port can request `/config_dump` and read the provider API keys. The trust boundary crossed is an unauthenticated network caller reaching administrative configuration that holds live credentials. The concrete impact is disclosure of configured LLM provider API keys to any caller with network reach to the host port.

The finding is a chain of four facts. The admin surface is unauthenticated and bound to all interfaces, the default start path publishes it to the host, the provider keys are serialized into a config field that the admin surface returns, and Envoy does not redact that field.

### The admin listener binds to all interfaces with no restriction

The Envoy template defines the admin block at the top of the file, at `config/envoy.template.yaml:1-3`.

socket_address: { address: 0.0.0.0, port_value: 9901 }

The bind address is `0.0.0.0`, which accepts connections on every interface of the container or process. The template contains no `allow_paths` entry and no admin handler restriction anywhere. Envoy's admin interface has no authentication of its own, and the `/config_dump` endpoint is enabled by default. The reviewed deployment uses Envoy v1.37.0, set in the `Dockerfile` with `ARG ENVOY_VERSION=v1.37.0`.

### The default start path publishes the admin port to the host

The primary way to start Plano is the `plano up` command, which starts the container through `docker_start_plano_detached`. That function hardcodes the admin port publication at `cli/planoai/docker_cli.py:74-76`.

The `19901:9901` entry publishes container port 9901, the Envoy admin port, to host port 19901. The entry is a fixed element of the list. It sits inside no development or debug conditional, so every normal `plano up` invocation publishes it. A `docker run -p 19901:9901` binds to `0.0.0.0:19901` on the host under Docker defaults, so the admin interface becomes reachable on every host interface. `start_plano` in `cli/planoai/core.py` calls `docker_start_plano_detached` on the normal start path with no gating flag. The shipped development Compose file repeats the same publication at `config/docker-compose.dev.yaml:10`.

### The provider keys are serialized into the WASM filter configuration

The configuration generator processes the user providers and copies them back into the configuration dictionary at `cli/planoai/config_generator.py:588`.

config_yaml["model_providers"] = deepcopy(updated_model_providers)

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](