Skip to content

Latest commit

 

History

History
186 lines (170 loc) · 11.4 KB

File metadata and controls

186 lines (170 loc) · 11.4 KB

Security model

Trust boundary

The manager holds the Docker socket, which is equivalent to root on the host. Everything in front of it is therefore treated as untrusted input.

  • The manager listens only on the internal Docker network. Nginx is the sole public entry point.
  • Accounts, password hashing and sessions belong to better-auth, running as its own service with its own SQLite database. The manager never issues a credential; it validates the session cookie by reading that database.
  • Sessions are HttpOnly cookies with SameSite=Lax, signed with a BETTER_AUTH_SECRET the installer generates per install. There is no default: the auth service refuses to start without it.
  • The Secure attribute follows PUBLIC_URL. It is off until you declare an https:// origin for the panel, because a fresh install is reached over plain HTTP on port 3000 and a browser would discard the cookie.
  • Every cookie-authenticated mutation must carry an Origin matching the scheme, host and port the request was addressed to, so a project that publishes its own port on the panel's hostname does not pass as the panel. The scheme counts because nothing sets HSTS: port 80 only redirects, so a page injected over plain HTTP on the panel's own hostname would otherwise compare equal to it. A port that is the scheme's default is filled in before the comparison, so https://panel and panel:443 are the same origin. The scheme the request arrived on is the X-Forwarded-Proto Nginx sets, falling back to plain HTTP for a manager reached directly on its published port. Browsers cannot forge any of it, so this is the CSRF defense, on both the manager and the auth service. A request carrying no Origin at all is let through: a browser always sends one on a cross-site mutation, so its absence means the caller was not a browser page and is authenticating with a token rather than a cookie. The terminal WebSocket is a GET and runs the same check before it upgrades: a shell is worth more than any mutation.
  • Creating the first administrator additionally requires the setup token the installer generated and printed. Without it a panel that is publicly reachable before its owner reaches it would be claimed by whoever found it first.
  • Sign-up closes permanently once the first administrator exists.
  • Credential endpoints are rate limited twice: better-auth allows five attempts a minute per client, and Nginx throttles the same paths independently.
  • Every state-changing API call is recorded in an audit log with the actor, the path, the resulting status, the credential type and the client address. Rejected cross-origin attempts are recorded too.

Command execution

The platform shells out to git, docker and docker compose. It never builds a shell string: every argument is a separate slice element, so no user value can be reinterpreted as a flag or a command.

Values that reach a command line are validated first:

  • Repository URLs must be http, https, ssh or user@host:path. file:// and ext:: are rejected, since either would let a repository URL read host files or execute a command during clone. Secrets in the URL are rejected too.
  • Git refs reject anything starting with - and any shell metacharacter.
  • Build paths must stay inside the checkout: .., backslashes and anything a path clean would rewrite are rejected, on create and on edit alike, so a build context cannot be aimed at an arbitrary host directory.
  • Image references and volume mount points are matched against a narrow pattern before being written into the generated compose file. Both are user values that end up in YAML, where a space or a # is enough to change which image runs or where the volume lands.
  • Hostnames are validated per DNS label. A single leading wildcard passes validation and the domain is stored, but its certificate is refused until a Cloudflare token is configured, since HTTP-01 cannot validate one and DNS-01 can.

A scheduled task and POST /api/services/{id}/exec are the two places a user's own shell line is executed. It is passed as a single argument to /bin/sh -c, or /bin/bash -c, inside that service's container, which is the same reach the dashboard's terminal already gives an authenticated operator, and never on the host. The manager checks only that the command exists, fits, and carries no null byte; the shell name is narrowed to those two constants before it becomes part of an argv, and for a task the cron expression and timezone must both resolve before it is stored. Task output is written to SQLite unencrypted, so a command that echoes a secret leaves it in the run history; an exec answers its caller and stores nothing but the audit row every mutation gets.

