Skip to content

fix(install): the low findings of the review on the installer, the image and the data - #21

Merged
Flosk6 merged 6 commits into
masterfrom
fix/install-hardening
Oct 9, 2026
Merged

Flosk6 merged 6 commits into
masterfrom
fix/install-hardening

Conversation

@Flosk6

@Flosk6 Flosk6 commented Oct 9, 2026

Copy link
Copy Markdown
Owner

The low findings of the 2026-10-08 review on the installer, the image and the data directory. The code's findings come in a separate pull request.

The installer

  • No bootstrap token unless SPAWNER_BOOTSTRAP_TOKEN is given: it had every scope, no user and no expiry. Installations that have one keep it.
  • A version older than the installed one is refused, since its database only migrates forward, unless --allow-downgrade after restoring a backup.
  • A SPAWNER_DATA_DIR other than the installation's is refused. So are relative paths, .., and the directories of the system, for both the data and the installation directories, which --purge deletes.
  • An installation whose .env lost SPAWNER_SECRET or POSTGRES_PASSWORD stops the installer, instead of getting new ones that would lock the data out.
  • .env and dns.env are written in one move, and read back with the = their values end with (IFS='=' read dropped it).
  • An upgrade never goes on without a backup: Postgres is started for it.
  • --purge says it deletes the backups too, the DNS credentials typed are not shown, and --tls off warns that it serves plain HTTP on every interface.

The stack and the image

  • Traefik runs without capabilities except NET_BIND_SERVICE, with no-new-privileges. Postgres (1g) and Traefik (512m) have memory limits.
  • The image's code belongs to root, so Spawner, which runs as node, cannot change it. pnpm stays in the build stage, and Prisma's telemetry is off (CHECKPOINT_DISABLE).
  • The data directory is mode 700:
    • rendered compose files (which hold the secret variables), job logs and archived logs are written 0600;
    • route files (no secrets) are written 0644, so Traefik can read them without capabilities, and the entrypoint makes older route files readable.
  • Spawner still runs as uid 1000, which most cloud images give their first user (ubuntu). Changing it would break the sources that containers running as uid 1000 write into. security.md says to treat that account as an admin.
  • The development stack listens on 127.0.0.1, unless SPAWNER_HTTP_BIND says otherwise.

Git

  • GitHub, GitLab and Bitbucket are reached with the host keys they publish, shipped in apps/api/known_hosts and checked against their published fingerprints, even on the first connection; a different key is refused. Other hosts are still trusted on first use.

Upgrading

  • To turn off an existing bootstrap token, empty its line in /opt/spawner/.env.
  • Going back to an older version needs --allow-downgrade, after restoring its backup.

Tests

  • scripts/e2e-engine.sh passes. It reads the data directory through a container, since the directory is now 700, and listens on 0.0.0.0 for its firewall check.
  • scripts/e2e-installer.sh (CI only) now also checks the refused downgrade and data directory, the 700 / 600 / 644 modes, Traefik's capabilities, the code's owner, and the HTTP warning.
  • I checked by hand that ssh reaches github.com with the shipped key without writing to known_hosts, and refuses a different key.
  • shellcheck passes, with two expected notes about redirects under sudo.

Flosk6 added 5 commits October 9, 2026 23:03
…t keys

git trusted every SSH host on first use (accept-new): a machine in the
path of the first connection to github.com could answer in its place,
and be pinned.

- apps/api/known_hosts holds the host keys GitHub (api.github.com/meta),
  GitLab (its docs) and Bitbucket (bitbucket.org/site/ssh) publish,
  checked against their published fingerprints; ssh reads it as its
  global known_hosts, so these hosts are reached with these keys only,
  even the first time, and a different key is refused. Other hosts are
  still trusted on first use, then pinned.
… change

- The data directory is mode 700 (the entrypoint and the installer set
  it): the containers that mount parts of it do not go through it.
  Rendered compose files, which hold the secret variables, job logs and
  archived logs are written 0600; Traefik's route files, which hold no
  secret, 0644, so that Traefik reads them without the capabilities of
  root. The entrypoint makes the route files of older versions readable.
- The code of the image belongs to root: Spawner, which runs as node,
  cannot change it. pnpm stays in the build stage, and Prisma's telemetry
  is off (CHECKPOINT_DISABLE).
… to lose data

Low findings of the 2026-10-08 review on the installer and the stack.

- No bootstrap token unless SPAWNER_BOOTSTRAP_TOKEN is given: it had
  every scope, no user and no expiry. Installations that have one keep
  it.
- A version older than the installed one is refused (its database only
  migrates forward) unless --allow-downgrade, after restoring a backup.
- SPAWNER_DATA_DIR other than the installation's is refused, and so are
  relative paths, "..", and the directories of the system, for the data
  and the installation directories (--purge deletes them).
- An installation whose .env lost SPAWNER_SECRET or POSTGRES_PASSWORD
  stops the installer instead of getting new ones, which would lock the
  data out; .env and dns.env are written in one move, and read back
  with the "=" their values end with.
- An upgrade never goes on without a backup: Postgres is started for it.
- --purge says it deletes the backups too; the DNS credentials typed are
  not shown; --tls off warns that it serves plain HTTP on every
  interface.
- Traefik runs without capabilities but NET_BIND_SERVICE, with
  no-new-privileges; Postgres (1g) and Traefik (512m) have memory limits.
- The development stack listens on 127.0.0.1 unless SPAWNER_HTTP_BIND
  (the engine end-to-end test sets 0.0.0.0, and reads the data
  directory, now 700, through a container).
- The installer's end-to-end test checks the refused downgrade and data
  directory, the modes, Traefik's capabilities and the code's owner.
@Flosk6
Flosk6 force-pushed the fix/install-hardening branch 2 times, most recently from d44c453 to 51499ef Compare October 9, 2026 21:24
Docker Hub limits the anonymous pulls of an address, and the runners share
theirs: every job that pulls (the image's base, Postgres, Traefik, the
examples) failed with 429 Too Many Requests. Google's mirror does not help
the runners' Docker 28 (it gets "unauthorized" from it as a mirror, then
falls back to Docker Hub), only BuildKit.

- The jobs that pull log in to Docker Hub with a read-only token of the
  repository's secrets (DOCKERHUB_USERNAME, DOCKERHUB_TOKEN), and the
  installer's job logs root in too, since install.sh pulls as root. Without
  the secrets (Dependabot's pull requests unless they are given to it too),
  the jobs pull anonymously, as before.
- buildx asks mirror.gcr.io first.
@Flosk6
Flosk6 force-pushed the fix/install-hardening branch from 51499ef to 596e85f Compare October 9, 2026 22:45
@Flosk6
Flosk6 merged commit 38e66a8 into master Oct 9, 2026
4 checks passed
@Flosk6
Flosk6 deleted the fix/install-hardening branch October 9, 2026 22:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant