Skip to content

Security: LyoSU/capka

Security

SECURITY.md

Security Policy

Reporting a vulnerability

Please report suspected vulnerabilities privately to ua.lyo.su@gmail.com (or open a GitHub private security advisory). Do not file public issues for undisclosed vulnerabilities. We aim to acknowledge within 72 hours.

Sandbox isolation & hardening

Untrusted code runs in per-session containers locked down by a tested builder (sandbox-controller/sandbox-spec.js): never privileged, no-new-privileges, all capabilities dropped (only CHOWN/SETUID/SETGID added back for the boot sequence, plus NET_ADMIN/NET_RAW for the firewall setup when egress is on), memory / CPU / PID limits, and no network until egress is explicitly enabled (see "Network egress from sandboxes" below). The container starts as root just long enough for its entrypoint to chown the bind-mounted workspace, then drops to the unprivileged 1000:1000 user; every command the agent runs is pinned to 1000:1000, so no untrusted code ever runs as root. The controller never lets a caller specify container options — it only requests this fixed, safe shape.

Each user gets their own per-user workspace and /shared directories, and a container only ever bind-mounts the requesting user's own paths — so the shared 1000 uid never crosses the isolation boundary (separation is by container + bind mount, not by uid).

The controller reaches the Docker daemon through a socket-proxy that exposes the container and exec endpoints, the image endpoints (IMAGES=1) and read-only GET /info (INFO=1, for the gVisor runtime probe). The controller only inspects and pulls the sandbox image (to re-pull it after a prune), but the proxy cannot narrow IMAGES=1 by method: with POST=1 it allows every /images endpoint, including load, tag, push, delete and prune. Build, network, volume and swarm endpoints are denied, and the raw host socket is never mounted into the controller itself. The platform never touches the Docker socket directly.

Isolation runtime: runc by default, gVisor opt-in

The hardening above applies to both runtimes. What differs is the container↔host boundary itself, and that is the one choice worth making deliberately:

  • runc (default) — the container shares the host kernel. Every syscall the sandbox makes is served by that kernel, so a kernel bug reachable from a syscall is the escape path and there is no second wall behind it. Boots anywhere with no host setup, at native speed. The controller logs an isolation.unhardened warning at boot so the posture stays visible.
  • runsc (gVisor, opt-in) — a user-space kernel intercepts those syscalls, so untrusted code talks to gVisor instead of to the host kernel; a kernel bug now has to be reachable through that layer first. No KVM required (unlike Kata/Firecracker), so it runs on ordinary VPS hosts. Enable it with sudo sh scripts/install-gvisor.sh on the host plus SANDBOX_RUNTIME=runsc. The resulting "secure" profile is fail-closed: the controller refuses to boot if runsc isn't registered on the daemon — it never silently falls back.

What gVisor costs. None of these is a reason to skip it for untrusted code, but all of them are real and worth knowing before you flip the switch:

Cost What it means in practice
Host setup, Linux only Root install plus a Docker restart. The script also enables userns-remap, which shifts container uids on the host — a multi-tenant requirement, not an optional extra. On a host that already runs Capka, back up first (Backup & restore): Docker then keeps containers, images and volumes in a separate storage root, so the database seems to vanish until you restore it, and files under ./data keep owners the remapped containers may not be allowed to write.
Speed Syscalls are serviced in user space, so syscall-heavy work (many small file operations, spawning processes) is slower; CPU-bound work much less so.
Resource budget gVisor's own processes are charged to the sandbox's SANDBOX_PIDS_LIMIT and to the same memory cgroup as the workload. gVisor's stub processes are a high-water mark that is never reclaimed, so a long agent session spends the PID budget permanently and exhausting it kills the sandbox outright — hence the 1024 default; under ~128, image and document rendering dies with a misleading Cannot allocate memory.
Egress plumbing Under gVisor the in-container firewall needs CAP_NET_RAW and the legacy iptables backend. install-gvisor.sh registers the runtime with --net-raw=true for exactly this reason; if you ever register runsc by hand, keep that flag or every sandbox dies at startup the moment egress is on.
Upgrades Fail-closed cuts both ways: anything that de-registers runsc on the daemon stops the controller from booting rather than quietly downgrading it to runc. That is the intended behavior — treat it as a deploy-time check, not a surprise.

