Sthora
Reference

Security model

What the platform trusts, what it refuses, what it stores and how, and the things it deliberately does not claim to do.

Start here

The control plane holds the Docker socket

Anyone with admin access to the portal has root on the server. Every control below exists because that access has to be narrowed by the platform rather than trusted.

That is not a flaw to be fixed by a permission model. Creating a container with a volume mount is sufficient to mount the host filesystem and read anything on the box, and creating containers is the product.

What follows from it: give portal accounts only to people you would give root to, keep signup off on a self-hosted box, and turn on two-factor everywhere.

The three boundaries with regression suites

BoundaryFileWhat it stops
Compose sanitisercompose-sanitiser.spec.tsA user's file granting itself host access, or claiming a hostname
Archive extractionarchive-entry-path.spec.tsZip-slip and path traversal
Proxy configurationproxy.service.spec.tsThe Traefik dashboard being exposed, and the socket being mounted writable

Each of these suites exists to stay unbroken. A case is never deleted to make a change pass.

Authentication

PasswordsArgon2id verifiers stored on the account record, never on the user. Parameters live in exactly one file and are re-applied on sign-in.
SessionsOpaque random tokens, stored hashed. A database leak yields no usable session.
Session lifetimeAUTH_SESSION_TTL_HOURS (720). Renewal slides the expiry forward, throttled to once a day.
CookieAUTH_COOKIE_NAME, with the __Host- prefix applied automatically in production.
Two-factorHand-rolled RFC 6238 TOTP, verified against the published test vectors.
API tokensHMAC digests under AUTH_SECRET, resolving into the same session shape — no parallel authorization path.
CSRFAUTH_TRUSTED_ORIGINS is the allow-list of origins whose unsafe-method requests the API will act on. Separate from CORS on purpose: CORS governs what the browser will read, this governs what the API will act on.

Secrets at rest

AES-256-GCM, keyed from SECRET_ENCRYPTION_KEY_FILE (preferred) or SECRET_ENCRYPTION_KEY. Stored form is v1.<nonce>.<tag>.<ciphertext>, base64url.

Encrypted this way: environment variables, deploy-key private halves, webhook secrets, managed service credentials, S3 backup destinations, and TOTP seeds.

Hashed rather than encrypted, because they never need to be readable: passwords, sessions, API tokens, one-time tokens, and two-factor recovery codes.

Write-only by contract

Several response shapes have no field that could carry a secret. That is the enforcement — not a service that has to remember to strip one.

  • EnvironmentVariableData has valueLength, never value.
  • ApiTokenData has prefix, never the issued token.
  • InvitationData has no token field.
  • ManagedServiceData has no credential field; connection details are a separate, audited read.
  • GET /git-source returns the deploy key's public half only.

The network

  • Inbound 22, 80, 443 only, configured by the installer.
  • Postgres and Redis publish no host ports.
  • Managed services publish no host ports.
  • Applications publish nothing once they have a verified hostname; before that, a numbered port in 30000–32000.
  • The one public route that can start work is /webhooks/:provider, behind an HMAC signature check and its own tighter rate limit.

What is honestly not covered

Rate limiting is keyed on IP address only

The global throttler keys on req.ip, which is the client's address only when TRUSTED_PROXY_HOPS matches the deployment's proxy count. Keying auth throttling on the identifier as well — so a distributed attacker cannot get a fresh allowance per address against one account — is not implemented.

Application volumes are not backed up

Managed services have scheduled backups. An application's own volumes do not.

The installer has not been run end to end on a fresh machine

It has been syntax-checked and everything it orchestrates is exercised by the test suite.

docker-socket-proxy: evaluated, not adopted

The idea is to put a filtering proxy in front of the Docker socket so the control plane can only make the API calls it needs.

The problem is which calls those are. This platform creates containers with volume mounts, which is sufficient to mount the host filesystem into a container and read anything on the box. A proxy that permits container creation grants root by a slightly longer path; one that forbids it forbids deploying.

It would narrow the surface against a bug while leaving the actual capability intact, at the cost of a component whose failure mode is every deployment breaking at once. Worth revisiting if the Docker API grows a create-with-no-mounts permission.

Trust boundaries with the agent

The agent holds the Docker socket on the machine it runs on — the same access the control plane holds on its own. It is not less privileged; it is the same privilege at the other end of a connection you can cut.

The channel's envelope is validated with zod on arrival, because it comes from a machine the control plane does not run. A result's contents are shaped by ServerDriver and trusted exactly as far as the server they describe.

On this page