Secrets

  • Environment variables are encrypted with AES-256-GCM before they touch SQLite. A fresh nonce per value means two identical secrets are not linkable.
  • The master key is a 0600 file under /opt/vexdock/secrets, never in the database it protects.
  • Git credentials belong to a service, not to a project: each one is encrypted with the same AES-256-GCM key and only ever decrypted for that service's own clone. The secret is write-only over the API and never read back, so an export or a project move arrives without it.
  • Git credentials never reach the command line. Tokens go through GIT_ASKPASS and SSH keys through a 0600 temporary file removed when the clone finishes. SSH host keys are pinned on first use in /opt/vexdock/secrets/known_hosts, so a later clone from a host whose key changed fails instead of proceeding.
  • A git provider connection stores every secret it holds encrypted with the same key: GitHub's client secret, private key and webhook secret, GitLab's and Gitea's client secret with their access and refresh tokens, Bitbucket's app password or API token. None of them is returned by any endpoint, in any shape, and each is decrypted for two things only: listing that connection's repositories and branches for the source picker, and cloning a service that points at it.
  • The owner and repository a listing takes are interpolated into the host's URL path, so each is validated first: an owner may contain slashes because a GitLab group nests, a repository name may not, and neither may contain ... A token therefore cannot be pointed at a path of someone else's choosing. A clone URL coming back from a host is validated exactly like one typed by hand before it can reach a git clone, and a self-hosted host must be an https origin, so a token is never sent in the clear.
  • A GitHub connection holds a private key rather than a token. The key never leaves the manager: it signs a ten minute JWT, the JWT mints an installation token that lives an hour, and that token is what lists and clones. Its reach is whatever the owner selected while installing, so a compromised panel cannot read a repository the App was not installed on, and removing the connection drops the minted token from memory as well as the row. Its webhook deliveries are verified against the secret GitHub generated for it before a push can start a deployment.
  • GitLab, Gitea and Bitbucket deliveries are not signed App-style, so their deploy endpoints verify nothing and instead deploy only what a service already tracks: the exact owner, repository and branch. An unknown repository is answered 202 ignored, which is the same thing a forged delivery gets.
  • The OAuth state for all three handshakes is the connection row's own id, so a code can only ever be exchanged against the connection that started the flow. The redirect URI is PLATFORM_PUBLIC_URL when it is set and the address the browser reached the panel on otherwise, so an install with no domain yet can still connect. The request-derived form is only ever read from a request that already passed the session and same-origin checks, and what it produces is stored on the connection, so the exchange repeats the exact URI the authorization used rather than whatever a later request happens to claim.
  • Registry credentials are encrypted in the database and also handed to docker login, which writes them to /opt/vexdock/system/docker/config.json in Docker's own format. That directory is on the host so the login survives the manager being recreated on update; treat it as it is, a plaintext credential store readable by root.
  • The variable editors (/variables on a project, an environment or a service) return stored values, because showing them is the point of the page. What protects a secret is the write side: a value that comes back as the mask means "unchanged" and never overwrites the real one, so editing one variable cannot silently blank another. A database service's connection panel (GET /api/services/{id}/database) and a service export asked for with ?secrets=true return values too. All sit behind the same session or token guard, and the export is recorded in the audit log.
  • The generated project .env is written with 0600 permissions, and so is each managed service's own services/<name>.env. A generated database password is stored there and nowhere else, scoped to that one service.
  • A service name becomes a file name, so it is validated against [a-zA-Z0-9][a-zA-Z0-9._-]* first and cannot escape the project directory.
  • /opt/vexdock/.env holds the session secret and the setup token and is 0600.
  • A service export withholds secret values by default, sending their keys with empty values. ?secrets=true includes them, and that request is written to the audit log even though it is a read, because it is the one route that hands over plaintext in bulk. The payload is base64, which is encoding and not encryption, so one taken with secrets is as sensitive as the database it came from.
  • A backup snapshot contains master.key, because without it the encrypted values in the snapshot cannot be read back. Treat a snapshot as being as sensitive as the server.

Destructive actions

Nothing an operator created is pruned or deleted on a schedule; only the bounded observability tables age out on their own, metrics after seven days. The updater removes previous system image tags only when the operator selects that option, and only after the new manager is healthy. Removing a volume requires an explicit confirm=true, and so does pruning unused ones, because a stopped project's database is unreferenced and would otherwise be swept up by a cleanup that reads as routine. The other cleanup targets need no confirmation: an image can be pulled again and a network recreated. Every cleanup screen shows what will be reclaimed before anything is removed. Deleting a container is offered only once it is stopped, so the dashboard never sends the force the API would accept; the volumes stay behind either way, and the next deploy recreates anything the platform manages. Deleting a snapshot removes its directory and nothing else; the name is resolved inside the backups directory first, so it cannot point at a path outside it. Uninstalling keeps application data by default.

Proxy configuration

Generated Nginx configuration is validated with nginx -t before a reload. If validation fails, the previous configuration is restored byte for byte and the error is surfaced to the user, so an invalid domain cannot take down every other site on the server.

Reporting a vulnerability

Open a private security advisory on the repository rather than a public issue.