Choosing. Stay on runc while you know who runs code — a single operator, or colleagues you would already trust with a shell on that box; the sandbox is then protecting you from mistakes, not from people. Move to runsc once that stops being true: strangers, customers, or code an agent wrote that nobody reviews. For untrusted or multi-tenant code, combine gVisor with rootless Docker (below) — gVisor hardens the container↔host boundary, rootless decides what an escape is worth, and neither substitutes for the other.

This is defense-in-depth, not a hard boundary

A compromised controller could still create a non-privileged sandbox. The only way to make a container escape not equal host root is to run the Docker daemon rootless.

# on the host, as a non-root user
# (see https://docs.docker.com/engine/security/rootless/)
dockerd-rootless-setuptool.sh install
export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
# Point the socket-proxy bind at the rootless socket (the default is the rootful
# path, so this line is REQUIRED for rootless — otherwise sandboxes can't start):
echo "DOCKER_SOCKET=/run/user/$(id -u)/docker.sock" >> .env
sh scripts/up.sh   # the stack now drives a rootless daemon; an escape lands unprivileged

Hardening posture by deployment

Deployment Required posture
Single-operator / trusted users Default stack. Egress is off until an admin enables it in-app; set SANDBOX_ALLOW_NETWORK=false to hard-forbid it regardless of the UI.
Team, semi-trusted users Above + rootless Docker, set PUBLIC_URL, TLS in front (see Caddy profile).
Multi-tenant / internet-facing Above + rootless or gVisor (runsc) runtime, SANDBOX_ALLOW_NETWORK=false (or a vetted egress policy), external managed Postgres, regular backups.

Network egress from sandboxes

Egress is governed by two independent layers, and a sandbox reaches the internet only when both allow it:

  1. SANDBOX_ALLOW_NETWORK — the deployment-level kill-switch on the controller. When unset the controller is fail-closed (no sandbox gets network, regardless of any in-app setting). The shipped docker-compose.yml defaults it to true so an admin can turn egress on from the UI without editing .env and redeploying — it raises the ceiling, it does not by itself grant egress.
  2. sandbox_network — the org-wide default (Settings → Security → Network, "Let code reach the internet"), which is none out of the box. This is what makes a fresh deployment effectively no-outbound by default even though the kill-switch above ships open.

So the default posture is: no egress until an admin explicitly enables it in-app. For untrusted / multi-tenant hosts, set SANDBOX_ALLOW_NETWORK=false to hard-forbid egress at the controller so the in-app toggle can't open it at all. Enabling egress widens the blast radius of any sandbox compromise. Whenever egress is on, the in-container firewall additionally blocks private and cloud-metadata ranges and refuses to start if its rules can't be verified.

Restricting egress to an allowlist of hosts

SANDBOX_EGRESS_ALLOW (blank by default) narrows egress from "the public internet" to a named set of hosts. It is what closes the exfiltration path that open egress leaves: an agent that can read a token can also post it anywhere.

Set it, and a sandbox with network access is built differently — it joins the capka-sandbox-egress network, which is internal, so Docker installs no route off that bridge and no public DNS, and its own firewall permits exactly one destination: the egress-proxy service. That proxy speaks HTTP CONNECT, resolves each requested host itself, checks the resolved address, and tunnels only to what the allowlist names.

Syntax: example.com (that host, port 443), *.example.com (its subdomains, not the apex — list both if you want both), example.com:8443 (that port only). A malformed entry is dropped, and a list that parses to nothing reaches nothing.

