Skip to content

Security

Kaio has no authentication: whatever reaches its port controls the Docker daemon, which is root-equivalent on that host. This page is the canonical account of what that means, and every other page points here. Read it before binding Kaio anywhere but localhost.

Capability Why it matters
Deploy a stack Compose pulls and runs arbitrary images: this is remote code execution on the host, by design
Open a shell /api/containers/{id}/shell runs commands inside any container
Delete or prune Containers, images, volumes and networks, with no confirmation at the API level
Read the environment Secrets are masked in responses, but the containers running with them are reachable through the shell

The socket is mounted read-only in the reference Compose file. That limits nothing: the Docker API takes its commands over the protocol spoken on the socket, not through writes to the file. Access to the socket is equivalent to root on the host.

The server answers every route with a permissive CORS policy and asks nothing of the caller. That means any web page open in your browser can drive the API, provided your browser can reach the port.

The effective mitigations are to put authentication in front of the port, or to keep the port out of reach of a browsing session entirely. Naming your origins in KAIO_ALLOWED_ORIGINS narrows the CORS policy but does not authenticate anyone.

Setting KAIO_MCP puts the MCP endpoint on the same port as the API, and an MCP client is one more caller of a daemon that is already root-equivalent on the host. It adds no exposure that /api did not already have, and it authenticates no one either: the reverse proxy stays the layer.

What it does change is who is holding the controls. An agent decides which tool to call from text it was given, and that text may come from a container name, a log line or a compose file. Two things keep that bounded:

  • KAIO_MCP is a ladder and defaults to off. Left on read, no tool at /mcp can change anything, whatever an agent is talked into calling.
  • write adds deploys, updates, lifecycle calls and rollbacks. None of them is a delete call and every deploy is a numbered version you can roll back, but do not read that as a sandbox: kaio_stack_deploy takes an arbitrary Compose file and runs it, so a service dropped from that file is removed with --remove-orphans, and privileged: true, a host namespace or a bind mount of the Docker socket is granted as written. write is worth exactly what a call to /api is worth, which is root on the host.
  • admin is where the calls with no undo live: removing a stack, a version, a container, an image, a volume, a network, and pruning. It is the rung to grant last, and the one whose tools a client should confirm.
  • Every tool carries the annotation its rung implies, read-only, non-destructive or destructive, so a client that asks before a destructive call has something to ask about.
  • There is no shell tool, so /mcp never becomes arbitrary execution inside a container.

The endpoint has its own browser guards rather than inheriting the API’s CORS policy: unset, KAIO_MCP_ALLOWED_ORIGINS refuses every request that carries an Origin header, which closes the localhost hole above for /mcp specifically, and KAIO_MCP_ALLOWED_HOSTS refuses any Host you did not name.

Variables flagged as secret are never returned by the API and are masked in the UIs. That protects the screen, not the storage:

  • values are stored in clear text in SQLite;
  • they are written to data/stacks/<name>/.env, mode 0600;
  • every stack version snapshots them, so deleted secrets survive in the history.

Treat data/ as a secret-bearing directory: same file permissions, same backup handling, same disposal rules as a private key.

One route hands secrets back in the clear: GET /api/stacks/{name}/env/export, which exists so a server can move a stack to another server. It answers 401 unless the caller presents the cluster join token in Authorization: Bearer <token>, and it records an event against the stack each time it answers. Authorization is stripped by the node gateway, so a browser holding a token cannot reach the export through /api/nodes/{name}/proxy/{*path}. Over plain HTTP the secrets still cross the wire readable by anything on the path: put the cluster behind TLS or on a network you trust before you move a stack across it.

A server that keeps a cluster stores its join token in clear text in SQLite. Anyone who reaches that server can read it and enrol a machine into the cluster. That is no worse than what the open port already allows, since they could drive the daemon directly, but worth knowing.

A server that joined a cluster keeps that same token, in the clear, in its own SQLite, from the moment it joins until it leaves. It is what lets it recognise its control plane on the routes that ask for a credential, the environment export above being the first. So a compromised node now leaks the token of the whole cluster, where before it leaked only its own address. kaio-cli cluster leave forgets it on the spot.

kaio-cli cluster token --rotate retires a leaked one. Servers already enrolled keep running, and keep being probed, but they still hold the retired token: on those routes they will answer 401 until they rejoin.

Node rows themselves hold no credential: a name, a URL, and the result of the last probe. And the registry is the control plane’s own: /api/nodes always describes the server you are talking to, so a node cannot be used to enumerate its siblings.

A control plane also reaches its nodes on your behalf. /api/nodes/{name}/proxy forwards anything to any server in its registry, so whoever reaches the control plane’s port reaches every node in it, with the same power they would have at each node’s own port. Its exposure should be the strictest of the set, not the loosest.

The registry is what gates that route, and nothing more: removing a node closes the path through the control plane while the node’s own address stays exactly as open as before. Treat it as routing, not as access control.

A second consequence is outbound reach. Joining makes the server fetch an address the caller chose: /api/cluster/register probes the advertise it is given, and /api/cluster/join calls the control_plane_url it is given, with the result reported back. Anyone who can reach the port therefore has a way to ask the server to connect somewhere and learn whether it answered, including addresses only that server can reach. It is not an escalation over the Docker control the port already grants, but it is reach the port did not have before, and it is worth knowing if Kaio sits somewhere with a privileged view of your network.

A vulnerability scan starts a throwaway Trivy container with the Docker socket bound into it. Pin KAIO_TRIVY_IMAGE to a digest you trust rather than leaving it on a moving tag, since that image is handed the daemon.

Stack names are validated against [a-z0-9][a-z0-9_-]* before touching the filesystem, so a stack name cannot escape data/stacks/.

  1. Leave KAIO_ADDR on 127.0.0.1 unless something in front of it authenticates.
  2. Put a reverse proxy with authentication in front, and expose only that. See Reverse proxy.
  3. Never publish port 8080 to a network you do not control.
  4. Give the container its own network namespace rather than network_mode: host.
  5. Back up data/ as you would a credential store, and restrict who can read it.
  6. Pin the Trivy image.
  7. If the server pilots nodes, treat its port as the union of every node’s port: guard it at least as strictly as the strictest node behind it.
  8. Leave KAIO_MCP off unless an agent needs it, and stop at read until a write tool is something you want an agent reaching for.
  9. If KAIO_METRICS is on, set KAIO_METRICS_TOKEN. /metrics reads nothing and changes nothing, but it names every stack and image you run and counts their CVEs, which is a map of what to attack.

There is no user management, no audit of who did what, and no rate limiting. Events record that a stack was deployed, never who deployed it. If you need attribution, it has to come from the layer you put in front.

Kaio, built by Régis Gaidot