Security¶
An agent is root on its keepalived host in all but name, and the server reaches every agent. This page lists what protects that chain and what you have to set up yourself.
Checklist¶
- The
admin/adminpassword is changed (Quick Start). - The server is reachable only through a reverse proxy with TLS, and that proxy is listed in
security.trusted_proxies(below). - Agents that the server dials outside a trusted network serve TLS (below).
allowedNetworksis set on agents the server dials, andenableRegisterPage: falseon agents that will not be registered through their web UI.- The agents' data volumes are readable only by root — they hold each agent's auth token.
- The agent container runs with the restrictions of the shipped
compose.yaml(below).
Sign-in¶
- Local accounts (bcrypt) and OIDC single sign-on (
oidcin the server'sconfig.yaml). - Sessions are an httpOnly cookie that expires after
jwtExpiresIn(default 12 h); the dashboard signs out when it does. - Changing a user's password or auth methods, or deleting the user, ends all of that user's sessions at once, open dashboards included.
POST /api/loginaccepts at most 10 attempts per 15 minutes per client IP.
Reverse proxy¶
The server believes X-Forwarded-For and X-Forwarded-Proto only from the proxies listed in
security.trusted_proxies (or KASM_TRUSTED_PROXIES). The list is empty by default, so the
client address is the connection's own and a caller cannot choose it by sending the header —
port 3010 can be published directly.
Behind a proxy, list it:
security:
# A proxy on the same host:
trusted_proxies: ["loopback"]
# A proxy container on a Docker network (its address is assigned by Docker):
# trusted_proxies: ["uniquelocal"]
Without the entry nothing fails outright, but three things go wrong quietly:
- Every request appears to come from the proxy. The login rate limit then counts all users
together, and
security.allowed_networksand a client's allowed address are checked against the proxy's address. - A new inbound client is restricted to the proxy's address instead of the agent's.
- The session cookies lose
Secure, because the server sees the proxy's plain-HTTP connection rather than the browser's HTTPS one.
The startup log says which proxies are trusted, or that none are.
The proxy has to pass WebSockets (/ws/dashboard, /ws/agent) and should send
X-Forwarded-Proto, so the session cookies are marked Secure.
Security Headers¶
The server sends a Content-Security-Policy and the usual hardening headers (via
@fastify/helmet). The policy allows scripts, styles and fonts only from the server itself, and WebSocket connections
to the same host. The font ships with the bundle, so no other origin is named. If a
reverse proxy injects scripts or other resources into the dashboard, those are blocked.
Strict-Transport-Security is off unless security.hsts: true is set, because many
installations run on plain HTTP. Behind TLS, either enable it here or let the reverse proxy
send it.
Agent registration¶
- A registration token is valid for one registration. The server keeps its SHA-256 hash.
- Every registration additionally needs the agent's setup PIN from its log, or its
KASM_REGISTRATION_SECRET— someone who can reach port 3011 cannot point the agent at a server of their own. See Setup PIN. - The server issues the client id and a permanent auth token. The agent authenticates with both on every connection.
Address Checks for Agent Connections¶
Three settings decide where an agent connection may come from. They answer different questions:
| Setting | Scope | Question |
|---|---|---|
security.allowed_networks (server config.yaml) |
all agents | May any agent connect from this network? |
| Allowed IP or network (client editor, per inbound client) | one client | Does this connection come from where this client is allowed to be? |
allowedNetworks (agent config.yaml) |
one agent's listener | May the server dial this agent from this network? |
An empty network list means no restriction. A newly registered inbound client starts out
restricted to the address it registered from. In the client editor that value can be widened
to a network (192.168.1.0/24), or the check can be switched off for the client — the right
choice for a host whose address its environment assigns, such as a container on a bridge
network or DHCP without a reservation. Its token is then accepted from anywhere
allowed_networks permits.
The editor knows the address of that client's last successful connect and warns when the value about to be saved would not let it back in. Without that warning the mistake surfaces only at the agent's next reconnect, with nothing but an offline client to go on.
The server checks the connection's peer — or, when that peer is listed in
security.trusted_proxies, the address the proxy forwarded. The agent checks the socket peer
(it has no trustProxy). Behind a reverse proxy, list the
proxy's address. With Docker port publishing the peer is normally the server's address, but a
userland proxy (for example Docker Desktop, or 127.0.0.1 published ports) shows up as the
bridge gateway instead — check the agent's log line denied: not in allowedNetworks for the
address that was actually seen. A wrong allowedNetworks can only be fixed on the agent host:
the connection one would fix it over is the one being refused.
TLS to an Outbound Agent¶
An outbound agent is dialled by the server, and the agent's auth token travels in the
/ws/agent query string. Over plain ws:// that token is readable by anything on the path,
which matters as soon as the agent sits somewhere the operator does not control end to end.
Two settings, one on each side:
- The agent serves TLS — a
tlsblock naming a certificate and key in itsconfig.yaml(see Serving it over TLS), or a reverse proxy terminating TLS in front of it. - The client's target address says so:
wss://host:portinstead ofhost:port, set in the client editor or when the client is added.
They have to agree. An address written wss:// against an agent serving plain HTTP fails to
connect, and so does a bare address against an agent serving TLS. Addresses stored before
this existed keep working unchanged — a bare host:port still means ws://.
By default the server verifies the agent's certificate, so a wrong or expired one is a failed connection rather than a silent one. An agent on a home network usually carries a self-signed certificate, and running a CA for a handful of hosts is more than that warrants:
It applies to every outbound agent alike and only where the address is wss:// — a plaintext
target has no certificate to check. This is the mirror image of allowSelfSignedCertificates
in the agent's own configuration, which is the same decision for the other direction of the
same link.
What is stored where¶
| Secret | Stored in |
|---|---|
| User passwords (bcrypt), registration tokens (SHA-256), inbound clients' auth tokens (SHA-256) | the server's SQLite database (server-data volume) — hashes only, the server just has to recognise the value |
| Outbound clients' auth tokens | the server's SQLite database, encrypted (AES-256-GCM) with secretKey — the server presents them when it dials, so it has to read them back |
Session signing key (jwtSecret), encryption key (secretKey), OIDC client secret |
the server's config.yaml |
| The agent's client id and auth token | identity.json in the agent's client-data volume, readable by root only |
The database and config.yaml are kept apart on purpose — a volume and a bind-mounted file —
so a copy of the volume alone contains no usable agent token. Back them up separately.
What the agent reads from keepalived is taken over field by field: auth_pass and anything
else it does not name never leave the host.
Container users¶
The server process runs as the unprivileged user node (UID 1000), not as root. The
container starts as root only for a moment: its entrypoint hands the data volume and, when
the server cannot write it, the mounted server-config.yaml to UID 1000, then drops to
that user. So an installation from an older image keeps working after an update, but
server-config.yaml on the host may afterwards belong to UID 1000. Check with
docker top kasm-server, not docker exec … id: exec starts its shell as root.
To pick the UID yourself, set user: "1234:1234" on the service. The entrypoint then
changes nothing, and the volume and the config file have to be writable by that UID —
otherwise the server stops at start-up and names the directory it cannot write.
The agent stays root on purpose; what it may do is the next section.
Agent Permissions¶
The agent runs on the keepalived host and needs exactly what reading keepalived takes — the
shipped compose.yaml grants it and nothing more:
| Setting | Why |
|---|---|
pid: host |
To find keepalived's process in /proc and send it SIGUSR1/SIGUSR2. |
cap_add: KILL |
To signal a process that runs under another user. Part of Docker's default set; listed so it stays when the defaults are tightened. |
cap_add: SYS_PTRACE |
To read the dump through /proc/<pid>/root, keepalived's own view of the file system. That is what makes a systemd PrivateTmp=true irrelevant. |
security_opt: apparmor:unconfined |
Docker's default AppArmor profile forbids reading another process's root even with the capability. Hosts without AppArmor ignore the line. |
network_mode: host |
Keeps the agent's web port under the host's address, which outbound mode dials. |
A reading that fails for lack of a permission says which one in the agent's status and in the
activity list (keepalived could not be read).
What These Permissions Amount To¶
The agent gets no Docker socket, no host file system mount and no privileged mode — and is
still root on the host in all but name. SYS_PTRACE is not limited to keepalived: it
applies to every process in the host's PID namespace. /proc/1/root is the host's whole file
system, /proc/<pid>/environ every process's environment, and without a user namespace
(which pid: host rules out) the container's root is the host's root, so root-owned host
files are readable and writable through that path. ptrace itself is allowed as well, which
means code can be injected into host processes.
Treat the agent accordingly: whoever takes it over takes over the host. What limits that is
its attack surface — set allowedNetworks, and disable the register page for an agent the
server registers (outbound). An inbound agent's register page closes once it is registered.
Hardening¶
compose.yaml takes away what the agent does not need:
| Setting | Effect |
|---|---|
cap_drop: ALL |
Leaves nothing but KILL and SYS_PTRACE. Gone are, among others, NET_RAW (forged VRRP or ARP packets on the host network), DAC_OVERRIDE (files of other users), CHOWN, SETUID and MKNOD. |
security_opt: no-new-privileges:true |
A setuid binary cannot raise privileges again. |
read_only: true, tmpfs: /tmp |
The agent writes config.yaml and its data volume, nothing else. |
client-config.yaml has to belong to root (sudo chown root: client-config.yaml).
Without DAC_OVERRIDE root in the container can write only files it owns; a file belonging
to the operator's user stays readable, but the server URL of a registration through the web
UI is not written back (Failed to save config.yaml in the agent's log). The identity itself
is not affected: it goes to identity.json in the data volume.
None of this closes the two paths above. That takes a custom seccomp profile (Docker's default
without ptrace, process_vm_readv and process_vm_writev — reading /proc/<pid>/root
needs the capability, not the syscall) or a custom AppArmor profile in place of unconfined.
The agent can also run without a container, as root, with node client/dist/index.js.