Enabling or disabling it rebuilds sandbox containers: the network a container joins is fixed when it is created, so the change reaches a running sandbox only by replacing it. Files survive; processes inside do not.

What this does not do, stated plainly because the gaps matter more than the guarantees:

  • Plain http:// and non-HTTP protocols stop working. The proxy tunnels TLS and nothing else — no SOCKS, so git+ssh, database clients and raw sockets fail closed. Tools that ignore proxy environment variables (notably urllib3 used directly) also fail; the JVM is handled via JAVA_TOOL_OPTIONS.
  • An allowlisted host that fronts other origins allows them too. A CONNECT tunnel is opaque, so a request inside it can name a different virtual host on the same address. Nothing short of TLS interception (which would mean a CA in the container — deliberately not done) prevents this. Prefer narrow, single-purpose hostnames over a CDN apex.
  • An allowlisted host that accepts uploads is still an exfiltration route. Allowing github.com allows a gist; allowing a package registry allows a publish. The allowlist limits where data can go, not whether it can.
  • It needs the current sandbox image. The firewall lives in the image's entrypoint. The controller refreshes a registry-sourced image on every start, so an upgrade normally picks up the new rules by itself — but a deployment building from source that has not rebuilt, or one whose refresh pull failed (logged as a warning; it keeps serving the image it has), still runs the previous release's rules. CAPKA_VERSION pinned to an older tag is that older tag, deliberately.
  • One sandbox reaching another is blocked by the sandbox's own firewall, not by the network. Every gated sandbox shares one internal network, and internal isolates it from the outside world, not from its siblings — nor from the Docker host's own gateway, which is why the in-container default-deny is load-bearing rather than defence in depth. Because a rule present in the table is not necessarily a rule being applied (gVisor's netfilter is partial), the entrypoint probes its own DROP before dropping privileges and refuses to run if a connection it must not be able to make is answered. A per-session network would remove the shared bridge entirely; it is not implemented, because deleting one needs Docker API access the socket proxy deliberately withholds.
  • The proxy's address is pinned per sandbox. A firewall rule names an address, resolved when that sandbox started. The proxy therefore holds a fixed address (SANDBOX_EGRESS_PROXY_IP); if you override it, or run two Capka stacks on one host — they share the un-prefixed capka-sandbox-egress network and the capka-egress-proxy alias — expect the second stack's proxy to collide loudly rather than take over quietly.

Disk / workspace quota

Each workspace has a byte budget (MAX_WORKSPACE_MB, default 500). It is enforced at command boundaries: the controller re-measures the tree and refuses the next command with 413 WORKSPACE_FULL once it's over. A single command (or a detached background job) can therefore transiently overshoot the budget before the following command is blocked. A per-file hard cap (MAX_FILE_MB, kernel-enforced via RLIMIT_FSIZE) stops the one-shot disk bomb (fallocate -l 100G), but many-small-files growth within one command is only caught on the next check.

This poll-based quota is adequate for single-operator / trusted-user deployments. For multi-tenant or untrusted workloads, back DATA_ROOT with a filesystem that enforces a real hard limit — an XFS/ext4 project quota or a size-limited volume per deployment — so no single workspace can exhaust the host disk regardless of command timing. The app-level quota then stays as defense-in-depth on top.

Host folder mounts

The agent can work with folders that live outside its workspace: a directory on the server bind-mounted into the sandbox at /folders/<name>. This exposes the operator's own filesystem to sandboxed code, so it is gated deliberately:

  • Off by default. Two independent org settings, both off by default: host_folder_access (true/false) governs SERVER folder bind-mounts and is admin-only; pc_folder_access (off/admins/everyone) governs folders a user connects from their own computer (synced into /workspace, not a host mount). Nothing can be attached until an admin turns the relevant one on.
  • Admin-confirmed, every time. Attaching a server folder always shows a confirmation card (it is not bypassable even in autonomous mode), and is recorded in the audit log (folder.add / folder.remove).
  • Read-only by default, mounted with HostConfig.Mounts (not Binds, which would silently create a missing source as a root-owned dir) and Propagation: rprivate.
  • Validated by a denylist. The controller's mount-safety rejects system trees (/etc, /proc, /sys, /dev, /usr, /boot, /root, …) and anything that contains or sits under the data root (DATA_ROOT / HOST_DATA_ROOT) — mounting a child would leak other users' workspaces, an ancestor the whole store. All checks are directory-boundary checks (/data never matches /data-archived).
  • Optional hard perimeter. Set SANDBOX_MOUNT_ALLOW (:-separated roots) to restrict mounts to subpaths of those roots — recommended for multi-admin deployments. When unset, any path passing the denylist is allowed and the admin confirm is the final gate.

Honest limitation: the controller runs in a container and cannot resolve host-level symlinks — a host symlink under an allowed root could point elsewhere on the host. The denylist + SANDBOX_MOUNT_ALLOW + the admin confirm are the mitigations; there is no server-side realpath of arbitrary host paths.

A read-write mount must be writable by uid 1000 on the host (the entrypoint does not chown /folders/*); otherwise the agent sees EACCES. suid binaries in a mount are neutralized by no-new-privileges + exec as uid 1000. gVisor is recommended for deployments using host mounts — its default --file-access-mounts=shared is correct for folders written by both host and sandbox.

Marketplace, plugins & third-party code

Installing a plugin pulls code from a third-party repo into the capability system. The trust model:

  • Pinned to a commit. An install resolves its source ref to a concrete git commit and pulls the tree + files at that SHA — a consistent snapshot and a provenance record of exactly what was installed. A re-install re-pulls the same pinned commit; only an explicit upgrade moves the pin.
  • Code execution is disabled by default. Every stdio MCP server a plugin routes (bundled code or a bare npx/uvx that fetches a remote package) is installed off; an admin reviews and enables it in Extensions. Sandbox isolation is the containment; this is informed consent.
  • Upgrades are reviewable. An upgrade previews the file-level diff between the pinned and target commits (flagging changed connector definitions) and applies exactly the reviewed commit, so a hostile upstream can't swap in a different commit between review and apply.

This reduces — it does not eliminate — the risk of running third-party code. Treat marketplace sources as you would any dependency: install from repos you trust.

Secret handling

  • CAPKA_MASTER_KEY encrypts provider API keys at rest and lives outside the database, so a DB leak alone cannot decrypt them. 64 hex chars. In production the app is fail-closed: with no CAPKA_MASTER_KEY it still starts, but every sign-in and every use of a stored key fails (the boot log says so) rather than fall back to a DB-stored key. Set ALLOW_DB_MASTER_KEY=true to knowingly accept the insecure fallback (dev/testing). With a DB-stored key, every database dump — including each backup — also contains the key that decrypts it.
  • CONTROLLER_SECRET gates the platform↔controller channel; the controller refuses to boot on the default value in production.
  • scripts/up.sh generates strong values into .env (mode 600) on first run and never overwrites an operator-set value.

Chat-driven configuration (the manage tool)

Users and admins can change settings, connectors and skills by asking the agent (see src/lib/manage/). This is designed so the agent cannot change anything sensitive on its own — important because the agent processes untrusted content (scraped pages, uploaded files, MCP tool results) that could carry a prompt injection.

  • Role from the session, never the prompt. Every action is authorized from the signed-in identity in dispatch.ts; a non-admin cannot even see org settings (get on a hidden control returns not_found, no enumeration leak).
  • Confirmation is a real boundary, not the model's word. A risky change (any org setting, adding/removing a connector or skill) is only ever staged: the server stores the exact pending mutation (single-use, 10-minute TTL, bound to the user) and hands the model only an opaque id it cannot replay. The change applies only when the human acts on their own authenticated channel — the web Confirm button (session cookie) or a Telegram inline button (callback tied to the Telegram user). So a prompt-injected agent in an admin session can ask to disable the egress firewall, but cannot apply it; the staged change dies at its TTL unless the human clicks. Undo travels the same path.
  • Secrets never transit chat. Connectors that need an API token are configured on the settings page, not dictated to the agent; the agent only wires up OAuth (a browser sign-in handoff) and non-secret config.

Operator checks

Accounts on @telegram.local addresses

Telegram sign-in creates users with the predictable placeholder address tg<telegram id>@telegram.local. v0.42.0 and earlier did not refuse such an address on every sign-up path, so someone may have registered one before its owner signed in with Telegram; later releases refuse it everywhere but leave an existing row alone. If this install ever ran v0.42.0 or earlier, list the users on those addresses who sign in, or are linked to the bot, as anyone but the Telegram id in their address (read-only; run it in the install directory):

docker compose exec -T postgres psql -X -U Capka -d Capka <<'SQL'
SELECT u.id, u.name, u.email, u.role, u.status, u.created_at,
       CASE WHEN EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id AND a.provider_id = 'telegram'
                           AND 'tg' || a.account_id || '@telegram.local' = lower(btrim(u.email))) THEN 'shared'
            WHEN EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id AND a.provider_id <> 'telegram') THEN 'squat'
            ELSE 'moved' END AS kind,
       (SELECT string_agg(a.provider_id || ':' || a.account_id, ', ')
          FROM account a WHERE a.user_id = u.id) AS sign_in,
       (SELECT string_agg(l.telegram_user_id || coalesce(' @' || l.telegram_username, ''), ', ')
          FROM telegram_links l WHERE l.user_id = u.id) AS bot_link
FROM "user" u
WHERE lower(btrim(u.email)) LIKE '%@telegram.local'
  AND (EXISTS (SELECT 1 FROM account a WHERE a.user_id = u.id
                 AND NOT (a.provider_id = 'telegram'
                          AND 'tg' || a.account_id || '@telegram.local' = lower(btrim(u.email))))
    OR EXISTS (SELECT 1 FROM telegram_links l WHERE l.user_id = u.id
                 AND 'tg' || l.telegram_user_id || '@telegram.local' <> lower(btrim(u.email))));
SQL

No rows is the expected result: a Telegram user's only sign-in is telegram:<n>, where <n> is the number in their own tg<n>@telegram.local address. kind sorts the rows that do appear:

  • squat: a sign-in other than Telegram (a password) and no telegram:<n> for its own <n>. Someone else registered the address. In Settings → People (/settings/users) suspend that user, which signs them out everywhere, then remove them.
  • moved: only telegram: entries, or none, and none for its own <n>. Usually the owner, who disconnected Telegram on the Settings page and linked another Telegram account. A squat that linked its own Telegram and then removed its password looks the same. Removing the row deletes its chats, so do not remove it on this result alone: ask the person behind it (name, the Telegram account in bot_link) whether they first signed in with Telegram <n>.
  • shared: the real Telegram user's account, with their chats, that someone else can also sign in to; removing it deletes their data. Suspend it, then run the statements below. They delete every other sign-in and bot link, every session and pending bot link code (a suspended account can still sign in, and that session works again once it is reactivated; releases after v0.42.0 sign it out and drop its codes on reactivation themselves, so those two DELETEs matter only on older ones), switch off the account's automations and unshare its chats: a webhook URL or a share link the other person created keeps working once the account is active again. If the owner is left with no bot link, their next message to the bot restores it.
docker compose exec -T postgres psql -X -U Capka -d Capka <<'SQL'
DELETE FROM account WHERE user_id = '<id>' AND NOT (provider_id = 'telegram' AND account_id = '<n>');
DELETE FROM telegram_links WHERE user_id = '<id>' AND telegram_user_id <> <n>;
DELETE FROM link_codes WHERE user_id = '<id>';
DELETE FROM session WHERE user_id = '<id>';
UPDATE automations SET enabled = false WHERE user_id = '<id>';
UPDATE chats SET visibility = 'private', share_token = NULL WHERE user_id = '<id>' AND share_token IS NOT NULL;
SQL

Then reactivate it. Before their next chat the owner checks Settings → Extensions (skills and connectors), Memory and Providers (when shown) and removes anything they did not add: a connector receives the agent's tool calls with their data. They turn back on the automations they want, using "Replace the address" on a webhook automation, and re-share the chats they meant to share (each gets a new link).

Known limitations & residual risks

We'd rather state these plainly than imply a stronger posture than ships today. Capka is a self-hosted Docker app for solo operators and small/medium teams, with sandboxed execution and a documented hardening path for higher-trust deployments — not a turnkey-certified multi-tenant platform.

  • Long-running process required. The agent worker runs in-process inside the platform container via Next.js instrumentation. Capka must run as a long-lived process (Docker/VM); serverless/edge hosts that freeze between requests are unsupported. A separate worker service running the same code is a planned option, not a current one.
  • Realtime/queue scale boundary. The task queue and realtime bus are Postgres (SKIP LOCKED leases + LISTEN/NOTIFY). This is deliberately dependency-light and right for small/medium load; it is not an event-streaming layer (8 KB NOTIFY payload limit, single-DB fan-out). Very high concurrency needs a different bus.
  • CSP is partial. The shipped policy is the inline-safe slice (object-src, base-uri, form-action, frame-ancestors). A strict script-src without unsafe-inline (per-request nonce) is not yet enabled, so this is not full XSS containment for model/user-generated content.
  • SSRF is narrowed and connection-pinned. Outbound fetches to user-supplied URLs (MCP servers, OAuth discovery, marketplace, custom provider base URLs, and provider model listing) always block link-local/cloud-metadata ranges, strip credentials on cross-host redirects, and pin the TCP connection to the vetted IP so DNS can't rebind between the address check and the connect. Loopback and private ranges are reachable by default, so a provider on your own network (e.g. Ollama) works; an admin blocks them with Settings → Security → Network → "Block internal addresses for providers". Turn that on when people you don't fully trust can add connectors or providers. First-party fixed hosts (e.g. api.anthropic.com) use the default fetch — not a user-controlled SSRF vector. This is app-level defense-in-depth, not a substitute for network-level egress controls on an instance where reaching an internal service would be catastrophic.
  • Auth depends on better-auth. A young dependency carries its own advisory surface; keep it updated. Enterprise SSO/OIDC/SCIM is a separate commercial edition, not in this repo.
  • Docker socket is root-equivalent. Even via the socket-proxy, only rootless Docker (or gVisor) makes a container escape not equal host root — see above.
  • Audit log is best-effort. Entries are written after the action, not in the same transaction, and a write failure is logged but does not block the action. In a DB outage a critical event could go unrecorded. Treat the audit log as strong evidence, not a hard guarantee; for compliance, ship logs off-box.
  • Dependency audit has accepted residual advisories. Fixable ones are pinned via overrides (postcss, dompurify, and js-yaml under gray-matter, whose input is also bounded: SKILL.md frontmatter must be plain YAML, without anchors or aliases, and at most 8 KB). The unused /_next/image optimizer is switched off (images.unoptimized). What npm audit --omit=dev still lists is dev tooling pulled into the prod tree by better-auth's optional peer declarations — the esbuild dev-server advisory under drizzle-kit (only affects esbuild serve) and vitest (only while tests run); neither ships in the standalone runner image. The sandbox controller's one hit is uuid under dockerode, used only by BuildKit session code the controller never calls. Their npm-offered fixes are major-version changes (or, for vitest, a patch npm 10 currently fails to resolve), so they're accepted, not applied. Re-evaluate on each dependency bump.

If your threat model exceeds these boundaries, run rootless + gVisor, front the app with your own WAF/egress controls, and budget for a security review before exposing it to untrusted users.

There aren't any published security advisories